From dec5b0bc434aae5b36bd675aa4858d974ab203fc Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 13:08:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20lake=5Fmargin=5Fq=20depth?= =?UTF-8?q?=20band=20=E2=80=94=20lake=20shorelines=20gain=20gradient=20voc?= =?UTF-8?q?abulary=20(T-1188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lake edges rendered as hard step-edges while ocean coasts got multi-tone transition bands: every coastal-transition morphology gate keys on ocean_fraction_q, definitionally 0 inside a lake basin (hypothesis (b) of the ticket; (a) disproven first — a shoreline-crossing sweep at 2048/512/128m plus a 10m fine sweep all land on the same continuous crossing, so positional refinement was never broken). New DistrictProfile.lake_margin_q (0-100 settled-hydrology depth band, from the same bilinear filled/elevation pair the lake test already samples; ceiling calibrated just above the observed p90 depth on GJ338Bd's 5,043 flooded cells), threaded through both derive paths onto EncodedStepCanvas (serde-default for shape tolerance) and down the client: protocol decode, terrain-layer plane, colorize shades Lake cells by depth band instead of elev_q (bedrock-under-water, the wrong signal). project.yaml 0.4.0 -> 0.4.1: the new wire field must invalidate the client disk cache via its version tag (T-1183's D-192 mechanism). Acceptance gates green with the new field (lossless round-trip, cache-hit==cache-miss, every rung). Co-Authored-By: Claude Fable 5 --- .../scripts/protocol/step_canvas_protocol.gd | 16 ++- .../apps/atlas/atlas_overlay_colors.gd | 33 +++++ .../atlas/step_canvas/step_canvas_colorize.gd | 22 +++- .../step_canvas/step_canvas_terrain_layer.gd | 9 +- project.yaml | 2 +- server/src/atlas/believability.rs | 1 + server/src/atlas/chunk_context.rs | 2 + server/src/atlas/district_profile.rs | 114 +++++++++++++++--- server/src/atlas/layer_proxy.rs | 1 + server/src/atlas/step_canvas.rs | 46 ++++++- server/src/atlas/voxel.rs | 4 + server/tests/derivation_harness.rs | 1 + 12 files changed, 218 insertions(+), 33 deletions(-) diff --git a/client/scripts/protocol/step_canvas_protocol.gd b/client/scripts/protocol/step_canvas_protocol.gd index 37681d74c..b03a1fa77 100644 --- a/client/scripts/protocol/step_canvas_protocol.gd +++ b/client/scripts/protocol/step_canvas_protocol.gd @@ -34,10 +34,17 @@ class_name StepCanvasProtocol ## (duplicated here per browse_protocol.gd's own "genuinely standalone" ## precedent, not shared via a Callable). ## EncodedStepCanvas — a map: {width, height, morphology, elev_q, temp_dc, -## moisture_q, vegetation, settlement_id, glaciation, flooded_q, courses, -## cliffs}. The six PNG-per-field dense planes (morphology/elev_q/ -## moisture_q/vegetation/glaciation/flooded_q) are each a map -## {"png_bytes": [...]} — png_bytes is a Rust `Vec` with NO +## moisture_q, vegetation, settlement_id, lake_margin_q, glaciation, +## flooded_q, courses, cliffs}. `lake_margin_q` (T-1188) is a MessagePack +## map key that did not exist before this codec version — an older server +## build's payload simply omits it (`d.get("lake_margin_q")` below returns +## null, decode_png_field() then returns an empty PackedByteArray, the +## same "field absent -> draws as the colorize fallback" posture every +## other optional plane on this wire already has); a client this new +## talking to that old a server is not a supported combination anyway +## (D-192 co-ship). The seven PNG-per-field dense planes (morphology/ +## elev_q/moisture_q/vegetation/lake_margin_q/glaciation/flooded_q) are +## each a map {"png_bytes": [...]} — png_bytes is a Rust `Vec` with NO ## serde_bytes annotation anywhere in this codebase (confirmed: grep for ## serde_bytes across server/src returns nothing), so serde's blanket ## Vec impl serializes it via serialize_seq — a msgpack ARRAY of @@ -173,6 +180,7 @@ static func _decode_encoded_canvas(raw: Variant) -> Variant: if settlement_id_raw is Dictionary else [] ), + "lake_margin_q": decode_png_field(d.get("lake_margin_q")), "glaciation": decode_png_field(d.get("glaciation")), "flooded_q": decode_png_field(d.get("flooded_q")), "courses": d.get("courses", []), diff --git a/client/ui/implant/apps/atlas/atlas_overlay_colors.gd b/client/ui/implant/apps/atlas/atlas_overlay_colors.gd index 27e2d29dd..f911eccc5 100644 --- a/client/ui/implant/apps/atlas/atlas_overlay_colors.gd +++ b/client/ui/implant/apps/atlas/atlas_overlay_colors.gd @@ -177,6 +177,12 @@ const GLACIATION_TINT_ALPHA: Dictionary = { 4: 0.70, # IceCap } +## Lake-margin depth-band lightness endpoints (T-1188) — see +## `lake_margin_lightness()`'s own doc, next to +## `district_window_elevation_lightness()`, for the full rationale. +const LAKE_MARGIN_LIGHTNESS_BASE: float = 0.7 +const LAKE_MARGIN_LIGHTNESS_RANGE: float = 0.3 + static func morphology_color(zone: int) -> Color: if zone >= 0 and zone < MORPHOLOGY_COLORS.size(): @@ -289,6 +295,33 @@ static func district_window_elevation_lightness(base: Color, elev_q: int) -> Col return Color(base.r * lightness, base.g * lightness, base.b * lightness, base.a) +## Lake-margin depth-band lightness (T-1188): the lake-shoreline counterpart +## to `district_window_elevation_lightness()` above. Ocean coastlines get a +## multi-tone transition band for free from `ocean_fraction_q`'s coastal +## morphology gates (TidalFlat/DuneStrand/CliffCoast/Estuarine); a lake basin +## sits above sea level, so `ocean_fraction_q` is always `0` there and none +## of those gates can ever fire — the lake reads as one flat, hard-edged +## color. `lake_margin_q` (`district_profile::DistrictProfile.lake_margin_q`, +## `0` at/near the shoreline crossing, ramping toward `100` at a basin's deep +## centre) is the server-derived depth signal this shades with: multiply RGB +## by `LAKE_MARGIN_LIGHTNESS_BASE + LAKE_MARGIN_LIGHTNESS_RANGE * +## (lake_margin_q/100)` — INVERTED relative to the elevation ramp (deep water +## reads DARKER/richer, not lighter, matching the ocean-side reading that +## `MORPHOLOGY_RGB_OPAQUE`'s own OpenOcean entry is already the darkest +## water-family hue in the table). Caller-gated (only applied to Lake-zone +## cells, matching `district_window_elevation_lightness`'s "caller decides +## when" composition discipline — see step_canvas_colorize.gd's `_base_color`). +## Endpoint constants declared with the rest of the const block near the top +## of this file (gdlint's class-definitions-order rule). +static func lake_margin_lightness(base: Color, lake_margin_q: int) -> Color: + var clamped: int = clampi(lake_margin_q, 0, 100) + var lightness: float = ( + (LAKE_MARGIN_LIGHTNESS_BASE + LAKE_MARGIN_LIGHTNESS_RANGE) + - LAKE_MARGIN_LIGHTNESS_RANGE * (float(clamped) / 100.0) + ) + return Color(base.r * lightness, base.g * lightness, base.b * lightness, base.a) + + ## Vegetation green-family ramp (D-226 T-1124 amendment §5). Marine (6) ## returns Color.TRANSPARENT — "lets the morphology water-blue show through" ## per the amendment; the caller must skip the draw_rect entirely on a 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 34fb96b31..44baa31da 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 @@ -35,16 +35,24 @@ const COLOR_MOISTURE_WET: Color = Color(0.25, 0.72, 0.65, 1.0) const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC -## One decoded plane set, pre-extracted from the four L8 Images + the two +## MorphologyZone::Lake discriminant (D-239 §6 order, T-1046) — matches +## AtlasOverlayColors.MORPHOLOGY_LAKE (duplicated here per this file's own +## "no cross-file constant sharing beyond the preloaded module" precedent, +## same as step_canvas_protocol.gd's status-decode duplication rationale). +const MORPHOLOGY_LAKE: int = 1 + +## 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. ## `temp_dc`/`settlement_id` stay as plain Arrays (their domain doesn't fit -## a byte plane — see step_canvas_protocol.gd's own doc). +## a byte plane — see step_canvas_protocol.gd's own doc). `lake_margin_q` +## added T-1188 (the lake-shoreline tone source). class CellPlanes: var morphology: Image var elev_q: Image var moisture_q: Image var vegetation: Image + var lake_margin_q: Image var glaciation: Image var temp_dc: Array var width: int @@ -72,8 +80,16 @@ static func cell_color(planes: CellPlanes, col: int, row: int, active_toggle: St static func _base_color(planes: CellPlanes, col: int, row: int) -> Color: var zone: int = _l8_value(planes.morphology, col, row) - var eq: int = _l8_value(planes.elev_q, col, row) var base: Color = AtlasOverlayColors.district_window_morphology_color(zone) + # T-1188: Lake cells shade by lake_margin_q (the settled-hydrology depth + # band), NOT elev_q — elev_q on a Lake cell is the ORIGINAL bedrock + # elevation under the water (see DistrictProfile.elev_q's own doc), an + # unrelated signal that would shade the wrong thing. Every other zone + # keeps the existing elev_q lightness read unchanged. + if zone == MORPHOLOGY_LAKE: + var lmq: int = _l8_value(planes.lake_margin_q, col, row) + return AtlasOverlayColors.lake_margin_lightness(base, lmq) + var eq: int = _l8_value(planes.elev_q, col, row) return AtlasOverlayColors.district_window_elevation_lightness(base, eq) 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 937a91091..2e218f4bf 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 @@ -86,9 +86,11 @@ func rebuild_from_canvas(canvas: Dictionary, rung: String, active_toggle: String queue_redraw() -## Decode the four L8-plane PackedByteArrays (via Image.load_png_from_buffer, -## per step_canvas_protocol.gd's own PNG-per-field wire note) plus the two -## raw arrays into one CellPlanes bundle, once per rebuild. +## Decode the five L8-plane PackedByteArrays (morphology/elev_q/moisture_q/ +## vegetation/lake_margin_q — glaciation is the sixth, decoded separately +## below) via Image.load_png_from_buffer (per step_canvas_protocol.gd's own +## PNG-per-field wire note) plus the raw temp_dc array into one CellPlanes +## bundle, once per rebuild. `lake_margin_q` added T-1188. func _decode_planes(canvas: Dictionary, width: int, height: int) -> StepCanvasColorize.CellPlanes: var planes := StepCanvasColorize.CellPlanes.new() planes.width = width @@ -97,6 +99,7 @@ func _decode_planes(canvas: Dictionary, width: int, height: int) -> StepCanvasCo planes.elev_q = _decode_l8_plane(canvas.get("elev_q")) 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.glaciation = _decode_l8_plane(canvas.get("glaciation")) planes.temp_dc = canvas.get("temp_dc", []) return planes diff --git a/project.yaml b/project.yaml index d36c4dc02..1b5d94b4e 100644 --- a/project.yaml +++ b/project.yaml @@ -1,7 +1,7 @@ name: The Settled Reach # Version scheme: 0.{phase}.{n} — phase = active Development Cascade phase (D-166). # Phase 4 (deterministic world generation) is active. -version: 0.4.0 +version: 0.4.1 repository: settled-reach diff --git a/server/src/atlas/believability.rs b/server/src/atlas/believability.rs index 61dd577bb..5b75f10d6 100644 --- a/server/src/atlas/believability.rs +++ b/server/src/atlas/believability.rs @@ -711,6 +711,7 @@ mod tests { slope_q, elev_q, ocean_fraction_q, + lake_margin_q: 0, river_threshold: 200, temperature_c: Some(15.0), moisture_q, diff --git a/server/src/atlas/chunk_context.rs b/server/src/atlas/chunk_context.rs index 84f61b9b9..031eb9f92 100644 --- a/server/src/atlas/chunk_context.rs +++ b/server/src/atlas/chunk_context.rs @@ -500,6 +500,7 @@ mod tests { slope_q: 5, elev_q: 20, ocean_fraction_q: 15, + lake_margin_q: 0, river_threshold: 200, temperature_c: Some(18.0), moisture_q: 55, @@ -637,6 +638,7 @@ mod tests { slope_q: 3, elev_q: 10, ocean_fraction_q: 0, // no water at all + lake_margin_q: 0, river_threshold: 300, temperature_c: Some(35.0), moisture_q: 5, diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index bd547d9d8..f6a5a2052 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -226,6 +226,24 @@ pub struct DistrictProfile { /// Ocean fraction for this district (0–100 scale, integer). pub ocean_fraction_q: i32, + /// Settled-hydrology lake-margin depth band (T-1188, D-227 amendment (4) + /// continued): `0` at/near the shoreline (the `filled == elevation` + /// crossing `lake_from_hydrology_at` gates on), ramping toward `100` as + /// the settled water surface sits deeper above the original bedrock — + /// quantized `((filled - elevation) / LAKE_MARGIN_DEPTH_CEILING * 100)`, + /// clamped. `0` for every non-lake cell (never negative — a cell with no + /// settled water above it has no margin to shade). This is the lake + /// counterpart to `ocean_fraction_q`'s coastal transition-zone gradient: + /// `ocean_fraction_q` is always `0` inside a lake basin (lakes sit ABOVE + /// sea level; `ta.ocean_mask` never fires there), so the existing + /// TidalFlat/DuneStrand/CliffCoast/Estuarine morphology gates — every one + /// keyed on `ocean_fraction_q` — are structurally unreachable at a lake + /// edge. `lake_margin_q` gives the client a continuous tone source for + /// lake shorelines without inventing a second morphology-classification + /// path; see `derive_lake_margin_q`'s doc for the full rationale. + #[serde(default)] + pub lake_margin_q: i32, + /// Per-district river threshold (D-239 §1). Replaces the global /// `RIVER_THRESHOLD = 200` for tile-layer consumers. The drainage constant /// itself is unchanged — this value is what `DistrictProfile` carries downstream. @@ -1522,15 +1540,19 @@ pub fn derive_district_profile( /// is threaded straight through to `derive_vegetation` unchanged, after every /// moisture/temperature/morphology field above it has already been resolved. /// -/// ## Lake sourcing (T-1184, D-227 amendment (4)) +/// ## Lake sourcing (T-1184, D-227 amendment (4); T-1188 depth band) /// /// `lake_from_hydrology` is the caller-computed [`lake_from_hydrology_at`] /// verdict for this position — threaded straight into /// [`derive_morphology_zone`]'s new gate, ahead of its pre-existing -/// `ocean_fraction_q >= 60` heuristic. Computed by the caller (not here) for -/// the same reason `near_perennial_water` is: this function stays free of -/// `TerrainAnalysis`/pixel-position concerns, taking only the already-reduced -/// per-position signals every other field here consumes. +/// `ocean_fraction_q >= 60` heuristic. `lake_margin_q` is the SAME call's +/// depth-band quantization, threaded straight onto the output profile +/// (T-1188 — no further derivation needed; see `lake_from_hydrology_at`'s +/// doc for what it represents and why lakes need it where oceans don't). +/// Computed by the caller (not here) for the same reason `near_perennial_water` +/// is: this function stays free of `TerrainAnalysis`/pixel-position concerns, +/// taking only the already-reduced per-position signals every other field +/// here consumes. #[allow(clippy::too_many_arguments)] fn build_district_profile( seed: SeedChain, @@ -1546,6 +1568,7 @@ fn build_district_profile( min_wavelength_m: f64, near_perennial_water: bool, lake_from_hydrology: bool, + lake_margin_q: i32, ) -> DistrictProfile { let tectonic_class = derive_tectonic_class(body_params); @@ -1635,6 +1658,7 @@ fn build_district_profile( slope_q, elev_q, ocean_fraction_q, + lake_margin_q, river_threshold, temperature_c, moisture_q, @@ -1876,7 +1900,9 @@ fn derive_at_metres_with_riparian( // T-1184: the settled-hydrology lake test, sampled at the SAME (px, py) // fractional working-grid position every other envelope field here reads // — the continuous filled-surface comparison (D-227 amendment (4)). - let lake_from_hydrology = lake_from_hydrology_at(ta, px, py); + // T-1188: the same sample also yields the depth-band tone source + // (`lake_margin_q`) lake shorelines were missing. + let (lake_from_hydrology, lake_margin_q) = lake_from_hydrology_at(ta, px, py); build_district_profile( seed, @@ -1892,6 +1918,7 @@ fn derive_at_metres_with_riparian( min_wavelength_m, near_perennial_water, lake_from_hydrology, + lake_margin_q, ) } @@ -2008,7 +2035,8 @@ pub fn derive_orbital_at_metres( // T-1184: same continuous filled-surface comparison every rung samples, // at the orbital rung's own (px, py) — lake edges refine at Region // spacing exactly as they do at every finer rung (D-227 amendment (4)). - let lake_from_hydrology = lake_from_hydrology_at(ta, px, py); + // T-1188: the same sample also yields the depth-band tone source. + let (lake_from_hydrology, lake_margin_q) = lake_from_hydrology_at(ta, px, py); build_district_profile( seed, @@ -2039,6 +2067,7 @@ pub fn derive_orbital_at_metres( // spacing and could never fire (Ruling 4e). false, lake_from_hydrology, + lake_margin_q, ) } @@ -2072,29 +2101,74 @@ pub(crate) fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f a + (b - a) * ty } +/// Saturation ceiling for [`lake_from_hydrology_at`]'s depth-band quantization, +/// in the SAME `[0.0, 1.0]` normalized elevation-fraction units as +/// `HydrologySample.filled`/`elevation` (NOT metres — no per-body elevation +/// span is threaded to this call site, matching `ocean_fraction_q`'s own +/// fraction-domain quantization one scope up). T-1188 calibration (GJ338Bd's +/// `wiki`-committed lake, `believability-v1` seed): sampled 5,043 flooded +/// working-grid cells, depth (`filled - elevation`) p50 ≈ 0.0083, p90 ≈ +/// 0.058, max ≈ 0.116 — this ceiling sits just above the observed p90 so +/// most real basins use the full 0–100 range instead of clipping early +/// (a shallow margin near the shore reads near-0, a basin's deep centre +/// saturates to 100 — exactly the "deep water reads darker/richer" reading +/// the ocean-side `ocean_fraction_q >= 80` OpenOcean floor already assumes). +/// A tuning constant, not a measured physical limit — revisit if a +/// lore-anchored body ships a dramatically deeper basin. +const LAKE_MARGIN_DEPTH_CEILING: f32 = 0.06; + /// The T-1184 settled-hydrology lake test (D-227 amendment (4) / D-255(f) -/// mechanism B): `true` when a bilinear sample of the settled filled-surface -/// field strictly exceeds a bilinear sample of the original elevation at the -/// SAME fractional working-grid position — the continuous comparison that -/// makes lake edges refine with rung exactly like coastlines, rather than -/// projecting `HydrologyResult.basins[*].cells` membership as a discrete, -/// non-refining lookup (explicitly rejected, see this function's callers' -/// docs). `false` when `ta.hydrology` is `None` (no solve available for this -/// analysis — every caller must already treat `false` here as "fall through -/// to the `ocean_fraction_q` heuristic", never as an error). +/// mechanism B) PLUS its T-1188 depth-band extension. Returns +/// `(is_lake, lake_margin_q)`: +/// +/// - `is_lake` — `true` when a bilinear sample of the settled filled-surface +/// field strictly exceeds a bilinear sample of the original elevation at +/// the SAME fractional working-grid position — the continuous comparison +/// that makes lake edges refine with rung exactly like coastlines, rather +/// than projecting `HydrologyResult.basins[*].cells` membership as a +/// discrete, non-refining lookup (explicitly rejected, see this function's +/// callers' docs). `false` when `ta.hydrology` is `None` (no solve +/// available for this analysis — every caller must already treat `false` +/// here as "fall through to the `ocean_fraction_q` heuristic", never as an +/// error). +/// - `lake_margin_q` — `0` when `!is_lake` (a non-lake cell has no margin to +/// shade); otherwise the settled depth `(filled - original)`, quantized +/// against [`LAKE_MARGIN_DEPTH_CEILING`] to `[0, 100]`. T-1188: this is +/// the continuous tone source lake shorelines were missing — +/// `ocean_fraction_q` is definitionally `0` throughout a lake basin (lakes +/// sit above sea level; `ta.ocean_mask` never fires there), so every +/// coastal-transition morphology gate (TidalFlat, DuneStrand, CliffCoast, +/// Estuarine — all keyed on `ocean_fraction_q >= N`) is structurally +/// unreachable at a lake edge even though the underlying position +/// sampling refines correctly with rung (verified: a shoreline-crossing +/// sweep at district/quarter/block spacing lands on the exact same +/// continuous world-metres crossing at every rung, and a 10 m fine sweep +/// confirms sub-block precision — the T-1188 hypothesis (a) positional +/// check). `lake_margin_q` fixes the PRESENTATION gap (hypothesis (b)) +/// without touching that already-correct positional refinement. /// /// Both `elevation` and `filled` are sampled via the SAME `bilinear` helper /// `ocean_fraction_q`'s own `ta.elev_pct`/`ta.ocean_mask` reads already use at /// every derive-core call site (T-1178/T-1154's per-cell rate numbers already /// include equivalent-cost sampling in the measured per-rung budget — no new -/// cost category, per the workshop's own pipeline-slot ruling). -fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> bool { +/// cost category, per the workshop's own pipeline-slot ruling); computing +/// both `is_lake` and `lake_margin_q` from the one bilinear pair costs +/// nothing beyond the pre-existing sample. +fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> (bool, i32) { let Some(h) = ta.hydrology.as_ref() else { - return false; + return (false, 0); }; let filled = bilinear(&h.filled, ta.w, ta.h, px, py); let original = bilinear(&h.elevation, ta.w, ta.h, px, py); - filled > original + let is_lake = filled > original; + let lake_margin_q = if is_lake { + (((filled - original) / LAKE_MARGIN_DEPTH_CEILING) * 100.0) + .round() + .clamp(0.0, 100.0) as i32 + } else { + 0 + }; + (is_lake, lake_margin_q) } /// Bilinear interpolation of a boolean mask as a 0–1 fraction (for ocean coverage). diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 61ceb77cd..6fbb50f9c 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -2441,6 +2441,7 @@ mod tests { slope_q: 0, elev_q: elev, ocean_fraction_q: 0, + lake_margin_q: 0, river_threshold: 200, temperature_c: Some(10.0), moisture_q: 50, diff --git a/server/src/atlas/step_canvas.rs b/server/src/atlas/step_canvas.rs index 1a36320ab..050108f50 100644 --- a/server/src/atlas/step_canvas.rs +++ b/server/src/atlas/step_canvas.rs @@ -203,7 +203,13 @@ pub enum StepCanvasStatus { /// than raw dense msgpack, and faster to encode/decode than every /// alternative measured). `width`/`height` are carried on /// [`EncodedStepCanvas`] (shared by every field), not duplicated per field. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// +/// `Default` (T-1188): needed for `#[serde(default)]` on newly-added +/// `EncodedStepCanvas` fields (`lake_margin_q`) so an old-shape payload +/// missing the field still deserializes — an empty `png_bytes` decodes via +/// `png_decode_u8_plane` to an all-zero plane (see that function's +/// empty-input handling), the correct "field absent" reading. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct EncodedField { pub png_bytes: Vec, } @@ -279,7 +285,8 @@ pub struct CliffSegment { pub struct EncodedStepCanvas { pub width: u32, pub height: u32, - // --- Static geometry plane (6 dense fields) --- + // --- Static geometry plane (7 dense fields — lake_margin_q added T-1188, + // see below; converged shape was 6, this is the one addition since) --- pub morphology: EncodedField, pub elev_q: EncodedField, pub temp_dc: EncodedTempField, @@ -291,6 +298,25 @@ pub struct EncodedStepCanvas { /// membership test, a fixed-radius proximity approximation pending real /// quarter-footprint geometry). pub settlement_id: EncodedSettlementField, + /// T-1188 — settled-hydrology lake-margin depth band (`0` at/near the + /// shoreline, ramping toward `100` at a basin's deep centre): the + /// continuous tone source lake shorelines were missing, since + /// `ocean_fraction_q` (the field that gives ocean coastlines their + /// multi-tone transition band via `derive_morphology_zone`'s coastal + /// gates) is definitionally `0` throughout a lake basin. Static geometry + /// (a pure function of position, same plane as `elev_q`), NOT sim-state — + /// grouped with the other static fields rather than next to + /// `glaciation`/`flooded_q` below. `#[serde(default)]` (same precedent as + /// `cliffs`) so a MessagePack decode of an old-shape payload doesn't + /// hard-fail on the missing map key; not expected to occur in practice + /// (client/server ship together, D-192, and the client's persistent + /// step-canvas cache is version-tagged — see this ticket's cache-schema + /// note — so a stale disk entry misses rather than decodes), but the + /// derived default (`EncodedField { png_bytes: vec![] }`) is also never + /// itself PNG-decoded on that path — nothing reads `lake_margin_q` + /// before the version tag has already forced a fresh fetch. + #[serde(default)] + pub lake_margin_q: EncodedField, // --- Sim-state plane (2 dense fields, see struct doc) --- pub glaciation: EncodedField, pub flooded_q: EncodedField, @@ -436,6 +462,7 @@ struct StepCanvasCell { vegetation: u8, glaciation: u8, flooded_q: u8, + lake_margin_q: u8, } /// Derive one cell at `(wx, wy)` world metres, dispatching to the D-255(a)/(f) @@ -485,6 +512,11 @@ fn derive_step_canvas_cell( // D-253 stub — see EncodedStepCanvas's doc. Always "not flooded" // until the sim-state driving clock exists. flooded_q: 0, + // T-1188: the lake-margin depth-band tone source — see + // `district_profile::DistrictProfile::lake_margin_q`'s doc. `0` for + // 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, } } @@ -509,6 +541,10 @@ pub struct RawStepCanvas { pub settlement_id: Vec, pub glaciation: Vec, pub flooded_q: Vec, + /// T-1188 — settled-hydrology lake-margin depth band, the lake-shoreline + /// tone source ocean coastlines already get for free from + /// `ocean_fraction_q`. See `district_profile::DistrictProfile::lake_margin_q`. + pub lake_margin_q: Vec, pub courses: Vec, pub cliffs: Vec, } @@ -976,6 +1012,7 @@ pub fn build_step_canvas( let mut vegetation = vec![0u8; cells]; let mut glaciation = vec![0u8; cells]; let mut flooded_q = vec![0u8; cells]; + let mut lake_margin_q = vec![0u8; cells]; for (row, row_cells) in rows.into_iter().enumerate() { let base = row * width as usize; @@ -988,6 +1025,7 @@ pub fn build_step_canvas( vegetation[i] = cell.vegetation; glaciation[i] = cell.glaciation; flooded_q[i] = cell.flooded_q; + lake_margin_q[i] = cell.lake_margin_q; } } @@ -1037,6 +1075,7 @@ pub fn build_step_canvas( settlement_id, glaciation, flooded_q, + lake_margin_q, courses, cliffs, } @@ -1085,6 +1124,7 @@ pub fn encode_step_canvas(raw: &RawStepCanvas) -> EncodedStepCanvas { settlement_id: EncodedSettlementField { values: raw.settlement_id.clone(), }, + lake_margin_q: png_encode_u8_plane(raw.width, raw.height, &raw.lake_margin_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(), @@ -1107,6 +1147,7 @@ pub fn decode_step_canvas(enc: &EncodedStepCanvas) -> RawStepCanvas { moisture_q: png_decode_u8_plane(&enc.moisture_q), 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), glaciation: png_decode_u8_plane(&enc.glaciation), flooded_q: png_decode_u8_plane(&enc.flooded_q), courses: enc.courses.clone(), @@ -1850,6 +1891,7 @@ mod tests { moisture_q: png_encode_u8_plane(1, 1, &[0]), vegetation: png_encode_u8_plane(1, 1, &[0]), settlement_id: EncodedSettlementField { values: vec![0] }, + lake_margin_q: png_encode_u8_plane(1, 1, &[0]), glaciation: png_encode_u8_plane(1, 1, &[0]), flooded_q: png_encode_u8_plane(1, 1, &[0]), courses: Vec::new(), diff --git a/server/src/atlas/voxel.rs b/server/src/atlas/voxel.rs index a70369996..f6679cb00 100644 --- a/server/src/atlas/voxel.rs +++ b/server/src/atlas/voxel.rs @@ -2291,6 +2291,7 @@ mod tests { slope_q: 5, elev_q: 20, ocean_fraction_q: 15, + lake_margin_q: 0, river_threshold: 200, temperature_c: Some(18.0), moisture_q: 55, @@ -2312,6 +2313,7 @@ mod tests { slope_q, elev_q: 5, ocean_fraction_q: 90, + lake_margin_q: 0, river_threshold: 200, temperature_c: Some(10.0), moisture_q: 80, @@ -2857,6 +2859,7 @@ mod tests { slope_q: 5, elev_q: 20, // low elevation ocean_fraction_q: 0, // no channel — simpler tile layout for elevation check + lake_margin_q: 0, river_threshold: 200, temperature_c: Some(18.0), moisture_q: 55, @@ -2871,6 +2874,7 @@ mod tests { slope_q: 5, elev_q: 70, // high elevation — makes the blend measurable ocean_fraction_q: 0, + lake_margin_q: 0, river_threshold: 200, temperature_c: Some(18.0), moisture_q: 55, diff --git a/server/tests/derivation_harness.rs b/server/tests/derivation_harness.rs index a6c117a85..e3de0ea88 100644 --- a/server/tests/derivation_harness.rs +++ b/server/tests/derivation_harness.rs @@ -2269,6 +2269,7 @@ fn make_region( slope_q, elev_q, ocean_fraction_q, + lake_margin_q: 0, river_threshold: derive_river_threshold(tectonic, precip), temperature_c, moisture_q,