diff --git a/.pql/changelog/ticket_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql index 2956d63ed..bf621d34f 100644 --- a/.pql/changelog/ticket_history/2026-08.sql +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -101,3 +101,70 @@ for a failure of the un-summarisation work.', NULL, '2026-08-06 15:59:39', '2026 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', 'status', 'backlog', 'in_progress', NULL, '2026-08-07 11:36:05', '2026-08-07 11:36:05.551', '2026-08-07 11:36:05.551', NULL, '56946f83904827105a9ae52d64a91a1d', 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', 'status', 'in_progress', 'in_progress', NULL, '2026-08-07 11:36:20', '2026-08-07 11:36:20.465', '2026-08-07 11:36:20.465', NULL, 'd57ff6176db9e02ac1e97fb8cb8e609b', 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 ('06FSJWSX11WV3C1XXZEV88Q3P0', 'status', 'backlog', 'in_progress', NULL, '2026-08-07 12:44:21', '2026-08-07 12:44:21.620', '2026-08-07 12:44:21.620', NULL, '013321fe4ccaa686875a688ae6174292', 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 ('06FXF1VDVQDQ8EFGTXX787M90R', 'status', 'backlog', 'in_progress', NULL, '2026-08-13 22:12:59', '2026-08-13 22:12:59.623', '2026-08-13 22:12:59.623', NULL, '679671404e00c4e081f107559acc4c2d', 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 ('06FXF1VDVQDQ8EFGTXX787M90R', 'description', 'Found 2026-08-06 when the visual capture resolution was raised from 960x540 to the native 3440x1440. On Ferrath (GJ820Bc) Global the wire delivers 375 river courses and the annotation layer draws NONE: ''courses=375 drawn=0'' in the view-transform readout. At 960x540 the SAME build drew them as visible strokes, so this is resolution-dependent, not a river-generation failure -- the courses are present and correct on the wire. Suspect the D-261 cull (drop a course below 15 px of on-screen length, 3x the 5 px stroke) or the water-truncation step: T-1237 computes the clipped/culled polylines ONCE on canvas adoption (set_frame) rather than per draw, so if adoption runs against a pre-layout or stale viewport the screen-length measurement is wrong for every course at once -- which matches an all-or-nothing drawn=0 rather than a partial cull. Note the scale moved the RIGHT way for visibility (46.792 km/gridunit at 960x540 vs 35.267 at 3440x1440 -- more gridunits across the body, so a river spans MORE of them), which makes a legitimate cull an unlikely explanation. Reproduce: tests/run-visual --screenshot atlas_GJ820Bc_Global and read the drawn= count.', 'Found 2026-08-06 when the visual capture resolution was raised from 960x540 to the native 3440x1440. On Ferrath (GJ820Bc) Global the wire delivers 375 river courses and the annotation layer draws NONE: ''courses=375 drawn=0'' in the view-transform readout. At 960x540 the SAME build drew them as visible strokes, so this is resolution-dependent, not a river-generation failure -- the courses are present and correct on the wire. Suspect the D-261 cull (drop a course below 15 px of on-screen length, 3x the 5 px stroke) or the water-truncation step: T-1237 computes the clipped/culled polylines ONCE on canvas adoption (set_frame) rather than per draw, so if adoption runs against a pre-layout or stale viewport the screen-length measurement is wrong for every course at once -- which matches an all-or-nothing drawn=0 rather than a partial cull. Note the scale moved the RIGHT way for visibility (46.792 km/gridunit at 960x540 vs 35.267 at 3440x1440 -- more gridunits across the body, so a river spans MORE of them), which makes a legitimate cull an unlikely explanation. Reproduce: tests/run-visual --screenshot atlas_GJ820Bc_Global and read the drawn= count. + +--- +DIAGNOSED 2026-08-14. Not a client rendering bug. Both suspects in the original +report are wrong, and so is the `team: client` label — the defect is a stale +client-side disk cache (T-1183/D-255), invalidated by nothing that changed. + +MEASUREMENT. Same build, same scenario, same native 3440x1440, only the cache +differs: + + stale cache courses=375 runs=180 longest=6.0px (~106 km) drawn=0 + cold cache courses=73 runs=23 longest=93.2px (~1,644 km) drawn=18 + +Server-side, at the same moment the cold capture ran: +`river_cells=615 paths=123 courses=73 longest_path_cells=27 ta_w=512 ta_h=256`. +The network is exactly as designed. During the STALE capture the server logged +NO course production at all — the canvas never came from it. + +WHY IT LOOKED RESOLUTION-DEPENDENT. It isn''t. 960x540 resolves to an 814x407 +canvas, a cache key never written before, so it MISSED and re-derived correctly +(73 courses, 1,644 km trunk). 3440x1440 resolves to 1080x540, which HAD a cached +entry from 2026-08-06 — written before T-1237 (4e503c356) replaced one-course- +per-D8-hop with one-course-per-river. Every `.dat` payload for GJ820Bc predates +that fix; the oldest is 2026-07-28. So the "high resolution" capture was +replaying a pre-fix canvas: 375 hop fragments, none clearing D-261''s +read-as-a-line floor, hence drawn=0. The ticket''s own note that the scale "moved +the RIGHT way for visibility" was correct and was the clue — a legitimate cull +could not explain it, because the cull was never the actor. + +The 375 / 180 / 0 chain also matches, digit for digit, the pre-fix measurement +already written into `_cull_short`''s doc comment. That number was being re-read +off a cache, not re-measured. + +ROOT CAUSE. The disk cache''s only invalidation signal is `project.yaml: version` +(step_canvas_disk_cache.gd `current_schema_version()`). 4e503c356 changed how +canvases are GENERATED but touched only the annotation layer, river_course.rs and +step_canvas.rs — never project.yaml — so every hop-shaped entry stayed "valid". +All 13 stale GJ820Bc entries are stamped 0.4.5, the then-current version. This is +the fourth instance of the same class: project.yaml''s own comments record 0.4.2, +0.4.3, 0.4.4 and 0.4.5 as bumps forced by exactly this failure. + +FIX, three parts: +1. project.yaml 0.4.5 -> 0.4.6, forcing every pre-T-1237 entry to miss. This is + what repairs a real player''s Atlas; clearing a local cache is not a fix. +2. tests/run-visual isolates `user://` per capture (XDG_DATA_HOME into + .cache/visual-user-data, recreated each run). The harness was reading the + developer''s persistent cache, so a capture could render a canvas built by a + build that no longer existed — and every golden shot in that window silently + inherited it. A visual test must exercise the tree it is run against. +3. tests/run-visual keeps server stderr (was `2>/dev/null` into an unlinked + mktemp file). No tracing output from a capture was reachable, which is why + "the server produced nothing" was invisible for eight days. + +Diagnostic left in place: the capture readout now prints `runs=` and `longest=` +between `courses=` and `drawn=`, so the three stages of "arrived -> survived the +water clip -> survived the length cull" can be told apart from a single capture. +That distinction is what made this solvable, and its absence is what made the +original report guess between two wrong suspects. + +FOLLOW-UPS worth their own tickets, not done here: +- `current_schema_version()` reads `res://../project.yaml`, which does not exist + in an exported build — it returns the "?.?.?" fallback, identical for every + build, so a shipped game''s cache would never invalidate on version at all. +- Nothing enforces the generation-change/version-bump pairing. Four occurrences + suggests a check (e.g. a pre-push rule: canvas-generation paths touched => + project.yaml version must move) rather than a fifth comment.', NULL, '2026-08-14 21:13:15', '2026-08-14 21:13:15.779', '2026-08-14 21:13:15.779', NULL, 'a7cfa7d53e62cff64cd95b5738d1ee75', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/ticket_idmap/2026-08.sql b/.pql/changelog/ticket_idmap/2026-08.sql index bd2595dd2..d1f4db206 100644 --- a/.pql/changelog/ticket_idmap/2026-08.sql +++ b/.pql/changelog/ticket_idmap/2026-08.sql @@ -1,2 +1,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXF1VDVQDQ8EFGTXX787M90R', 'T-1239', '2026-08-06 14:43:24.767', '2026-08-06 14:43:24.767', NULL, 'ae5ccb58f885727882f56ce2a32c91e3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FXRSY7QWD8J5X6G1N86WMKEC', 'T-1240', '2026-08-07 13:26:56.708', '2026-08-07 13:26:56.708', NULL, 'dc79a976524b951a73f05532772ef86a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G0495WRHF8ADK82VR8CH1J8R', 'T-1241', '2026-08-14 21:19:17.192', '2026-08-14 21:19:17.192', NULL, '0374016de542b027720ca7a45d39f385', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G04975H3S7GRVQXHKYCR7BKR', 'T-1242', '2026-08-14 21:19:27.624', '2026-08-14 21:19:27.624', NULL, '410d294a5da4ba7435bc3fd4d1e08c95', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql index 342c52833..2605029c8 100644 --- a/.pql/changelog/tickets/2026-08.sql +++ b/.pql/changelog/tickets/2026-08.sql @@ -167,3 +167,72 @@ ticket''s scope, but it will be visible in the same captures, so do not mistake for a failure of the un-summarisation work.', 'in_progress', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:15.245', '2026-08-07 11:36:20.464', NULL, '94ccf78431914ea836ba5d59170a3395', 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 ('06FSJWSX11WV3C1XXZEV88Q3P0', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Biome/relief stipple-density layer on the terrain build (RimWorld technique 4)', 'Follow-up from T-1175''s assess-only item (2026-07-25, stig''s write-up): a stipple/texture-density layer carrying hills/mountains/forest on top of hue, driven by the already-wire-carried vegetation and elev_q L8 planes — presentation-only, D-255(e)-legal (texture-space dithering of already-derived per-cell values, deterministically seeded per cell coordinate + value so it is stable across cache hit/miss; never invents samples between server cells). Would live as a post-process in step_canvas_terrain_layer.gd::rebuild_from_canvas()''s Image.set_pixel build. Design questions to settle at pickup: (a) stipple dots inline in the existing per-cell loop (cheap, same O(wxh) pass) vs a second overlay pass (simpler code, doubles pixel-touch cost); (b) density from vegetation class directly vs a combination with elev_q — relief hachures and forest texture are two different visual grammars in the RimWorld reference, not one slider; (c) own legend toggle (TMP/MST/VEG overlay-bar pattern) vs always-on like the elevation lightness modifier. Reference: docs/design/references/rimworld-world-map-fluency.jpg. Related: T-1175, T-1162 (vegetation patchiness fields), D-255(e).', 'in_progress', 'low', NULL, 'client', NULL, '2026-07-25 13:24:54.152', '2026-08-07 12:44:21.620', NULL, 'c1560b624bd9a9d79fe919f282620f22', 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 ('06FXRSY7QWD8J5X6G1N86WMKEC', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'MIN_WL_BANDS_M octave cutoffs are pre-extent-inversion (stale, currently inert)', 'Found during T-1213 (2026-08-07). MIN_WL_BANDS_M (layer_proxy.rs) is built from 2*DISTRICT_M (4,096 m) and 2*QUARTER_M (1,024 m) -- Nyquist for a sample spacing equal to the rung''s CELL SIZE. That was correct while a rung fixed SPACING; after D-255''s extent inversion a rung fixes EXTENT and District''s spacing is 2048/540 = 3.8 m, so its true Nyquist floor is ~7.6 m. The bands are off by roughly the canvas cell count (~540x). This is the same class of defect as the Global 2x1 wire-extent sentinel the D-258 amendment documents: a constant that was correct under the pre-inversion model and silently outlived it. The code even states the consequence as though intended -- district_profile.rs''s comment reads ''At District''s real Nyquist floor (4,096 m) every VOXEL_OCTAVE_WAVELENGTHS_M entry is truncated, so relief is always exactly 0.0 there''. IMPORTANT SCOPE NOTE, verified before filing: this is currently INERT for the step canvas. step_canvas_viewer._fire_request() calls request_now(body, rung, center, extent) with no min_wl_m, so it defaults to 0, and quantize_min_wl_m(0) returns 0 (the leading sentinel band) -- no truncation happens on the served path. It therefore only affects the legacy layer_proxy district-window consumer. It is NOT the cause of the flat District/Quarter rungs; that is elev_q''s 80 m quantisation (0-100 across MAX_REGION_ELEVATION_KM = 8.0 km), measured at d1 mean 0.02 with the cutoff already disabled. Fix: derive the cutoff from the resolved canvas spacing rather than the rung cell size. Check the layer_proxy consumer''s expectations first -- MIN_WL_BANDS_M is shared, carries a const assert tying band 4 to OCTAVE_WAVELENGTHS_M[3], and is part of the cache key, so a change there is not local.', 'backlog', 'medium', NULL, 'server', 'D-255', '2026-08-07 13:26:56.703', '2026-08-07 13:26:56.703', NULL, '45cb7553effbe05042fe7f3eb09e6483', 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 ('06FXF1VDVQDQ8EFGTXX787M90R', 'bug', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas rivers vanish at native resolution — 375 courses arrive, 0 drawn', 'Found 2026-08-06 when the visual capture resolution was raised from 960x540 to the native 3440x1440. On Ferrath (GJ820Bc) Global the wire delivers 375 river courses and the annotation layer draws NONE: ''courses=375 drawn=0'' in the view-transform readout. At 960x540 the SAME build drew them as visible strokes, so this is resolution-dependent, not a river-generation failure -- the courses are present and correct on the wire. Suspect the D-261 cull (drop a course below 15 px of on-screen length, 3x the 5 px stroke) or the water-truncation step: T-1237 computes the clipped/culled polylines ONCE on canvas adoption (set_frame) rather than per draw, so if adoption runs against a pre-layout or stale viewport the screen-length measurement is wrong for every course at once -- which matches an all-or-nothing drawn=0 rather than a partial cull. Note the scale moved the RIGHT way for visibility (46.792 km/gridunit at 960x540 vs 35.267 at 3440x1440 -- more gridunits across the body, so a river spans MORE of them), which makes a legitimate cull an unlikely explanation. Reproduce: tests/run-visual --screenshot atlas_GJ820Bc_Global and read the drawn= count.', 'in_progress', 'high', NULL, 'client', 'D-261', '2026-08-06 14:43:24.765', '2026-08-13 22:12:59.623', NULL, 'a52ba3f39408c80765dca1eb58e79ae6', 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 ('06FXF1VDVQDQ8EFGTXX787M90R', 'bug', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas rivers vanish at native resolution — 375 courses arrive, 0 drawn', 'Found 2026-08-06 when the visual capture resolution was raised from 960x540 to the native 3440x1440. On Ferrath (GJ820Bc) Global the wire delivers 375 river courses and the annotation layer draws NONE: ''courses=375 drawn=0'' in the view-transform readout. At 960x540 the SAME build drew them as visible strokes, so this is resolution-dependent, not a river-generation failure -- the courses are present and correct on the wire. Suspect the D-261 cull (drop a course below 15 px of on-screen length, 3x the 5 px stroke) or the water-truncation step: T-1237 computes the clipped/culled polylines ONCE on canvas adoption (set_frame) rather than per draw, so if adoption runs against a pre-layout or stale viewport the screen-length measurement is wrong for every course at once -- which matches an all-or-nothing drawn=0 rather than a partial cull. Note the scale moved the RIGHT way for visibility (46.792 km/gridunit at 960x540 vs 35.267 at 3440x1440 -- more gridunits across the body, so a river spans MORE of them), which makes a legitimate cull an unlikely explanation. Reproduce: tests/run-visual --screenshot atlas_GJ820Bc_Global and read the drawn= count. + +--- +DIAGNOSED 2026-08-14. Not a client rendering bug. Both suspects in the original +report are wrong, and so is the `team: client` label — the defect is a stale +client-side disk cache (T-1183/D-255), invalidated by nothing that changed. + +MEASUREMENT. Same build, same scenario, same native 3440x1440, only the cache +differs: + + stale cache courses=375 runs=180 longest=6.0px (~106 km) drawn=0 + cold cache courses=73 runs=23 longest=93.2px (~1,644 km) drawn=18 + +Server-side, at the same moment the cold capture ran: +`river_cells=615 paths=123 courses=73 longest_path_cells=27 ta_w=512 ta_h=256`. +The network is exactly as designed. During the STALE capture the server logged +NO course production at all — the canvas never came from it. + +WHY IT LOOKED RESOLUTION-DEPENDENT. It isn''t. 960x540 resolves to an 814x407 +canvas, a cache key never written before, so it MISSED and re-derived correctly +(73 courses, 1,644 km trunk). 3440x1440 resolves to 1080x540, which HAD a cached +entry from 2026-08-06 — written before T-1237 (4e503c356) replaced one-course- +per-D8-hop with one-course-per-river. Every `.dat` payload for GJ820Bc predates +that fix; the oldest is 2026-07-28. So the "high resolution" capture was +replaying a pre-fix canvas: 375 hop fragments, none clearing D-261''s +read-as-a-line floor, hence drawn=0. The ticket''s own note that the scale "moved +the RIGHT way for visibility" was correct and was the clue — a legitimate cull +could not explain it, because the cull was never the actor. + +The 375 / 180 / 0 chain also matches, digit for digit, the pre-fix measurement +already written into `_cull_short`''s doc comment. That number was being re-read +off a cache, not re-measured. + +ROOT CAUSE. The disk cache''s only invalidation signal is `project.yaml: version` +(step_canvas_disk_cache.gd `current_schema_version()`). 4e503c356 changed how +canvases are GENERATED but touched only the annotation layer, river_course.rs and +step_canvas.rs — never project.yaml — so every hop-shaped entry stayed "valid". +All 13 stale GJ820Bc entries are stamped 0.4.5, the then-current version. This is +the fourth instance of the same class: project.yaml''s own comments record 0.4.2, +0.4.3, 0.4.4 and 0.4.5 as bumps forced by exactly this failure. + +FIX, three parts: +1. project.yaml 0.4.5 -> 0.4.6, forcing every pre-T-1237 entry to miss. This is + what repairs a real player''s Atlas; clearing a local cache is not a fix. +2. tests/run-visual isolates `user://` per capture (XDG_DATA_HOME into + .cache/visual-user-data, recreated each run). The harness was reading the + developer''s persistent cache, so a capture could render a canvas built by a + build that no longer existed — and every golden shot in that window silently + inherited it. A visual test must exercise the tree it is run against. +3. tests/run-visual keeps server stderr (was `2>/dev/null` into an unlinked + mktemp file). No tracing output from a capture was reachable, which is why + "the server produced nothing" was invisible for eight days. + +Diagnostic left in place: the capture readout now prints `runs=` and `longest=` +between `courses=` and `drawn=`, so the three stages of "arrived -> survived the +water clip -> survived the length cull" can be told apart from a single capture. +That distinction is what made this solvable, and its absence is what made the +original report guess between two wrong suspects. + +FOLLOW-UPS worth their own tickets, not done here: +- `current_schema_version()` reads `res://../project.yaml`, which does not exist + in an exported build — it returns the "?.?.?" fallback, identical for every + build, so a shipped game''s cache would never invalidate on version at all. +- Nothing enforces the generation-change/version-bump pairing. Four occurrences + suggests a check (e.g. a pre-push rule: canvas-generation paths touched => + project.yaml version must move) rather than a fifth comment.', 'in_progress', 'high', NULL, 'client', 'D-261', '2026-08-06 14:43:24.765', '2026-08-14 21:13:15.779', NULL, 'ee86b57855c05540234c4b0f04ca2a18', 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 ('06G0495WRHF8ADK82VR8CH1J8R', 'bug', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Atlas disk cache never invalidates in an exported build — current_schema_version() falls back to ''?.?.?''', 'step_canvas_disk_cache.gd::current_schema_version() reads ProjectSettings.globalize_path(''res://'') + ''/../project.yaml''. That resolves to the repo-root file in a dev run (res:// = client/), but an exported build has no project.yaml one level above res://, so the function returns its ''?.?.?'' fallback. Every exported build therefore stamps and compares the SAME sentinel version, which means the schema-version invalidation path — the cache''s only invalidation signal — is inert in a shipped game: a canvas cached by one build is served forever by every later build. Found while diagnosing T-1239, where the same mechanism failed in dev for a different reason (the version simply was not bumped). Fix direction: bake the version into the client at export time (a generated const, ProjectSettings application/config/version, or an exported resource) rather than reading a repo file at runtime. Note test_current_schema_version_reads_project_yaml passes in dev and would not catch this — it asserts the non-fallback path, in the only environment where that path works.', 'backlog', 'medium', NULL, 'client', 'D-255', '2026-08-14 21:19:17.188', '2026-08-14 21:19:17.188', NULL, '7880dfa6b9038360c78843ae9f6ca2e2', 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 ('06G04975H3S7GRVQXHKYCR7BKR', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Enforce the canvas-generation/project.yaml-version pairing — four silent stale-cache regressions and counting', 'project.yaml''s version is the Atlas disk cache''s only invalidation signal, and nothing enforces that a change to canvas GENERATION also moves it. The file''s own comment block now records four bumps forced after the fact by exactly this failure: 0.4.2 (lake_margin_q semantics), 0.4.3 (coast_warp_px at orbital sampling), 0.4.4 (D-255 extent inversion), 0.4.5 (Global sentinel), and now 0.4.6 (T-1237 one-course-per-river, diagnosed as T-1239 eight days after it shipped). The failure is silent and machine-dependent: it reproduces only where a warm cache exists, so the author with a cold checkout sees nothing wrong. Direction: a pre-push check in .config/hooks/pre-push — if the push touches the canvas-generation paths (server/src/atlas/step_canvas.rs, river_course.rs, layer1.rs, district_profile.rs, the client step_canvas layers) and project.yaml''s version line is unchanged in the same range, reject with the reason. Registry-driven like tooling/generator_sources.py rather than a hand-kept path list in the hook. A false positive is cheap (bump the version, entries miss once); a false negative is another week of a wrong map.', 'backlog', 'medium', NULL, 'client', 'D-255', '2026-08-14 21:19:27.624', '2026-08-14 21:19:27.624', NULL, '5386de8d8c8120e17373cc1c1a59a9cd', 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/tests/visual_capture.gd b/client/tests/visual_capture.gd index b7eee6d14..17b7978b2 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 drawn=%d settlements=%d" + + "courses=%d runs=%d longest=%.1fpx drawn=%d settlements=%d" ) % [ scenario_name, @@ -348,6 +348,8 @@ func _log_atlas_view_transform(tree_root: Node, scenario_name: String) -> void: int(summary.get("canvas_width", 0)), int(summary.get("canvas_height", 0)), int(summary.get("course_count", 0)), + int(summary.get("runs_built", 0)), + float(summary.get("longest_run_px", 0.0)), int(summary.get("drawn_course_count", 0)), int(summary.get("settlement_count", 0)), ] diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd index ca48bd42f..591db19ce 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd @@ -206,6 +206,16 @@ var _body_radius_km: float = 0.0 ## (D-261). Entries: {points: PackedVector2Array, terminus: String, taper: bool}. var _prepared_courses: Array = [] +## Preparation stage counts, for telling apart the three ways a river can fail +## to appear (T-1239). "375 courses arrived and 0 were drawn" is not one fact +## but a chain: courses arrive, the water clip splits them into runs, the length +## cull drops the short runs. Without the intermediate counts every failure +## looks identical from a capture, which is how T-1239 sat as "suspect the cull +## OR the water truncation" instead of a measurement. Cheap: three integers +## written once per canvas adoption, never per frame. +var _runs_built: int = 0 +var _longest_run_px: float = 0.0 + ## Adopt a new canvas + its request frame (world center, rung, extent, body ## radius) — the world->screen projection for every drawn feature depends on @@ -249,6 +259,8 @@ func clear_frame() -> void: ## dropped entirely rather than drawn as specks. func _prepare_courses() -> void: _prepared_courses.clear() + _runs_built = 0 + _longest_run_px = 0.0 if not _canvas is Dictionary: return var d: Dictionary = _canvas @@ -298,6 +310,8 @@ func _flush_run( ) -> void: if run.size() < 2: return + _runs_built += 1 + _longest_run_px = maxf(_longest_run_px, _polyline_length_px(run)) _prepared_courses.append( { "points": run, @@ -351,6 +365,21 @@ func get_drawn_course_count() -> int: return _prepared_courses.size() +## Runs the water clip produced, before the length cull (T-1239 diagnostic). +## Read with get_drawn_course_count(): runs_built == 0 means the water clip ate +## everything; runs_built high with drawn 0 means the cull did. +func get_runs_built() -> int: + return _runs_built + + +## The longest run's on-screen length in px, before the cull (T-1239). Compared +## against MIN_COURSE_LENGTH_PX it says whether the threshold was near-miss or +## nowhere close — the difference between "tune the constant" and "the geometry +## is wrong". +func get_longest_run_px() -> float: + return _longest_run_px + + static func _polyline_length_px(pts: PackedVector2Array) -> float: var total: float = 0.0 for i in range(1, pts.size()): 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 cb6b125a7..3d1f79b61 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 @@ -434,6 +434,10 @@ func get_current_canvas_summary() -> Dictionary: "drawn_course_count": ( _annotation_layer.get_drawn_course_count() if _annotation_layer else 0 ), + # T-1239: the two stages between "arrived" and "drawn", so a capture can + # 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, "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 cc12655e9..e812ba63f 100644 --- a/project.yaml +++ b/project.yaml @@ -19,7 +19,22 @@ name: The Settled Reach # every Global request resolved to a two-cell canvas regardless of viewport. # Those entries were also cached under a key that ignored extent, so they # answered every later request forever. 0.4.5 forces them to miss. -version: 0.4.5 +# 0.4.5-tagged canvases carry pre-T-1237 river courses: one course per D8 HOP +# rather than one per river (build_paths walks whole headwater->mouth rivers +# now). The hop-shaped entries survived that fix because it changed only server +# and client code, never this version — so Ferrath Global replayed 375 fragments +# with a 106 km longest run, all of them below D-261's read-as-a-line floor, and +# drew NOTHING, while a cold cache on the same build produced 73 whole rivers +# with a 1,644 km trunk and drew 18. Diagnosed as T-1239. 0.4.6 forces them to +# miss. +# +# THE PATTERN, now four bumps deep: a change to how a canvas is GENERATED is +# only half the change — the other half is this line. Nothing enforces the +# pairing, so a canvas-generation commit that leaves this version alone ships a +# silent regression that reproduces only on machines with a warm cache. The +# capture harness no longer hides it (tests/run-visual isolates user:// per run, +# T-1239), but a real player's cache is still governed by this number alone. +version: 0.4.6 repository: settled-reach diff --git a/server/src/atlas/step_canvas.rs b/server/src/atlas/step_canvas.rs index 84af81b85..f97e8faf7 100644 --- a/server/src/atlas/step_canvas.rs +++ b/server/src/atlas/step_canvas.rs @@ -781,6 +781,21 @@ fn invent_courses_for_canvas( }); } + // T-1239: the network is built once per body on the fixed GRID_W x GRID_H + // working grid, so these counts MUST NOT vary with canvas extent. They were + // observed to (73 courses at one viewport, 375 at another), which is only + // visible if the pre-crop network size is logged next to the post-crop wire + // size — the client can only ever see the latter. + tracing::info!( + river_cells = river_network.river_cells.len(), + paths = paths.len(), + courses = courses.len(), + longest_path_cells = paths.iter().map(|p| p.cells.len()).max().unwrap_or(0), + ta_w = ta.w, + ta_h = ta.h, + "step canvas: river course production" + ); + courses } diff --git a/tests/run-visual b/tests/run-visual index 98b99c950..61f917384 100755 --- a/tests/run-visual +++ b/tests/run-visual @@ -43,6 +43,13 @@ while [[ $# -gt 0 ]]; do --filter) shift 2 ;; # Accept and ignore (run-all compat) --filter=*) shift ;; --interval) INTERVAL="${2:-3}"; shift 2 ;; + # Override the config resolution for ONE ad-hoc capture (T-1239). The + # Atlas derives canvas extent, and therefore metres-per-gridunit, from + # the viewport — so "does this defect depend on resolution?" is a real + # diagnostic question, and answering it by hand-editing the committed + # tests/visual.json invites leaving it edited. Goldens are shot at the + # config resolution; this flag deliberately does not touch them. + --resolution) RESOLUTION_OVERRIDE="${2:-}"; shift 2 ;; *) echo "Unknown argument: $1" >&2; exit 2 ;; esac done @@ -70,6 +77,10 @@ fi GOLDEN_DIR="$ROOT/$(python3 -c "import json; c=json.load(open('$CONFIG')); print(c.get('golden_dir','client/tests/golden/visual'))")" TOLERANCE="$(python3 -c "import json; c=json.load(open('$CONFIG')); print(c.get('tolerance', 5))")" RESOLUTION="$(python3 -c "import json; c=json.load(open('$CONFIG')); r=c.get('resolution',[960,540]); print(f'{r[0]}x{r[1]}')")" +if [[ -n "${RESOLUTION_OVERRIDE:-}" ]]; then + RESOLUTION="$RESOLUTION_OVERRIDE" + echo "Note: resolution overridden to $RESOLUTION (goldens are shot at the config resolution)" >&2 +fi # Read scenario names from config SCENARIOS=($(python3 -c " @@ -117,6 +128,21 @@ godot_capture() { local output="$3" local extra_args=("${@:4}") + # Isolate `user://` per run (T-1239). Godot resolves user:// under + # XDG_DATA_HOME, which is how the Atlas disk cache (D-255, T-1183) persisted + # across captures — including across the server changes that made its + # contents wrong. A capture then rendered a canvas generated by a build that + # no longer existed: Ferrath Global replayed a pre-T-1237 canvas from weeks + # earlier and showed 375 hop-fragments where the live server produces 73 + # whole rivers, and every golden shot in that window silently inherited it. + # A visual test must exercise the code in the tree, so the cache it warms + # must not outlive the run. The dir is recreated fresh each capture; the + # user's real cache at ~/.local/share/godot is never touched. + local user_data="$ROOT/.cache/visual-user-data" + rm -rf "$user_data" + mkdir -p "$user_data" + + XDG_DATA_HOME="$user_data" \ "${CAPTURE_PREFIX[@]}" "$GODOT" --rendering-driver opengl3 --fixed-fps 60 \ --resolution "$RESOLUTION" \ --path "$ROOT/client" -s res://tests/visual_capture.gd -- \ @@ -154,32 +180,36 @@ ensure_server_built() { # Start server with --test-mode --port 0, parse LISTENING:{port} start_server() { - local stdout_log - stdout_log=$(mktemp) - "$SERVER_BIN" --test-mode --port 0 >"$stdout_log" 2>/dev/null & + # Keep the server's own output (T-1239). This was `mktemp` + `2>/dev/null` + # + `rm` as soon as LISTENING was parsed, which meant two things: every + # tracing::warn!/error! the simulation emitted was discarded, and everything + # after startup went to an unlinked file. A server quietly degrading mid- + # capture looked identical to a healthy one — the capture only ever showed + # what the CLIENT thought it received. Now stderr is merged in and the log + # lives at a stable path that survives the run for inspection. + local stdout_log="$ROOT/.cache/visual-server.log" + mkdir -p "$ROOT/.cache" + "$SERVER_BIN" --test-mode --port 0 >"$stdout_log" 2>&1 & SERVER_PID=$! local attempts=0 while [[ $attempts -lt 150 ]]; do if ! kill -0 "$SERVER_PID" 2>/dev/null; then - echo " Error: server exited unexpectedly" >&2 - rm -f "$stdout_log" + echo " Error: server exited unexpectedly — see $stdout_log" >&2 SERVER_PID="" return 1 fi if grep -q "^LISTENING:" "$stdout_log" 2>/dev/null; then SERVER_PORT=$(sed -n 's/^LISTENING://p' "$stdout_log") - rm -f "$stdout_log" - echo " Server started: pid=$SERVER_PID port=$SERVER_PORT" + echo " Server started: pid=$SERVER_PID port=$SERVER_PORT log=$stdout_log" return 0 fi sleep 0.1 attempts=$((attempts + 1)) done - echo " Error: no LISTENING signal after 15s" >&2 + echo " Error: no LISTENING signal after 15s — see $stdout_log" >&2 kill "$SERVER_PID" 2>/dev/null || true - rm -f "$stdout_log" SERVER_PID="" return 1 }