feat(simulation): relief_q — a local relief signal the deep rungs can resolve (T-1213)

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:11:23 +02:00
co-authored by Claude Opus 5
parent 566b566519
commit 5eb394b36f
11 changed files with 178 additions and 5 deletions
+1
View File
@@ -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 ('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);
+1
View File
@@ -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 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); 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 ('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);
@@ -76,6 +76,12 @@ const RUGGEDNESS_FULL_SCALE_Q: int = 4
## has arrived but has not yet flattened into the regional average. ## has arrived but has not yet flattened into the regional average.
const RUGGEDNESS_BASELINE_CELLS: int = 4 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 ## 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 ## raw-array fields a caller needs per cell — built once per arrived canvas
## (see StepCanvasTerrainLayer.build_texture()), not re-decoded per pixel. ## (see StepCanvasTerrainLayer.build_texture()), not re-decoded per pixel.
@@ -88,6 +94,7 @@ class CellPlanes:
var moisture_q: Image var moisture_q: Image
var vegetation: Image var vegetation: Image
var lake_margin_q: Image var lake_margin_q: Image
var relief_q: Image
var glaciation: Image var glaciation: Image
var temp_dc: Array var temp_dc: Array
var width: int 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 ## Clamped at the edges by sampling the centre, so a canvas border neither
## invents relief nor loses it. ## invents relief nor loses it.
static func _ruggedness(planes: CellPlanes, col: int, row: int) -> float: 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 here: int = _l8_value(planes.elev_q, col, row)
var worst: int = 0 var worst: int = 0
var b: int = RUGGEDNESS_BASELINE_CELLS 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 c: int = clampi(col + d.x, 0, planes.width - 1)
var r: int = clampi(row + d.y, 0, planes.height - 1) var r: int = clampi(row + d.y, 0, planes.height - 1)
worst = maxi(worst, absi(_l8_value(planes.elev_q, c, r) - here)) 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 ## Deterministic [0,1) dither for a cell, salted per mark type so the relief and
@@ -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.moisture_q = _decode_l8_plane(canvas.get("moisture_q"))
planes.vegetation = _decode_l8_plane(canvas.get("vegetation")) planes.vegetation = _decode_l8_plane(canvas.get("vegetation"))
planes.lake_margin_q = _decode_l8_plane(canvas.get("lake_margin_q")) 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.glaciation = _decode_l8_plane(canvas.get("glaciation"))
planes.temp_dc = canvas.get("temp_dc", []) planes.temp_dc = canvas.get("temp_dc", [])
return planes return planes
+1
View File
@@ -807,6 +807,7 @@ mod tests {
precipitation_class: PrecipitationClass::Temperate, precipitation_class: PrecipitationClass::Temperate,
slope_q, slope_q,
elev_q, elev_q,
relief_q: 50,
ocean_fraction_q, ocean_fraction_q,
lake_margin_q: 0, lake_margin_q: 0,
river_threshold: 200, river_threshold: 200,
+2
View File
@@ -499,6 +499,7 @@ mod tests {
precipitation_class: PrecipitationClass::Temperate, precipitation_class: PrecipitationClass::Temperate,
slope_q: 5, slope_q: 5,
elev_q: 20, elev_q: 20,
relief_q: 50,
ocean_fraction_q: 15, ocean_fraction_q: 15,
lake_margin_q: 0, lake_margin_q: 0,
river_threshold: 200, river_threshold: 200,
@@ -637,6 +638,7 @@ mod tests {
precipitation_class: PrecipitationClass::Arid, precipitation_class: PrecipitationClass::Arid,
slope_q: 3, slope_q: 3,
elev_q: 10, elev_q: 10,
relief_q: 50,
ocean_fraction_q: 0, // no water at all ocean_fraction_q: 0, // no water at all
lake_margin_q: 0, lake_margin_q: 0,
river_threshold: 300, river_threshold: 300,
+67
View File
@@ -223,6 +223,30 @@ pub struct DistrictProfile {
/// Average of `TerrainAnalysis.elev_pct` × 100 across district cells. /// Average of `TerrainAnalysis.elev_pct` × 100 across district cells.
pub elev_q: i32, pub elev_q: i32,
/// **Local** relief on a FIXED fine metre scale — 0100 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: 0100 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 (0100 scale, integer). /// Ocean fraction for this district (0100 scale, integer).
pub ocean_fraction_q: i32, 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). /// span can replace it when body relief data is carried (T-1032).
const MAX_REGION_ELEVATION_KM: f64 = 8.0; 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 /// Climate tuning constants. `ClimateConstants::default()` holds the embedded
/// values and is the authoritative runtime source **today**; the source-canonical /// values and is the authoritative runtime source **today**; the source-canonical
/// `server/data/climate_constants.toml` mirrors those same values and is the file /// `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 { struct InventedPrimitives {
slope_q: i32, slope_q: i32,
elev_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, 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 // 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 // byte-identical to the pre-T-1162 `elev_pct + scatter` wherever the
// cutoff discipline says it must be. // 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 { InventedPrimitives {
elev_q: (((elev_pct + scatter + relief) * 100.0).round() as i32).clamp(0, 100), 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) slope_q: (((local_slope + ruggedness * (scatter.abs() + relief.abs()) + carve) * 100.0)
.round() as i32) .round() as i32)
.clamp(0, 100), .clamp(0, 100),
@@ -1636,6 +1688,11 @@ fn build_district_profile(
climate: &ClimateConstants, climate: &ClimateConstants,
slope_q: i32, slope_q: i32,
elev_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, ocean_fraction_q: i32,
region_baseline_c: Option<f32>, region_baseline_c: Option<f32>,
basin_direction: BasinDirection, basin_direction: BasinDirection,
@@ -1733,6 +1790,7 @@ fn build_district_profile(
precipitation_class, precipitation_class,
slope_q, slope_q,
elev_q, elev_q,
relief_q,
ocean_fraction_q, ocean_fraction_q,
lake_margin_q, lake_margin_q,
river_threshold, river_threshold,
@@ -1986,6 +2044,7 @@ fn derive_at_metres_with_riparian(
climate, climate,
prims.slope_q, prims.slope_q,
prims.elev_q, prims.elev_q,
prims.relief_q,
prims.ocean_fraction_q, prims.ocean_fraction_q,
region_baseline_c, region_baseline_c,
BasinDirection::default(), BasinDirection::default(),
@@ -2182,6 +2241,14 @@ pub fn derive_orbital_at_metres(
climate, climate,
slope_q, slope_q,
elev_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, ocean_fraction_q,
region_baseline_c, region_baseline_c,
BasinDirection::default(), BasinDirection::default(),
+1
View File
@@ -2381,6 +2381,7 @@ mod tests {
precipitation_class: PrecipitationClass::Arid, precipitation_class: PrecipitationClass::Arid,
slope_q: 0, slope_q: 0,
elev_q: elev, elev_q: elev,
relief_q: 50,
ocean_fraction_q: 0, ocean_fraction_q: 0,
lake_margin_q: 0, lake_margin_q: 0,
river_threshold: 200, river_threshold: 200,
+51 -3
View File
@@ -381,6 +381,24 @@ pub struct EncodedStepCanvas {
/// before the version tag has already forced a fresh fetch. /// before the version tag has already forced a fresh fetch.
#[serde(default)] #[serde(default)]
pub lake_margin_q: EncodedField, pub lake_margin_q: EncodedField,
/// T-1213 — LOCAL sub-district relief on a fixed metre scale (0100, 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) --- // --- Sim-state plane (2 dense fields, see struct doc) ---
pub glaciation: EncodedField, pub glaciation: EncodedField,
pub flooded_q: EncodedField, pub flooded_q: EncodedField,
@@ -469,6 +487,8 @@ struct StepCanvasCell {
glaciation: u8, glaciation: u8,
flooded_q: u8, flooded_q: u8,
lake_margin_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) /// 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 // every non-lake cell, same static-geometry-plane posture as
// `elev_q`/`morphology` (a pure function of position, not sim-state). // `elev_q`/`morphology` (a pure function of position, not sim-state).
lake_margin_q: prof.lake_margin_q.clamp(0, 100) as u8, 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 /// tone source ocean coastlines already get for free from
/// `ocean_fraction_q`. See `district_profile::DistrictProfile::lake_margin_q`. /// `ocean_fraction_q`. See `district_profile::DistrictProfile::lake_margin_q`.
pub lake_margin_q: Vec<u8>, pub lake_margin_q: Vec<u8>,
/// T-1213 — local sub-district relief, the deep rungs' texture source.
pub relief_q: Vec<u8>,
pub courses: Vec<RiverCourse>, pub courses: Vec<RiverCourse>,
pub cliffs: Vec<CliffSegment>, pub cliffs: Vec<CliffSegment>,
} }
@@ -1078,6 +1101,7 @@ pub fn build_step_canvas(
let mut glaciation = vec![0u8; cells]; let mut glaciation = vec![0u8; cells];
let mut flooded_q = vec![0u8; cells]; let mut flooded_q = vec![0u8; cells];
let mut lake_margin_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() { for (row, row_cells) in rows.into_iter().enumerate() {
let base = row * width as usize; let base = row * width as usize;
@@ -1091,6 +1115,7 @@ pub fn build_step_canvas(
glaciation[i] = cell.glaciation; glaciation[i] = cell.glaciation;
flooded_q[i] = cell.flooded_q; flooded_q[i] = cell.flooded_q;
lake_margin_q[i] = cell.lake_margin_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, glaciation,
flooded_q, flooded_q,
lake_margin_q, lake_margin_q,
relief_q,
courses, courses,
cliffs, cliffs,
} }
@@ -1190,6 +1216,7 @@ pub fn encode_step_canvas(raw: &RawStepCanvas) -> EncodedStepCanvas {
values: raw.settlement_id.clone(), values: raw.settlement_id.clone(),
}, },
lake_margin_q: png_encode_u8_plane(raw.width, raw.height, &raw.lake_margin_q), 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), glaciation: png_encode_u8_plane(raw.width, raw.height, &raw.glaciation),
flooded_q: png_encode_u8_plane(raw.width, raw.height, &raw.flooded_q), flooded_q: png_encode_u8_plane(raw.width, raw.height, &raw.flooded_q),
courses: raw.courses.clone(), courses: raw.courses.clone(),
@@ -1213,6 +1240,7 @@ pub fn decode_step_canvas(enc: &EncodedStepCanvas) -> RawStepCanvas {
vegetation: png_decode_u8_plane(&enc.vegetation), vegetation: png_decode_u8_plane(&enc.vegetation),
settlement_id: enc.settlement_id.values.clone(), settlement_id: enc.settlement_id.values.clone(),
lake_margin_q: png_decode_u8_plane(&enc.lake_margin_q), 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), glaciation: png_decode_u8_plane(&enc.glaciation),
flooded_q: png_decode_u8_plane(&enc.flooded_q), flooded_q: png_decode_u8_plane(&enc.flooded_q),
courses: enc.courses.clone(), courses: enc.courses.clone(),
@@ -2149,6 +2177,7 @@ mod tests {
vegetation: png_encode_u8_plane(1, 1, &[0]), vegetation: png_encode_u8_plane(1, 1, &[0]),
settlement_id: EncodedSettlementField { values: vec![0] }, settlement_id: EncodedSettlementField { values: vec![0] },
lake_margin_q: png_encode_u8_plane(1, 1, &[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]), glaciation: png_encode_u8_plane(1, 1, &[0]),
flooded_q: png_encode_u8_plane(1, 1, &[0]), flooded_q: png_encode_u8_plane(1, 1, &[0]),
courses: Vec::new(), courses: Vec::new(),
@@ -2574,9 +2603,7 @@ mod tests {
let spacing = let spacing =
rung.spacing_m(raw.width, raw.height, params.body_radius_km.unwrap_or(0.0)); rung.spacing_m(raw.width, raw.height, params.body_radius_km.unwrap_or(0.0));
let uniq_u8 = |v: &Vec<u8>| { let uniq_u8 = |v: &Vec<u8>| v.iter().collect::<std::collections::BTreeSet<_>>().len();
v.iter().collect::<std::collections::BTreeSet<_>>().len()
};
let span_u8 = |v: &Vec<u8>| match (v.iter().min(), v.iter().max()) { let span_u8 = |v: &Vec<u8>| match (v.iter().min(), v.iter().max()) {
(Some(a), Some(b)) => *b as i32 - *a as i32, (Some(a), Some(b)) => *b as i32 - *a as i32,
_ => 0, _ => 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::<std::collections::BTreeSet<_>>()
.len();
grad += &format!(" RELIEF[uniq {ruq:3} d4mean {:.2}]", rsum / rn.max(1.0));
println!( println!(
"{:8?} {:>9.1} m/gu {}x{} elev[uniq {:3} span {:3}] moist[uniq {:3} span {:3}] \ "{: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}", morph[uniq {:2}] veg[uniq {:2}] glac[uniq {:2}] courses {}{grad}",
+15
View File
@@ -2290,6 +2290,7 @@ mod tests {
precipitation_class: PrecipitationClass::Temperate, precipitation_class: PrecipitationClass::Temperate,
slope_q: 5, slope_q: 5,
elev_q: 20, elev_q: 20,
relief_q: 50,
ocean_fraction_q: 15, ocean_fraction_q: 15,
lake_margin_q: 0, lake_margin_q: 0,
river_threshold: 200, river_threshold: 200,
@@ -2312,6 +2313,7 @@ mod tests {
precipitation_class: PrecipitationClass::Temperate, precipitation_class: PrecipitationClass::Temperate,
slope_q, slope_q,
elev_q: 5, elev_q: 5,
relief_q: 50,
ocean_fraction_q: 90, ocean_fraction_q: 90,
lake_margin_q: 0, lake_margin_q: 0,
river_threshold: 200, river_threshold: 200,
@@ -2858,6 +2860,7 @@ mod tests {
precipitation_class: crate::atlas::district_profile::PrecipitationClass::Temperate, precipitation_class: crate::atlas::district_profile::PrecipitationClass::Temperate,
slope_q: 5, slope_q: 5,
elev_q: 20, // low elevation 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 ocean_fraction_q: 0, // no channel — simpler tile layout for elevation check
lake_margin_q: 0, lake_margin_q: 0,
river_threshold: 200, river_threshold: 200,
@@ -2873,6 +2876,7 @@ mod tests {
precipitation_class: crate::atlas::district_profile::PrecipitationClass::Temperate, precipitation_class: crate::atlas::district_profile::PrecipitationClass::Temperate,
slope_q: 5, 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, ocean_fraction_q: 0,
lake_margin_q: 0, lake_margin_q: 0,
river_threshold: 200, river_threshold: 200,
@@ -2976,6 +2980,7 @@ mod tests {
ocean_fraction_q: 0, ocean_fraction_q: 0,
slope_q: 8, slope_q: 8,
elev_q: 30, elev_q: 30,
relief_q: 50,
moisture_q: 20, moisture_q: 20,
..alluvial_district() ..alluvial_district()
} }
@@ -3057,6 +3062,7 @@ mod tests {
glaciation_grade: GlaciationGrade::Moderate, // grade ≥ 2 glaciation_grade: GlaciationGrade::Moderate, // grade ≥ 2
slope_q: 60, slope_q: 60,
elev_q: 80, elev_q: 80,
relief_q: 50,
ocean_fraction_q: 30, ocean_fraction_q: 30,
moisture_q: 70, moisture_q: 70,
vegetation_class: VegetationClass::Barren, vegetation_class: VegetationClass::Barren,
@@ -3174,6 +3180,7 @@ mod tests {
tectonic_class: TectonicClass::Active, tectonic_class: TectonicClass::Active,
slope_q: 70, slope_q: 70,
elev_q: 60, elev_q: 60,
relief_q: 50,
ocean_fraction_q: 40, ocean_fraction_q: 40,
moisture_q: 50, moisture_q: 50,
vegetation_class: VegetationClass::Barren, vegetation_class: VegetationClass::Barren,
@@ -3259,6 +3266,7 @@ mod tests {
morphology_zone: MorphologyZone::Delta, morphology_zone: MorphologyZone::Delta,
slope_q: 2, slope_q: 2,
elev_q: 4, elev_q: 4,
relief_q: 50,
ocean_fraction_q: 50, ocean_fraction_q: 50,
moisture_q: 80, moisture_q: 80,
vegetation_class: VegetationClass::Scrub, vegetation_class: VegetationClass::Scrub,
@@ -3376,6 +3384,7 @@ mod tests {
morphology_zone: MorphologyZone::DuneStrand, morphology_zone: MorphologyZone::DuneStrand,
slope_q: 8, slope_q: 8,
elev_q: 6, elev_q: 6,
relief_q: 50,
ocean_fraction_q: 30, ocean_fraction_q: 30,
moisture_q: 40, moisture_q: 40,
vegetation_class: VegetationClass::Barren, vegetation_class: VegetationClass::Barren,
@@ -3470,6 +3479,7 @@ mod tests {
tectonic_class: TectonicClass::Active, tectonic_class: TectonicClass::Active,
slope_q: 80, slope_q: 80,
elev_q: 90, elev_q: 90,
relief_q: 50,
ocean_fraction_q: 5, ocean_fraction_q: 5,
moisture_q: 50, moisture_q: 50,
vegetation_class: VegetationClass::Barren, vegetation_class: VegetationClass::Barren,
@@ -3579,6 +3589,7 @@ mod tests {
morphology_zone: MorphologyZone::MeanderReach, morphology_zone: MorphologyZone::MeanderReach,
slope_q: 3, slope_q: 3,
elev_q: 15, elev_q: 15,
relief_q: 50,
ocean_fraction_q: 20, ocean_fraction_q: 20,
moisture_q: 70, moisture_q: 70,
vegetation_class: VegetationClass::Forest, vegetation_class: VegetationClass::Forest,
@@ -3623,6 +3634,7 @@ mod tests {
morphology_zone: MorphologyZone::MeanderReach, morphology_zone: MorphologyZone::MeanderReach,
slope_q: 5, // above wetland slope threshold slope_q: 5, // above wetland slope threshold
elev_q: 15, elev_q: 15,
relief_q: 50,
ocean_fraction_q: 20, ocean_fraction_q: 20,
moisture_q: 55, // below wetland moisture threshold moisture_q: 55, // below wetland moisture threshold
vegetation_class: VegetationClass::Forest, vegetation_class: VegetationClass::Forest,
@@ -3715,6 +3727,7 @@ mod tests {
let shared_district_base = DistrictProfile { let shared_district_base = DistrictProfile {
slope_q: 5, slope_q: 5,
elev_q: 20, elev_q: 20,
relief_q: 50,
ocean_fraction_q: 20, // ensure has_active_channel = true ocean_fraction_q: 20, // ensure has_active_channel = true
moisture_q: 55, moisture_q: 55,
vegetation_class: VegetationClass::Forest, vegetation_class: VegetationClass::Forest,
@@ -3843,6 +3856,7 @@ mod tests {
glaciation_grade: GlaciationGrade::Moderate, glaciation_grade: GlaciationGrade::Moderate,
slope_q: 50, slope_q: 50,
elev_q: 40, elev_q: 40,
relief_q: 50,
ocean_fraction_q: 20, ocean_fraction_q: 20,
moisture_q: 50, moisture_q: 50,
..alluvial_district() ..alluvial_district()
@@ -3878,6 +3892,7 @@ mod tests {
glaciation_grade: GlaciationGrade::Moderate, glaciation_grade: GlaciationGrade::Moderate,
slope_q: 50, slope_q: 50,
elev_q: 40, elev_q: 40,
relief_q: 50,
ocean_fraction_q: 5, ocean_fraction_q: 5,
moisture_q: 30, moisture_q: 30,
vegetation_class: VegetationClass::Barren, vegetation_class: VegetationClass::Barren,
+1
View File
@@ -2268,6 +2268,7 @@ fn make_region(
precipitation_class: precip, precipitation_class: precip,
slope_q, slope_q,
elev_q, elev_q,
relief_q: 50,
ocean_fraction_q, ocean_fraction_q,
lake_margin_q: 0, lake_margin_q: 0,
river_threshold: derive_river_threshold(tectonic, precip), river_threshold: derive_river_threshold(tectonic, precip),