fix(client): the Atlas was replaying a cache from a build that no longer existed (T-1239)

Ferrath's Global map drew no rivers at native resolution: 375 courses arrived
and 0 were drawn. The report suspected the D-261 length cull or the water
truncation. Both were innocent, and so was the renderer.

The client served the canvas from its own disk cache (T-1183). Every payload
for GJ820Bc predated T-1237 (4e503c356), which replaced one-course-per-D8-hop
with one-course-per-river -- so the map was drawing 375 hop fragments whose
longest run was 106 km, all of them under D-261's read-as-a-line floor. Same
build, same scenario, same 3440x1440, cache the only difference:

  stale   courses=375  runs=180  longest=6.0px  (~106 km)   drawn=0
  cold    courses=73   runs=23   longest=93.2px (~1,644 km) drawn=18

It looked resolution-dependent because it wasn't a resolution at all: 960x540
resolves to an 814x407 canvas, a key never cached, so it missed and re-derived
correctly. 3440x1440 resolves to 1080x540, which had an entry from 2026-08-06.
During the stale capture the server logged no course production whatsoever --
the canvas never came from it.

The cache's only invalidation signal is project.yaml's version, and 4e503c356
changed how canvases are generated without touching it, so hop-shaped entries
stayed valid. All 13 stale entries are stamped 0.4.5. 0.4.6 forces them to miss;
that, not clearing a local directory, is what repairs a player's Atlas.

The harness let this hide for eight days, in two ways now fixed. It ran against
the developer's persistent user:// cache, so a capture could render a canvas
built by a build that no longer existed -- and any golden shot in that window
silently inherited it; user:// is now isolated per run. And it sent server
stderr to /dev/null via an already-unlinked mktemp file, so no tracing from a
capture was ever reachable; the log now lives at .cache/visual-server.log.

The capture readout gained runs= and longest= between courses= and drawn=,
because "375 arrived, 0 drawn" is not one fact but three stages, and telling
them apart is what turned a guess between two suspects into a measurement.

