From 5eb394b36f5b4c678992a7d89bb95a635f3585e6 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 7 Aug 2026 16:11:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20relief=5Fq=20=E2=80=94=20a?= =?UTF-8?q?=20local=20relief=20signal=20the=20deep=20rungs=20can=20resolve?= =?UTF-8?q?=20(T-1213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The District and Quarter rungs rendered as flat colour, and the cause was not the biome work everyone assumed. Measured on Ferrath through the production canvas builder: at District the mean |elev_q delta| between neighbouring gridunits is 0.02, and NOT ONE PAIR in a 1290x540 frame differs by 2. elev_q spans 0-100 across the body's whole 8 km elevation range, so ONE STEP IS 80 METRES. A District canvas covers 2,048 m of ground, where the rolling relief a walker navigates by is metres to tens of metres -- a fraction of a single step. The sub-district detail IS generated (invent_primitives' scatter and relief bands compute it) and then rounded away. Confirmed by running the diagnostic with the octave cutoff disabled: still 0.02. relief_q carries that same invented fine component against a scale chosen to resolve it: 0-100 about a flat 50, RELIEF_FULL_SCALE_M = 400 m either side, so 8 m per step -- ten times finer than elev_q. elev_q keeps its body-absolute meaning and the Atlas legend stays true. Measured effect, elev_q vs relief_q (distinct values / mean 4-cell delta): Region 49 / 2.38 -> 101 / 21.86 District 10 / 0.08 -> 35 / 0.35 Quarter 8 / 0.02 -> 19 / 0.06 FIXED metre scale, never per-canvas normalization: the value for a piece of ground must not depend on what else is in frame, or the same hillside changes tone as the viewer pans. And it excludes elev_pct deliberately -- this is the departure from the surrounding land, not height above sea level; including the base would re-introduce the body-scale dominance that makes elev_q unusable down here. 50 at the orbital rungs, which skip invent_primitives by design. Nothing is lost: Global and Region still have varied elev_q (101 and 49 distinct values), and the client takes whichever field carries signal via a max, with no rung-name branching. The client's ruggedness driver changes with it. It was an elev_q GRADIENT, which cannot work across rungs -- the same 4-cell delta reads 21.86 at Region and 0.08 at District, so any single full-scale constant either saturates one or vanishes on the other. relief_q states relief outright, so |relief_q - 50| is the answer directly and a fixed metre scale is immune to that by construction. An absent plane reads FLAT, not zero -- 0 on this field means maximum relief BELOW flat, so a payload without it would have stippled the entire map. That is reachable: the field is #[serde(default)] so old-shape payloads decode. Two colorize tests whose fixtures predate the plane caught it. 2004 server tests, 1838 client tests, 0 failed. clippy clean. Co-Authored-By: Claude Opus 5 (1M context) --- .pql/changelog/ticket_idmap/2026-08.sql | 1 + .pql/changelog/tickets/2026-08.sql | 1 + .../atlas/step_canvas/step_canvas_colorize.gd | 37 +++++++++- .../step_canvas/step_canvas_terrain_layer.gd | 1 + server/src/atlas/believability.rs | 1 + server/src/atlas/chunk_context.rs | 2 + server/src/atlas/district_profile.rs | 67 +++++++++++++++++++ server/src/atlas/layer_proxy.rs | 1 + server/src/atlas/step_canvas.rs | 54 ++++++++++++++- server/src/atlas/voxel.rs | 17 ++++- server/tests/derivation_harness.rs | 1 + 11 files changed, 178 insertions(+), 5 deletions(-) diff --git a/.pql/changelog/ticket_idmap/2026-08.sql b/.pql/changelog/ticket_idmap/2026-08.sql index 01665c98b..980bc19b5 100644 --- a/.pql/changelog/ticket_idmap/2026-08.sql +++ b/.pql/changelog/ticket_idmap/2026-08.sql @@ -1 +1,2 @@ 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 OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); +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 OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql index 5719774d3..80c1a5994 100644 --- a/.pql/changelog/tickets/2026-08.sql +++ b/.pql/changelog/tickets/2026-08.sql @@ -166,3 +166,4 @@ rivers vanish on descent. That is tracked separately as T-1239 and is NOT this ticket''s scope, but it will be visible in the same captures, so do not mistake it for a failure of the un-summarisation work.', '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 OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); 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 OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +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 OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd index 7ec96c1fe..b31f13ac2 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd @@ -76,6 +76,12 @@ const RUGGEDNESS_FULL_SCALE_Q: int = 4 ## has arrived but has not yet flattened into the regional average. const RUGGEDNESS_BASELINE_CELLS: int = 4 +## relief_q deviation from flat (50) at which ground is "fully rugged". Each +## step is 8 m, so 25 = 200 m of local relief — a substantial ridge at the +## sub-district scale, and comfortably inside the range the field actually +## uses (Region spans all 101 values; District 35 of them). +const RELIEF_TEXTURE_FULL_SCALE_Q: int = 25 + ## One decoded plane set, pre-extracted from the five L8 Images + the two ## raw-array fields a caller needs per cell — built once per arrived canvas ## (see StepCanvasTerrainLayer.build_texture()), not re-decoded per pixel. @@ -88,6 +94,7 @@ class CellPlanes: var moisture_q: Image var vegetation: Image var lake_margin_q: Image + var relief_q: Image var glaciation: Image var temp_dc: Array var width: int @@ -172,6 +179,32 @@ static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Col ## Clamped at the edges by sampling the centre, so a canvas border neither ## invents relief nor loses it. static func _ruggedness(planes: CellPlanes, col: int, row: int) -> float: + # PRIMARY: relief_q states local relief outright (T-1213) — 50 is flat and + # each step is 8 m — so the ruggedness is just how far from flat this ground + # is. No neighbour comparison: the field already IS the answer, measured on + # a fixed metre scale rather than against whatever happens to be in frame. + # + # This replaced an elev_q GRADIENT, which cannot work across rungs: the same + # 4-cell delta reads 21.86 at Region and 0.08 at District, so any single + # full-scale constant either saturates the one or vanishes on the other. A + # fixed metre scale is immune to that by construction. + # Absent plane reads FLAT, not zero. `_l8_value` zero-fills a missing image, + # and on this field 0 does not mean "no data" — it means maximum relief + # below flat, so a payload without the plane would render every cell fully + # rugged and stipple the entire map. That is reachable in practice: the + # field is `#[serde(default)]` server-side precisely so an old-shape payload + # decodes rather than hard-failing. Caught by two colorize tests whose + # fixtures predate the plane. + var from_relief: float = 0.0 + if planes.relief_q != null: + var rel: int = _l8_value(planes.relief_q, col, row) + from_relief = absf(float(rel) - 50.0) / float(RELIEF_TEXTURE_FULL_SCALE_Q) + + # FALLBACK: the orbital rungs skip the sub-district derive entirely, so + # relief_q is a flat 50 there and would texture nothing. elev_q is still + # richly varied at that scale (101 distinct values on Ferrath at Global), so + # Global keeps the gradient read. Taking the max means each rung is driven + # by whichever field actually carries signal, with no rung-name branching. var here: int = _l8_value(planes.elev_q, col, row) var worst: int = 0 var b: int = RUGGEDNESS_BASELINE_CELLS @@ -179,7 +212,9 @@ static func _ruggedness(planes: CellPlanes, col: int, row: int) -> float: var c: int = clampi(col + d.x, 0, planes.width - 1) var r: int = clampi(row + d.y, 0, planes.height - 1) worst = maxi(worst, absi(_l8_value(planes.elev_q, c, r) - here)) - return clampf(float(worst) / float(RUGGEDNESS_FULL_SCALE_Q), 0.0, 1.0) + var from_elev: float = float(worst) / float(RUGGEDNESS_FULL_SCALE_Q) + + return clampf(maxf(from_relief, from_elev), 0.0, 1.0) ## Deterministic [0,1) dither for a cell, salted per mark type so the relief and 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 e8a0403ea..1bd60608a 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 @@ -112,6 +112,7 @@ func _decode_planes(canvas: Dictionary, width: int, height: int) -> StepCanvasCo planes.moisture_q = _decode_l8_plane(canvas.get("moisture_q")) planes.vegetation = _decode_l8_plane(canvas.get("vegetation")) planes.lake_margin_q = _decode_l8_plane(canvas.get("lake_margin_q")) + planes.relief_q = _decode_l8_plane(canvas.get("relief_q")) planes.glaciation = _decode_l8_plane(canvas.get("glaciation")) planes.temp_dc = canvas.get("temp_dc", []) return planes diff --git a/server/src/atlas/believability.rs b/server/src/atlas/believability.rs index de470178a..e83523157 100644 --- a/server/src/atlas/believability.rs +++ b/server/src/atlas/believability.rs @@ -807,6 +807,7 @@ mod tests { precipitation_class: PrecipitationClass::Temperate, slope_q, elev_q, + relief_q: 50, ocean_fraction_q, lake_margin_q: 0, river_threshold: 200, diff --git a/server/src/atlas/chunk_context.rs b/server/src/atlas/chunk_context.rs index 031eb9f92..827f38087 100644 --- a/server/src/atlas/chunk_context.rs +++ b/server/src/atlas/chunk_context.rs @@ -499,6 +499,7 @@ mod tests { precipitation_class: PrecipitationClass::Temperate, slope_q: 5, elev_q: 20, + relief_q: 50, ocean_fraction_q: 15, lake_margin_q: 0, river_threshold: 200, @@ -637,6 +638,7 @@ mod tests { precipitation_class: PrecipitationClass::Arid, slope_q: 3, elev_q: 10, + relief_q: 50, ocean_fraction_q: 0, // no water at all lake_margin_q: 0, river_threshold: 300, diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index d10c492d8..b39030b0d 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -223,6 +223,30 @@ pub struct DistrictProfile { /// Average of `TerrainAnalysis.elev_pct` × 100 across district cells. pub elev_q: i32, + /// **Local** relief on a FIXED fine metre scale — 0–100 with 50 = flat, + /// each step [`RELIEF_FULL_SCALE_M`] / 50 metres (T-1213, 2026-08-07). + /// + /// WHY THIS IS NOT `elev_q`. `elev_q` spans the body's whole elevation + /// range: 0–100 across [`MAX_REGION_ELEVATION_KM`] = 8 km, so **one step is + /// 80 metres**. That is the right granularity for a whole-body map and + /// useless below it — a District canvas covers 2,048 m of ground, where the + /// rolling relief a walker navigates by is metres to tens of metres, i.e. + /// a fraction of one `elev_q` step. Measured on Ferrath through the + /// production canvas builder: at District the mean |elev_q delta| between + /// neighbouring gridunits is **0.02**, and not one pair in a 1290×540 frame + /// differs by 2. The sub-district detail IS generated — `scatter` and + /// `relief` below compute it — and then rounded away. + /// + /// So this field carries that same invented fine component against a scale + /// chosen to resolve it, rather than against the body. It is the signal the + /// deep rungs draw texture from; `elev_q` keeps its body-absolute meaning + /// and the Atlas legend stays true. + /// + /// Signed about 50 rather than a magnitude, because a hollow and a rise are + /// different ground and a consumer that wants only roughness can take + /// `|relief_q - 50|` — the reverse is not recoverable. + pub relief_q: i32, + /// Ocean fraction for this district (0–100 scale, integer). pub ocean_fraction_q: i32, @@ -722,6 +746,17 @@ pub fn derive_morphology_zone( /// span can replace it when body relief data is carried (T-1032). const MAX_REGION_ELEVATION_KM: f64 = 8.0; +/// Full-scale swing of [`DistrictProfile::relief_q`], in metres either side of +/// flat. 400 m spans everything from a gentle roll to a substantial ridge at +/// the sub-district scale, and puts one quantization step at **8 m** — ten +/// times finer than `elev_q`'s 80 m, which is what makes the deep rungs +/// resolvable at all. +/// +/// A FIXED metre scale on purpose, never per-canvas normalization: the value +/// for a piece of ground must not depend on what else is in frame, or the same +/// hillside would change tone as the viewer pans and the map would shimmer. +const RELIEF_FULL_SCALE_M: f64 = 400.0; + /// Climate tuning constants. `ClimateConstants::default()` holds the embedded /// values and is the authoritative runtime source **today**; the source-canonical /// `server/data/climate_constants.toml` mirrors those same values and is the file @@ -1145,6 +1180,9 @@ const WINDOW_RELIEF_SALT: u64 = 0x5EED_C0DE; struct InventedPrimitives { slope_q: i32, elev_q: i32, + /// Local sub-district relief on the fixed [`RELIEF_FULL_SCALE_M`] scale — + /// see [`DistrictProfile::relief_q`]. + relief_q: i32, ocean_fraction_q: i32, } @@ -1360,8 +1398,22 @@ fn invent_primitives( // 0.0 at every cutoff ≥ 2,048 m (District and coarser), so this sum is // byte-identical to the pre-T-1162 `elev_pct + scatter` wherever the // cutoff discipline says it must be. + // The SAME invented fine component the elevation sum above folds in, kept + // on its own fixed metre scale instead of the body's (T-1213). `scatter` + // and `relief` are normalized fractions of MAX_REGION_ELEVATION_KM, so + // scaling by it gives metres; RELIEF_FULL_SCALE_M then maps +/-400 m onto + // 0..100 about a flat 50, i.e. 8 m per step against elev_q's 80 m. + // + // Deliberately excludes `elev_pct`: this is LOCAL relief, the departure + // from the surrounding land, not height above sea level. Including the + // base would re-introduce exactly the body-scale dominance that makes + // elev_q unusable down here. + let relief_m = (scatter + relief) * MAX_REGION_ELEVATION_KM * 1_000.0; + let relief_q = ((50.0 + (relief_m / RELIEF_FULL_SCALE_M) * 50.0).round() as i32).clamp(0, 100); + InventedPrimitives { elev_q: (((elev_pct + scatter + relief) * 100.0).round() as i32).clamp(0, 100), + relief_q, slope_q: (((local_slope + ruggedness * (scatter.abs() + relief.abs()) + carve) * 100.0) .round() as i32) .clamp(0, 100), @@ -1636,6 +1688,11 @@ fn build_district_profile( climate: &ClimateConstants, slope_q: i32, elev_q: i32, + // Local sub-district relief, fixed metre scale — see + // `DistrictProfile::relief_q`. Inert to every other field's derivation (a + // presentation-tier signal, not a classification input), so it is carried + // straight through to the output. + relief_q: i32, ocean_fraction_q: i32, region_baseline_c: Option, basin_direction: BasinDirection, @@ -1733,6 +1790,7 @@ fn build_district_profile( precipitation_class, slope_q, elev_q, + relief_q, ocean_fraction_q, lake_margin_q, river_threshold, @@ -1986,6 +2044,7 @@ fn derive_at_metres_with_riparian( climate, prims.slope_q, prims.elev_q, + prims.relief_q, prims.ocean_fraction_q, region_baseline_c, BasinDirection::default(), @@ -2182,6 +2241,14 @@ pub fn derive_orbital_at_metres( climate, slope_q, elev_q, + // 50 = flat. The orbital rung skips `invent_primitives` altogether + // (that is the whole point of this path — envelope-only, no scatter, + // no warp), so no sub-district relief is computed here and reporting a + // neutral midpoint is the honest answer rather than a fabricated one. + // Nothing is lost visually: at Global and Region `elev_q` still varies + // properly — 101 and 49 distinct values respectively on Ferrath — and + // it is only below them that it flattens and `relief_q` takes over. + 50, ocean_fraction_q, region_baseline_c, BasinDirection::default(), diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 3e6beda5d..d25a79ed4 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -2381,6 +2381,7 @@ mod tests { precipitation_class: PrecipitationClass::Arid, slope_q: 0, elev_q: elev, + relief_q: 50, ocean_fraction_q: 0, lake_margin_q: 0, river_threshold: 200, diff --git a/server/src/atlas/step_canvas.rs b/server/src/atlas/step_canvas.rs index ac8dbbcbe..bbf0cffab 100644 --- a/server/src/atlas/step_canvas.rs +++ b/server/src/atlas/step_canvas.rs @@ -381,6 +381,24 @@ pub struct EncodedStepCanvas { /// before the version tag has already forced a fresh fetch. #[serde(default)] pub lake_margin_q: EncodedField, + /// T-1213 — LOCAL sub-district relief on a fixed metre scale (0–100, 50 = + /// flat, 8 m per step). The signal the deep rungs draw relief texture from, + /// because `elev_q` structurally cannot: `elev_q` spans the body's whole + /// 8 km elevation range at 80 m per step, and a District canvas covers + /// 2,048 m of ground where real relief is a fraction of one such step — + /// measured at a mean neighbour delta of 0.02 there, with no pair in a + /// 1290×540 frame differing by 2. Same static-geometry plane as `elev_q` + /// (a pure function of position, not sim-state). + /// + /// `50` at the orbital rungs, where `invent_primitives` is skipped by + /// design and no sub-district detail is computed — Global and Region get + /// their texture from `elev_q`, which is still varied at that scale. + /// + /// `#[serde(default)]` for the same reason `lake_margin_q` carries it: an + /// old-shape payload decodes rather than hard-failing, and the version- + /// tagged cache forces a fresh fetch before anything reads it. + #[serde(default)] + pub relief_q: EncodedField, // --- Sim-state plane (2 dense fields, see struct doc) --- pub glaciation: EncodedField, pub flooded_q: EncodedField, @@ -469,6 +487,8 @@ struct StepCanvasCell { glaciation: u8, flooded_q: u8, lake_margin_q: u8, + /// T-1213 local relief, fixed metre scale — see `EncodedStepCanvas::relief_q`. + relief_q: u8, } /// Derive one cell at `(wx, wy)` world metres, dispatching to the D-255(a)/(f) @@ -523,6 +543,7 @@ fn derive_step_canvas_cell( // every non-lake cell, same static-geometry-plane posture as // `elev_q`/`morphology` (a pure function of position, not sim-state). lake_margin_q: prof.lake_margin_q.clamp(0, 100) as u8, + relief_q: prof.relief_q.clamp(0, 100) as u8, } } @@ -551,6 +572,8 @@ pub struct RawStepCanvas { /// tone source ocean coastlines already get for free from /// `ocean_fraction_q`. See `district_profile::DistrictProfile::lake_margin_q`. pub lake_margin_q: Vec, + /// T-1213 — local sub-district relief, the deep rungs' texture source. + pub relief_q: Vec, pub courses: Vec, pub cliffs: Vec, } @@ -1078,6 +1101,7 @@ pub fn build_step_canvas( let mut glaciation = vec![0u8; cells]; let mut flooded_q = vec![0u8; cells]; let mut lake_margin_q = vec![0u8; cells]; + let mut relief_q = vec![0u8; cells]; for (row, row_cells) in rows.into_iter().enumerate() { let base = row * width as usize; @@ -1091,6 +1115,7 @@ pub fn build_step_canvas( glaciation[i] = cell.glaciation; flooded_q[i] = cell.flooded_q; lake_margin_q[i] = cell.lake_margin_q; + relief_q[i] = cell.relief_q; } } @@ -1141,6 +1166,7 @@ pub fn build_step_canvas( glaciation, flooded_q, lake_margin_q, + relief_q, courses, cliffs, } @@ -1190,6 +1216,7 @@ pub fn encode_step_canvas(raw: &RawStepCanvas) -> EncodedStepCanvas { values: raw.settlement_id.clone(), }, lake_margin_q: png_encode_u8_plane(raw.width, raw.height, &raw.lake_margin_q), + relief_q: png_encode_u8_plane(raw.width, raw.height, &raw.relief_q), glaciation: png_encode_u8_plane(raw.width, raw.height, &raw.glaciation), flooded_q: png_encode_u8_plane(raw.width, raw.height, &raw.flooded_q), courses: raw.courses.clone(), @@ -1213,6 +1240,7 @@ pub fn decode_step_canvas(enc: &EncodedStepCanvas) -> RawStepCanvas { vegetation: png_decode_u8_plane(&enc.vegetation), settlement_id: enc.settlement_id.values.clone(), lake_margin_q: png_decode_u8_plane(&enc.lake_margin_q), + relief_q: png_decode_u8_plane(&enc.relief_q), glaciation: png_decode_u8_plane(&enc.glaciation), flooded_q: png_decode_u8_plane(&enc.flooded_q), courses: enc.courses.clone(), @@ -2149,6 +2177,7 @@ mod tests { vegetation: png_encode_u8_plane(1, 1, &[0]), settlement_id: EncodedSettlementField { values: vec![0] }, lake_margin_q: png_encode_u8_plane(1, 1, &[0]), + relief_q: png_encode_u8_plane(1, 1, &[50]), glaciation: png_encode_u8_plane(1, 1, &[0]), flooded_q: png_encode_u8_plane(1, 1, &[0]), courses: Vec::new(), @@ -2574,9 +2603,7 @@ mod tests { let spacing = rung.spacing_m(raw.width, raw.height, params.body_radius_km.unwrap_or(0.0)); - let uniq_u8 = |v: &Vec| { - v.iter().collect::>().len() - }; + let uniq_u8 = |v: &Vec| v.iter().collect::>().len(); let span_u8 = |v: &Vec| match (v.iter().min(), v.iter().max()) { (Some(a), Some(b)) => *b as i32 - *a as i32, _ => 0, @@ -2614,6 +2641,27 @@ mod tests { ); } + // The same 4-cell gradient over relief_q — the T-1213 field that + // exists precisely because elev_q's 80 m step cannot resolve + // sub-district ground. If this does not move where elev_q's does + // not, the field has bought nothing. + let mut rsum = 0.0f64; + let mut rn = 0.0f64; + for r in 0..h { + for c in 0..w.saturating_sub(4) { + let a = raw.relief_q[r * w + c] as i32; + let b = raw.relief_q[r * w + c + 4] as i32; + rsum += (a - b).abs() as f64; + rn += 1.0; + } + } + let ruq = raw + .relief_q + .iter() + .collect::>() + .len(); + grad += &format!(" RELIEF[uniq {ruq:3} d4mean {:.2}]", rsum / rn.max(1.0)); + println!( "{:8?} {:>9.1} m/gu {}x{} elev[uniq {:3} span {:3}] moist[uniq {:3} span {:3}] \ morph[uniq {:2}] veg[uniq {:2}] glac[uniq {:2}] courses {}{grad}", diff --git a/server/src/atlas/voxel.rs b/server/src/atlas/voxel.rs index f6679cb00..b4285c1ef 100644 --- a/server/src/atlas/voxel.rs +++ b/server/src/atlas/voxel.rs @@ -2290,6 +2290,7 @@ mod tests { precipitation_class: PrecipitationClass::Temperate, slope_q: 5, elev_q: 20, + relief_q: 50, ocean_fraction_q: 15, lake_margin_q: 0, river_threshold: 200, @@ -2312,6 +2313,7 @@ mod tests { precipitation_class: PrecipitationClass::Temperate, slope_q, elev_q: 5, + relief_q: 50, ocean_fraction_q: 90, lake_margin_q: 0, river_threshold: 200, @@ -2858,6 +2860,7 @@ mod tests { precipitation_class: crate::atlas::district_profile::PrecipitationClass::Temperate, slope_q: 5, elev_q: 20, // low elevation + relief_q: 50, // flat — this test measures the elevation blend, not relief ocean_fraction_q: 0, // no channel — simpler tile layout for elevation check lake_margin_q: 0, river_threshold: 200, @@ -2872,7 +2875,8 @@ mod tests { glaciation_grade: crate::atlas::district_profile::GlaciationGrade::None, precipitation_class: crate::atlas::district_profile::PrecipitationClass::Temperate, slope_q: 5, - elev_q: 70, // high elevation — makes the blend measurable + elev_q: 70, // high elevation — makes the blend measurable + relief_q: 50, // flat — see the primary district above ocean_fraction_q: 0, lake_margin_q: 0, river_threshold: 200, @@ -2976,6 +2980,7 @@ mod tests { ocean_fraction_q: 0, slope_q: 8, elev_q: 30, + relief_q: 50, moisture_q: 20, ..alluvial_district() } @@ -3057,6 +3062,7 @@ mod tests { glaciation_grade: GlaciationGrade::Moderate, // grade ≥ 2 slope_q: 60, elev_q: 80, + relief_q: 50, ocean_fraction_q: 30, moisture_q: 70, vegetation_class: VegetationClass::Barren, @@ -3174,6 +3180,7 @@ mod tests { tectonic_class: TectonicClass::Active, slope_q: 70, elev_q: 60, + relief_q: 50, ocean_fraction_q: 40, moisture_q: 50, vegetation_class: VegetationClass::Barren, @@ -3259,6 +3266,7 @@ mod tests { morphology_zone: MorphologyZone::Delta, slope_q: 2, elev_q: 4, + relief_q: 50, ocean_fraction_q: 50, moisture_q: 80, vegetation_class: VegetationClass::Scrub, @@ -3376,6 +3384,7 @@ mod tests { morphology_zone: MorphologyZone::DuneStrand, slope_q: 8, elev_q: 6, + relief_q: 50, ocean_fraction_q: 30, moisture_q: 40, vegetation_class: VegetationClass::Barren, @@ -3470,6 +3479,7 @@ mod tests { tectonic_class: TectonicClass::Active, slope_q: 80, elev_q: 90, + relief_q: 50, ocean_fraction_q: 5, moisture_q: 50, vegetation_class: VegetationClass::Barren, @@ -3579,6 +3589,7 @@ mod tests { morphology_zone: MorphologyZone::MeanderReach, slope_q: 3, elev_q: 15, + relief_q: 50, ocean_fraction_q: 20, moisture_q: 70, vegetation_class: VegetationClass::Forest, @@ -3623,6 +3634,7 @@ mod tests { morphology_zone: MorphologyZone::MeanderReach, slope_q: 5, // above wetland slope threshold elev_q: 15, + relief_q: 50, ocean_fraction_q: 20, moisture_q: 55, // below wetland moisture threshold vegetation_class: VegetationClass::Forest, @@ -3715,6 +3727,7 @@ mod tests { let shared_district_base = DistrictProfile { slope_q: 5, elev_q: 20, + relief_q: 50, ocean_fraction_q: 20, // ensure has_active_channel = true moisture_q: 55, vegetation_class: VegetationClass::Forest, @@ -3843,6 +3856,7 @@ mod tests { glaciation_grade: GlaciationGrade::Moderate, slope_q: 50, elev_q: 40, + relief_q: 50, ocean_fraction_q: 20, moisture_q: 50, ..alluvial_district() @@ -3878,6 +3892,7 @@ mod tests { glaciation_grade: GlaciationGrade::Moderate, slope_q: 50, elev_q: 40, + relief_q: 50, ocean_fraction_q: 5, moisture_q: 30, vegetation_class: VegetationClass::Barren, diff --git a/server/tests/derivation_harness.rs b/server/tests/derivation_harness.rs index e3de0ea88..d1c6e397e 100644 --- a/server/tests/derivation_harness.rs +++ b/server/tests/derivation_harness.rs @@ -2268,6 +2268,7 @@ fn make_region( precipitation_class: precip, slope_q, elev_q, + relief_q: 50, ocean_fraction_q, lake_margin_q: 0, river_threshold: derive_river_threshold(tectonic, precip),