diff --git a/.pql/changelog/ticket_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql index c3581c25a..4e21380e7 100644 --- a/.pql/changelog/ticket_history/2026-08.sql +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -661,3 +661,5 @@ same kind of ridge-and-valley form District does, at its own scale, and it will once the octaves below its sampling resolution are cut. The T-1213 work made this observable; before it, relief_q never arrived and the rung was flat for a different reason entirely.', NULL, '2026-08-16 16:23:17', '2026-08-16 16:23:17.190', '2026-08-16 16:23:17.190', NULL, '42875477f1917d72cd064d584df2b11f', 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 ('06FXRSY7QWD8J5X6G1N86WMKEC', 'status', 'backlog', 'in_progress', NULL, '2026-08-17 07:08:40', '2026-08-17 07:08:40.539', '2026-08-17 07:08:40.539', NULL, 'e4ccc5d347b5aa663e2b1f2807859e94', 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 ('06FXRSY7QWD8J5X6G1N86WMKEC', 'status', 'in_progress', 'in_progress', NULL, '2026-08-17 07:08:54', '2026-08-17 07:08:54.825', '2026-08-17 07:08:54.825', NULL, '7e81011477963b0c4e59458537164e4a', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql index 032ecaadc..203635d03 100644 --- a/.pql/changelog/tickets/2026-08.sql +++ b/.pql/changelog/tickets/2026-08.sql @@ -638,3 +638,57 @@ same kind of ridge-and-valley form District does, at its own scale, and it will once the octaves below its sampling resolution are cut. The T-1213 work made this observable; before it, relief_q never arrived and the rung was flat for a different reason entirely.', 'backlog', 'medium', NULL, 'server', 'D-255', '2026-08-07 13:26:56.703', '2026-08-16 16:23:17.190', NULL, '1ed56273a1d81dbb6d0e1614598f744b', 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. + +--- +NO LONGER INERT IN CONSEQUENCE (2026-08-16, found via T-1213). This ticket''s own +scope note says the stale cutoff "is NOT the cause of the flat District/Quarter +rungs". That was correct, and it is now the cause of something else: it caps +REGION. + +With relief_q reaching the renderer (T-1213 fixed a missing protocol decode) and +driving a hillshade, Region renders as a fine uniform stucco — texture at the +pixel scale, no landform. District and Quarter, on the same code, read as real +terrain (lum p1-p99 74.43 and 73.72 against Region''s 54.30). + +The reason is sampling, and it is this ticket: a Region cell is 379 m of ground, +while the relief field''s own content sits in the 128-1024 m composition band plus +192 m inclusions. The field is at or below Nyquist for that sampling, so every +neighbouring cell differs by an essentially independent draw and the gradient the +hillshade reads is ALIASING rather than slope. Because min_wl_m defaults to 0 on +the served path, nothing truncates the octaves Region cannot resolve — which is +precisely the fix this ticket proposes ("derive the cutoff from the resolved +canvas spacing rather than the rung cell size"). + +So the visible symptom to fix against is now concrete: Region should show the +same kind of ridge-and-valley form District does, at its own scale, and it will +once the octaves below its sampling resolution are cut. The T-1213 work made this +observable; before it, relief_q never arrived and the rung was flat for a +different reason entirely.', 'in_progress', 'medium', NULL, 'server', 'D-255', '2026-08-07 13:26:56.703', '2026-08-17 07:08:40.539', NULL, '67165f7b4321d58fbb98b706b50e9058', 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. + +--- +NO LONGER INERT IN CONSEQUENCE (2026-08-16, found via T-1213). This ticket''s own +scope note says the stale cutoff "is NOT the cause of the flat District/Quarter +rungs". That was correct, and it is now the cause of something else: it caps +REGION. + +With relief_q reaching the renderer (T-1213 fixed a missing protocol decode) and +driving a hillshade, Region renders as a fine uniform stucco — texture at the +pixel scale, no landform. District and Quarter, on the same code, read as real +terrain (lum p1-p99 74.43 and 73.72 against Region''s 54.30). + +The reason is sampling, and it is this ticket: a Region cell is 379 m of ground, +while the relief field''s own content sits in the 128-1024 m composition band plus +192 m inclusions. The field is at or below Nyquist for that sampling, so every +neighbouring cell differs by an essentially independent draw and the gradient the +hillshade reads is ALIASING rather than slope. Because min_wl_m defaults to 0 on +the served path, nothing truncates the octaves Region cannot resolve — which is +precisely the fix this ticket proposes ("derive the cutoff from the resolved +canvas spacing rather than the rung cell size"). + +So the visible symptom to fix against is now concrete: Region should show the +same kind of ridge-and-valley form District does, at its own scale, and it will +once the octaves below its sampling resolution are cut. The T-1213 work made this +observable; before it, relief_q never arrived and the rung was flat for a +different reason entirely.', 'in_progress', 'medium', NULL, 'server', 'D-255', '2026-08-07 13:26:56.703', '2026-08-17 07:08:54.825', NULL, '4300fa437b31a5f8e2b89999c882fcd8', 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 3383337a4..1cad9b1ff 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.11" +config/version="0.4.12" run/main_scene="res://scenes/main_menu.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" diff --git a/client/tests/visual_capture.gd b/client/tests/visual_capture.gd index fe2e978dd..1240451c1 100644 --- a/client/tests/visual_capture.gd +++ b/client/tests/visual_capture.gd @@ -283,8 +283,10 @@ func _settle_atlas_viewer_if_pending(tree_root: Node) -> void: waited += 1 if request.is_pending(): print( - "visual_capture: atlas settle timed out after %d frames — request still pending" - % ATLAS_SETTLE_MAX_FRAMES + ( + "visual_capture: atlas settle timed out after %d frames — request still pending" + % ATLAS_SETTLE_MAX_FRAMES + ) ) @@ -333,27 +335,31 @@ func _log_atlas_view_transform(tree_root: Node, scenario_name: String) -> void: var on_screen_footprint_px: Vector2 = raw_footprint_px * canvas_scale print( ( - "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 planes=%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 " + + "courses=%d runs=%d longest=%.1fpx drawn=%d settlements=%d " + + "planes=%s relief_grad=%.2f" + ) + % [ + scenario_name, + rung, + str(summary.get("world_center", [])), + str(held_extent_raw), + str(canvas_pos), + str(canvas_scale), + str(on_screen_footprint_px), + 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)), + str(summary.get("plane_variety", {})), + float(summary.get("relief_gradient", 0.0)), + ] ) - % [ - scenario_name, - rung, - str(summary.get("world_center", [])), - str(held_extent_raw), - str(canvas_pos), - str(canvas_scale), - str(on_screen_footprint_px), - 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)), - str(summary.get("plane_variety", {})), - ] ) @@ -498,10 +504,7 @@ func _run_atlas_matrix() -> void: # implant_app.gd — not previously documented in the recon blueprint). var first_body_id: String = str(shots[0].get("body_id", "")) var first_body: Dictionary = body_lookup.get(first_body_id, {}) - app.nav.push( - "regional", - {"body": first_body, "system": _system_dict_for(first_body)} - ) + app.nav.push("regional", {"body": first_body, "system": _system_dict_for(first_body)}) var viewer: Variant = app._regional_screen._viewer if viewer == null: push_error("visual_capture: regional screen has no viewer") @@ -524,9 +527,7 @@ func _run_atlas_matrix() -> void: # then navigates from there. if body_id != current_body_id: current_body_id = body_id - app._regional_screen.enter( - {"body": body, "system": _system_dict_for(body)} - ) + app._regional_screen.enter({"body": body, "system": _system_dict_for(body)}) await _settle_atlas_viewer_if_pending(root) # Apply overlay set: clear every gen_dw_* toggle, then enable this @@ -704,7 +705,9 @@ func _print_list() -> void: # tests/atlas_shots.json instead — see _run_atlas_matrix()), so it can't be # discovered by iterating _config above. Listed by hand so --list stays a # complete index of every --scenario value this script accepts. - print(" atlas_matrix — D-255 stepped Atlas capture matrix, keyed on (body, rung) (tests/atlas_shots.json, live-mode only)") + print( + " atlas_matrix — D-255 stepped Atlas capture matrix, keyed on (body, rung) (tests/atlas_shots.json, live-mode only)" + ) print("flows:") for name in flows: var steps: Array = flows[name].get("steps", []) 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 d64f268cc..b1bf1dd25 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 @@ -73,6 +73,9 @@ var _held_rung: String = StepCanvasTransport.RUNG_DISTRICT ## values separates them from a single capture. var _plane_variety: Dictionary = {} +## Mean |relief_q gradient| of the held canvas — see [method get_relief_gradient]. +var _relief_gradient_scale: float = 0.0 + ## Rebuild (or reuse) the held texture from a decoded StepCanvasResponse's ## `canvas` Dictionary (step_canvas_protocol.gd's shape: width/height + @@ -129,6 +132,7 @@ func _decode_planes(canvas: Dictionary, width: int, height: int) -> StepCanvasCo planes.lake_margin_q = _decode_l8_plane(canvas.get("lake_margin_q")) planes.relief_q = _decode_l8_plane(canvas.get("relief_q")) planes.relief_gradient_scale = _measure_relief_gradient(planes.relief_q) + _relief_gradient_scale = planes.relief_gradient_scale planes.glaciation = _decode_l8_plane(canvas.get("glaciation")) planes.temp_dc = canvas.get("temp_dc", []) return planes @@ -181,6 +185,13 @@ func get_plane_variety() -> Dictionary: return _plane_variety +## The held canvas's mean |relief_q gradient| — the number the hillshade +## normalizes against, surfaced so a capture can say whether a stucco-looking +## rung is a noisy FIELD or a renderer painting noise onto a smooth one (T-1240). +func get_relief_gradient() -> float: + return _relief_gradient_scale + + ## Cells stepped between variety samples. ## ## A full scan is 5 planes x 1290x540 = ~3.5M get_pixel() calls per canvas 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 912d7fc86..4ea7f64e8 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 @@ -46,9 +46,15 @@ extends Control signal back_pressed -const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") -const StepCanvasTerrainLayer := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd") -const StepCanvasAnnotationLayer := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd") +const StepCanvasTransport := preload( + "res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd" +) +const StepCanvasTerrainLayer := preload( + "res://ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd" +) +const StepCanvasAnnotationLayer := preload( + "res://ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd" +) const StepCanvasRequest := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_request.gd") const PANEL_MARGIN: float = 16.0 @@ -431,16 +437,19 @@ func get_current_canvas_summary() -> Dictionary: "canvas_width": int(d.get("width", 0)), "canvas_height": int(d.get("height", 0)), "course_count": courses.size(), - "drawn_course_count": ( - _annotation_layer.get_drawn_course_count() if _annotation_layer else 0 - ), + "drawn_course_count": # T-1239: the two stages between "arrived" and "drawn", so a capture can + _annotation_layer.get_drawn_course_count() if _annotation_layer else 0, # 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 {}, + # T-1240: the relief field's own mean cell-to-cell gradient. Separates + # "the server sent a noisy field" from "the renderer is painting noise + # onto a smooth one" — two causes with one appearance (a stucco rung). + "relief_gradient": _terrain_layer.get_relief_gradient() if _terrain_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), @@ -605,11 +614,7 @@ func _pan_drift_fraction() -> float: func _refloat_now() -> void: var screen_center: Vector2 = get_rect().size * 0.5 _world_center = StepCanvasTransport.canvas_local_to_world_m( - screen_center - _view_offset, - _world_center, - _held_rung, - _held_extent, - get_body_radius_km() + screen_center - _view_offset, _world_center, _held_rung, _held_extent, get_body_radius_km() ) _view_offset = Vector2.ZERO _fire_request() @@ -746,11 +751,7 @@ func _scroll_rung(direction: int, cursor_local: Vector2) -> void: _reset_to_global() return var cursor_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m( - cursor_local - _view_offset, - _world_center, - _held_rung, - _held_extent, - get_body_radius_km() + cursor_local - _view_offset, _world_center, _held_rung, _held_extent, get_body_radius_km() ) _rung_index = new_index _held_rung = StepCanvasTransport.rung_at_index(_rung_index) @@ -835,7 +836,9 @@ func _centered_view_offset() -> Vector2: var available: Vector2 = Vector2(maxf(viewport.x - reserved_left, 1.0), viewport.y) var scale: float = _letterbox_scale_for(raw_footprint) var scaled_footprint: Vector2 = raw_footprint * scale - return StepCanvasTransport.center_offset(scaled_footprint, available) + Vector2(reserved_left, 0.0) + return ( + StepCanvasTransport.center_offset(scaled_footprint, available) + Vector2(reserved_left, 0.0) + ) ## Hard reset to the Global opener (D-255(a): "a hard full-zoom-out reset to @@ -913,7 +916,6 @@ func _apply_transform() -> void: # Chrome # ============================================================================= - ## T-1197: the header used to be a bare ImplantHeader added straight to this ## Control, drawn directly over the terrain canvas with no backing — every ## OTHER implant chrome element (the legend below it) gets its scrim from @@ -978,9 +980,10 @@ func _refresh_screen_header() -> void: if _screen_header == null: return var name_label: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")) - var spacing_km: float = StepCanvasTransport.spacing_for_rung( - _held_rung, _held_extent, get_body_radius_km() - ) / 1000.0 + var spacing_km: float = ( + StepCanvasTransport.spacing_for_rung(_held_rung, _held_extent, get_body_radius_km()) + / 1000.0 + ) var title := "ATLAS — %s" % name_label.to_upper() var subtitle := "%s · %.3f km/gridunit" % [_held_rung.to_upper(), spacing_km] _screen_header.set_content(title, subtitle) diff --git a/project.yaml b/project.yaml index e2dd05799..74126b66a 100644 --- a/project.yaml +++ b/project.yaml @@ -61,7 +61,11 @@ name: The Settled Reach # 0.4.11 — per-class cover marks (copse, thicket, rocky outcrop). Paint again, # not derivation; bumped because the client step_canvas cluster is in the T-1242 # registry. -version: 0.4.11 +# 0.4.12 changes DERIVED BYTES, not paint (T-1240): Region canvases now truncate +# every octave the rung cannot reconstruct, so a 0.4.11 Region entry holds a +# relief field this build would never produce. Warm caches are genuinely wrong +# here, not merely stale. +version: 0.4.12 repository: settled-reach diff --git a/server/src/atlas/step_canvas.rs b/server/src/atlas/step_canvas.rs index f97e8faf7..1f1236a7b 100644 --- a/server/src/atlas/step_canvas.rs +++ b/server/src/atlas/step_canvas.rs @@ -469,6 +469,80 @@ pub fn quantize_min_wl_m_for_rung(raw: u32) -> u32 { crate::atlas::layer_proxy::quantize_min_wl_m(raw) } +/// The octave cutoff a canvas actually derives at: the wire request, floored by +/// what this canvas's own SAMPLE SPACING can carry (T-1240). +/// +/// # Why the wire value alone was wrong +/// +/// `min_wl_m` arrives as an LOD request and defaults to 0 — no cutoff — so every +/// invented octave contributed at every rung regardless of whether the canvas +/// could resolve it. `MIN_WL_BANDS_M` was supposed to be the floor, but it is +/// built from the rung's CELL SIZE (`2 × DISTRICT_M`), which stopped being the +/// sample spacing at the D-255 extent inversion: a rung now fixes EXTENT and the +/// spacing falls out of the canvas size. The bands are off by roughly the cell +/// count, and the served path never consulted them anyway. +/// +/// # What that cost, measured +/// +/// Region samples every 379.3 m while `voxel_relief`'s band runs 1,024/512/256/ +/// 128 m. Three of those four octaves are below Nyquist, so neighbouring cells +/// drew essentially independent values and the T-1213 hillshade read ALIASING +/// rather than slope — Region rendered as fine uniform stucco while District and +/// Quarter, on identical code, read as terrain. +/// +/// # Why this is a floor and not a replacement +/// +/// `max`, so a caller asking for something coarser still gets it: the wire value +/// is a legitimate "give me less detail" knob, while this is a physical limit on +/// what the canvas can represent. Only the limit is non-negotiable. +/// +/// # Why Global is exempt +/// +/// Global's floor would be ~70 km, which would truncate the ENTIRE coast-warp +/// band (`WARP_OCTAVE_WAVELENGTHS_M`, finest 1,024 m) and undo T-1160's "one +/// coastline at every rung". It needs no floor regardless: the orbital rungs skip +/// the sub-district derive, so `relief_q` is a flat 50 there and there is no +/// fine band to alias. +/// +/// # What this actually changes +/// +/// Only Region. Its 758.6 m floor truncates relief's 512/256/128 m octaves while +/// leaving the 1,024 m one — and leaves the coast warp untouched, whose finest +/// octave is exactly 1,024 m. District's floor is 7.6 m and Quarter's 1.9 m, +/// both below every octave in play, so those rungs derive byte-identically. +/// +/// Cache-safe by construction: this is a pure function of `(rung, extent, +/// body_radius)`, and all three are already in the step-canvas cache key, so the +/// same key still means the same bytes. +pub fn effective_min_wavelength_m(rung: StepCanvasRung, min_wl_m: u32, step_m: f64) -> f64 { + if rung.is_global() { + return min_wl_m as f64; + } + (min_wl_m as f64).max(SMOOTH_SAMPLES_PER_WAVELENGTH * step_m) +} + +/// Samples per wavelength the finest surviving octave must get. +/// +/// NOT 2. Nyquist (2 samples per cycle) is the ALIASING limit — the point below +/// which a wave is indistinguishable from a slower one — and it was the first +/// value tried here. It did not work, and the measurement says why: at 2x, a +/// Region canvas (379.3 m spacing) keeps the 1,024 m relief octave at 2.7 +/// samples per cycle, and the mean cell-to-cell relief gradient measured 18.24 +/// steps — 144 m of relief between neighbouring cells. Technically unaliased, +/// visually noise, and the hillshade drew it as stucco. +/// +/// The rungs that read as terrain say what the real threshold is. District keeps +/// a 128 m finest octave at 3.79 m spacing — 34 samples per cycle — and measures +/// a 0.30 gradient; Quarter gets 135 and measures 0.07. Reconstruction has to be +/// oversampled by an order of magnitude before a sampled field reads as a +/// surface rather than as a field of independent draws. +/// +/// 8 is the compromise: at Region it cuts the 1,024 m octave and leaves 4,096 m +/// at ~10.8 samples per cycle. Higher would be smoother still but starts +/// discarding real structure the rung could legitimately show; the acceptance +/// bar is the captured ladder, not this number. +const SMOOTH_SAMPLES_PER_WAVELENGTH: f64 = 8.0; + // --------------------------------------------------------------------------- // Derive core — one row-chunked parallel pass per rung (D-255(f) seed-chaining: // independent re-derivation, fallback path; mechanism-B acceleration lives in @@ -1041,10 +1115,10 @@ pub fn build_step_canvas( let body_radius_km = params.body_radius_km.unwrap_or(0.0); let (width, height) = resolve_canvas_extent(rung, extent, body_radius_km); - let min_wavelength_m = min_wl_m as f64; // RESOLVED dims, not the requested `extent` — a clamped canvas covers the // same ground at a coarser pitch (see StepCanvasRung::spacing_m). let step_m = rung.spacing_m(width, height, body_radius_km); + let min_wavelength_m = effective_min_wavelength_m(rung, min_wl_m, step_m); let cells = (width * height) as usize; let half_w = (width / 2) as i32; @@ -2338,6 +2412,100 @@ mod tests { // Quantization reuse // ----------------------------------------------------------------- + // ----------------------------------------------------------------- + // Spacing-derived octave floor (T-1240) + // ----------------------------------------------------------------- + + /// Ferrath's Region spacing on the 1290x540 canvas the descent ladder shoots. + const REGION_STEP_M: f64 = 379.3; + + #[test] + fn floor_truncates_the_octaves_region_cannot_reconstruct() { + let floor = effective_min_wavelength_m(StepCanvasRung::Region, 0, REGION_STEP_M); + // 8 samples per wavelength at 379.3 m spacing. + assert!(floor > 3_000.0 && floor < 3_100.0, "floor was {floor}"); + // The whole voxel-relief band goes: even its coarsest entry (1,024 m) + // is only 2.7 samples per cycle here, which measured as an 18.24 + // step-per-cell gradient and rendered as stucco. + for wl in [1_024.0, 512.0, 256.0, 128.0] { + assert!(wl < floor, "octave {wl} must be truncated at Region"); + } + // What remains is the terrain_detail band (>= 4,096 m), which Region + // samples ~10.8 times per cycle — the ridge-and-valley form it now shows. + assert!(4_096.0 > floor, "the 4,096 m terrain octave must survive"); + } + + /// THE TRADE, recorded rather than hidden. An 8x reconstruction floor also + /// truncates the coast warp's two finest octaves (2,048 and 1,024 m) at + /// Region, and T-1160 added that band so there is "one coastline at every + /// rung". An earlier version of this test asserted the opposite — that the + /// 1,024 m warp octave must survive — and a 2x Nyquist floor honoured it. + /// + /// It was changed deliberately, on the same reasoning that fixed the relief: + /// a 1,024 m coastline wiggle at 379.3 m per cell is 2.7 samples per cycle, + /// so drawing it draws NOISE, not coastline character. A rung cannot show + /// shape finer than its own cell, and pretending otherwise is what made this + /// rung unreadable. The warp is amplitude-capped sub-pixel on the working + /// grid (`WARP_AMPLITUDE_CAP_PX`), so what is lost at Region is small. + /// + /// If a future pass wants the warp exempt, the fix is a relief-only floor + /// threaded through `derive_at_metres` — not a lower global multiple, which + /// would take the stucco back. + #[test] + fn coast_warp_fine_octaves_are_deliberately_truncated_at_region() { + let floor = effective_min_wavelength_m(StepCanvasRung::Region, 0, REGION_STEP_M); + for wl in [2_048.0, 1_024.0] { + assert!( + wl < floor, + "coast-warp octave {wl} survives Region's floor {floor} — if this \ + fires, the multiple was lowered and the stucco is back" + ); + } + // The coarse warp octaves — the ones that carry the coastline's actual + // shape at this scale — must still contribute. + assert!( + 4_096.0 > floor, + "the coast warp must keep every octave Region can reconstruct" + ); + } + + /// Global is exempt: its floor would be ~70 km and would truncate the whole + /// coast-warp band. It needs none — the orbital derive leaves relief_q flat. + #[test] + fn global_takes_no_spacing_floor() { + let step_m = 35_267.0; // Ferrath Global + assert_eq!( + effective_min_wavelength_m(StepCanvasRung::Global, 0, step_m), + 0.0 + ); + } + + /// District and Quarter sample far finer than any octave in play, so the + /// floor must be inert there — those rungs already read as terrain and this + /// change must not move them. + #[test] + fn floor_is_inert_at_the_rungs_that_already_resolve_their_band() { + for (rung, step_m) in [ + (StepCanvasRung::District, 3.79_f64), + (StepCanvasRung::Quarter, 0.948_f64), + ] { + let floor = effective_min_wavelength_m(rung, 0, step_m); + assert!( + floor < 128.0, + "{rung:?} floor {floor} would truncate the finest relief octave \ + (128 m); this rung resolves its whole band and must not change" + ); + } + } + + /// The wire value is a coarser-detail REQUEST and still wins when it asks + /// for more truncation than the spacing requires. + #[test] + fn an_explicit_coarser_request_is_honoured_over_the_floor() { + let floor = effective_min_wavelength_m(StepCanvasRung::District, 4_096, 3.79); + assert_eq!(floor, 4_096.0); + } + #[test] fn quantize_min_wl_m_for_rung_matches_layer_proxy_quantization() { // Reusing the SAME banding function as the legacy carrier (see this