Follow-ups filed: T-1241 (current_schema_version() returns its ?.?.? fallback in
an exported build, so a shipped game never invalidates on version at all) and
T-1242 (nothing enforces the generation-change/version-bump pairing -- this is
the fourth bump forced after the fact).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 23:20:16 +02:00
co-authored by Claude Opus 5
parent 16348e2e89
commit 07ed2a47ab
9 changed files with 244 additions and 11 deletions
+67
View File
@@ -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', '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 ('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 ('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;
+2
View File
@@ -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 ('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 ('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;
+69
View File
@@ -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; 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 ('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 ('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;
+3 -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 " "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 " + "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, 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_width", 0)),
int(summary.get("canvas_height", 0)), int(summary.get("canvas_height", 0)),
int(summary.get("course_count", 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("drawn_course_count", 0)),
int(summary.get("settlement_count", 0)), int(summary.get("settlement_count", 0)),
] ]
@@ -206,6 +206,16 @@ var _body_radius_km: float = 0.0
## (D-261). Entries: {points: PackedVector2Array, terminus: String, taper: bool}. ## (D-261). Entries: {points: PackedVector2Array, terminus: String, taper: bool}.
var _prepared_courses: Array = [] 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 ## Adopt a new canvas + its request frame (world center, rung, extent, body
## radius) — the world->screen projection for every drawn feature depends on ## 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. ## dropped entirely rather than drawn as specks.
func _prepare_courses() -> void: func _prepare_courses() -> void:
_prepared_courses.clear() _prepared_courses.clear()
_runs_built = 0
_longest_run_px = 0.0
if not _canvas is Dictionary: if not _canvas is Dictionary:
return return
var d: Dictionary = _canvas var d: Dictionary = _canvas
@@ -298,6 +310,8 @@ func _flush_run(
) -> void: ) -> void:
if run.size() < 2: if run.size() < 2:
return return
_runs_built += 1
_longest_run_px = maxf(_longest_run_px, _polyline_length_px(run))
_prepared_courses.append( _prepared_courses.append(
{ {
"points": run, "points": run,
@@ -351,6 +365,21 @@ func get_drawn_course_count() -> int:
return _prepared_courses.size() 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: static func _polyline_length_px(pts: PackedVector2Array) -> float:
var total: float = 0.0 var total: float = 0.0
for i in range(1, pts.size()): for i in range(1, pts.size()):
@@ -434,6 +434,10 @@ func get_current_canvas_summary() -> Dictionary:
"drawn_course_count": ( "drawn_course_count": (
_annotation_layer.get_drawn_course_count() if _annotation_layer else 0 _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, "course_count_by_class": course_count_by_class,
"cliff_count": (d.get("cliffs", []) as Array).size(), "cliff_count": (d.get("cliffs", []) as Array).size(),
"settlement_count": _count_distinct_settlements(d), "settlement_count": _count_distinct_settlements(d),
+16 -1
View File
@@ -19,7 +19,22 @@ name: The Settled Reach
# every Global request resolved to a two-cell canvas regardless of viewport. # 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 # 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. # 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 repository: settled-reach
+15
View File
@@ -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 courses
} }
+39 -9
View File
@@ -43,6 +43,13 @@ while [[ $# -gt 0 ]]; do
--filter) shift 2 ;; # Accept and ignore (run-all compat) --filter) shift 2 ;; # Accept and ignore (run-all compat)
--filter=*) shift ;; --filter=*) shift ;;
--interval) INTERVAL="${2:-3}"; shift 2 ;; --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 ;; *) echo "Unknown argument: $1" >&2; exit 2 ;;
esac esac
done 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'))")" 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))")" 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]}')")" 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 # Read scenario names from config
SCENARIOS=($(python3 -c " SCENARIOS=($(python3 -c "
@@ -117,6 +128,21 @@ godot_capture() {
local output="$3" local output="$3"
local extra_args=("${@:4}") 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 \ "${CAPTURE_PREFIX[@]}" "$GODOT" --rendering-driver opengl3 --fixed-fps 60 \
--resolution "$RESOLUTION" \ --resolution "$RESOLUTION" \
--path "$ROOT/client" -s res://tests/visual_capture.gd -- \ --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 with --test-mode --port 0, parse LISTENING:{port}
start_server() { start_server() {
local stdout_log # Keep the server's own output (T-1239). This was `mktemp` + `2>/dev/null`
stdout_log=$(mktemp) # + `rm` as soon as LISTENING was parsed, which meant two things: every
"$SERVER_BIN" --test-mode --port 0 >"$stdout_log" 2>/dev/null & # 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=$! SERVER_PID=$!
local attempts=0 local attempts=0
while [[ $attempts -lt 150 ]]; do while [[ $attempts -lt 150 ]]; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo " Error: server exited unexpectedly" >&2 echo " Error: server exited unexpectedly — see $stdout_log" >&2
rm -f "$stdout_log"
SERVER_PID="" SERVER_PID=""
return 1 return 1
fi fi
if grep -q "^LISTENING:" "$stdout_log" 2>/dev/null; then if grep -q "^LISTENING:" "$stdout_log" 2>/dev/null; then
SERVER_PORT=$(sed -n 's/^LISTENING://p' "$stdout_log") SERVER_PORT=$(sed -n 's/^LISTENING://p' "$stdout_log")
rm -f "$stdout_log" echo " Server started: pid=$SERVER_PID port=$SERVER_PORT log=$stdout_log"
echo " Server started: pid=$SERVER_PID port=$SERVER_PORT"
return 0 return 0
fi fi
sleep 0.1 sleep 0.1
attempts=$((attempts + 1)) attempts=$((attempts + 1))
done 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 kill "$SERVER_PID" 2>/dev/null || true
rm -f "$stdout_log"
SERVER_PID="" SERVER_PID=""
return 1 return 1
} }