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
@@ -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
@@ -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