diff --git a/.pql/changelog/ticket_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql index bd0b6c3ca..2956d63ed 100644 --- a/.pql/changelog/ticket_history/2026-08.sql +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -100,3 +100,4 @@ ticket''s scope, but it will be visible in the same captures, so do not mistake for a failure of the un-summarisation work.', NULL, '2026-08-06 15:59:39', '2026-08-06 15:59:39.623', '2026-08-06 15:59:39.623', NULL, 'fff0549b0da56272e5c13e9de4374f76', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TZC9MJV6KZTSRVRYQ327M', 'status', 'backlog', 'in_progress', NULL, '2026-08-07 11:36:05', '2026-08-07 11:36:05.551', '2026-08-07 11:36:05.551', NULL, '56946f83904827105a9ae52d64a91a1d', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TZC9MJV6KZTSRVRYQ327M', 'status', 'in_progress', 'in_progress', NULL, '2026-08-07 11:36:20', '2026-08-07 11:36:20.465', '2026-08-07 11:36:20.465', NULL, 'd57ff6176db9e02ac1e97fb8cb8e609b', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FSJWSX11WV3C1XXZEV88Q3P0', 'status', 'backlog', 'in_progress', NULL, '2026-08-07 12:44:21', '2026-08-07 12:44:21.620', '2026-08-07 12:44:21.620', NULL, '013321fe4ccaa686875a688ae6174292', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql index a77639372..5719774d3 100644 --- a/.pql/changelog/tickets/2026-08.sql +++ b/.pql/changelog/tickets/2026-08.sql @@ -165,3 +165,4 @@ Note the ladder currently also reports courses=0 at every rung below Global — 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); diff --git a/client/tests/test_step_canvas_colorize.gd b/client/tests/test_step_canvas_colorize.gd index d1f7978ae..c731ddd5e 100644 --- a/client/tests/test_step_canvas_colorize.gd +++ b/client/tests/test_step_canvas_colorize.gd @@ -98,22 +98,22 @@ func test_null_plane_falls_back_to_zero_rather_than_crashing() -> void: p.glaciation = null p.temp_dc = [] # Must not crash — morphology/elev_q both read as 0 (OpenOcean, elev 0). + # OpenOcean is a flat tone since T-1194 (elev_q under water is seabed + # bedrock, not the surface), so the expectation is the bare morphology + # colour; the no-crash property under test is unchanged. var got: Color = StepCanvasColorize.cell_color(p, 0, 0, "") - assert_that(got).is_equal( - AtlasOverlayColors.district_window_elevation_lightness( - AtlasOverlayColors.district_window_morphology_color(0), 0 - ) - ) + assert_that(got).is_equal(AtlasOverlayColors.district_window_morphology_color(0)) +## Out-of-bounds reads zero-fill rather than crashing. Zone 0 is OpenOcean, and +## since T-1194 the open ocean is a FLAT tone — no elev_q lightness, because +## elev_q under water is seabed bedrock, not the surface. So the expected value +## is the bare morphology colour; the property under test (no crash, zero-fill) +## is unchanged. func test_out_of_bounds_cell_index_returns_zero_not_a_crash() -> void: var planes := _planes([8], [50], [0], [0], [0], [], 1, 1) var got: Color = StepCanvasColorize.cell_color(planes, 99, 99, "") - assert_that(got).is_equal( - AtlasOverlayColors.district_window_elevation_lightness( - AtlasOverlayColors.district_window_morphology_color(0), 0 - ) - ) + assert_that(got).is_equal(AtlasOverlayColors.district_window_morphology_color(0)) ## gdUnit4's generic assert_that() has no Color-typed is_equal_approx() — 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 44baa31da..7ec96c1fe 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 @@ -41,6 +41,41 @@ const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC ## same as step_canvas_protocol.gd's status-decode duplication rationale). const MORPHOLOGY_LAKE: int = 1 +## MorphologyZone::OpenOcean — the other open-water discriminant (D-239 §6 +## ordering; AtlasOverlayColors documents 0 and 1 as the only two zones that +## are actually water, everything from TidalFlat onward being land or a +## transition). +const MORPHOLOGY_OPEN_OCEAN: int = 0 + +## VegetationClass::Scrub (district_profile.rs) — the threshold at or above +## which a cell carries enough plant cover to earn the patch mark. Absent(0) +## and Barren(1) get none, which is the point: bare ground should read bare. +const VEGETATION_SCRUB: int = 2 + +## T-1194 texture tuning. Deliberately gentle — the reference's texture reads +## as grain over a still-legible hue, never as a second colour layer competing +## with it. Raising RELIEF_STIPPLE_STRENGTH past ~0.35 starts reading as noise. +const RELIEF_STIPPLE_STRENGTH: float = 0.22 +const VEGETATION_PATCH_STRENGTH: float = 0.10 +const VEGETATION_PATCH_DENSITY: float = 0.34 + +## The elev_q delta at which relief is "fully rugged" and every cell earns a +## mark. Measured, not guessed: on Ferrath at Region the mean |delta| over a +## 4-cell baseline is 2.38 with 61% of pairs at >=2, so a full scale of 4 puts +## the typical reading near the middle of the density range and leaves genuine +## headroom at both ends. A larger value flattens the texture toward "always +## sparse"; a smaller one saturates it back to the uniform grain this replaced. +const RUGGEDNESS_FULL_SCALE_Q: int = 4 + +## Cells between the two samples the relief delta is measured across. +## +## NOT 1. The elevation field's gradient saturates by about 4 cells (Region +## measures d1 1.40, d4 2.38, d8 2.41, d16 2.51), so a 1-cell baseline samples +## mostly quantization noise: it reads near-identically everywhere and renders +## as uniform static rather than as ruggedness. Four cells is where the signal +## has arrived but has not yet flattened into the regional average. +const RUGGEDNESS_BASELINE_CELLS: int = 4 + ## 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. @@ -74,10 +109,89 @@ static func cell_color(planes: CellPlanes, col: int, row: int, active_toggle: St TOGGLE_VEGETATION: base = _vegetation_color(planes, col, row) _: - base = _base_color(planes, col, row) + base = _texture(planes, col, row, _base_color(planes, col, row)) return _apply_glaciation(planes, col, row, base) +## Relief and vegetation TEXTURE over the base hue (T-1194, RimWorld technique +## 4 — "texture as data, not decoration"). +## +## WHY. The base layer reads hue from morphology and lightness from elev_q, and +## on a real body below Global that collapses: morphology resolves to ONE zone +## per canvas, so the frame becomes a single flat colour whose only variation is +## a lightness ramp too subtle to see. Measured on Ferrath at Region — 1 +## morphology zone, but 49 distinct elev_q values. The information was already +## on the wire and arriving; the renderer was throwing it away by expressing it +## in lightness alone. +## +## The reference map (docs/design/references/rimworld-world-map-fluency.jpg) +## uses TWO marks, not one slider — the ticket's design question (b), settled by +## looking at it: +## - RELIEF stipple: fine, dense, darker, keyed to RUGGEDNESS not height. Its +## high flat plains carry none; its mountain ranges are dense with it. So +## the driver here is the local elev_q *gradient*, not elev_q itself: a high +## plateau is smooth and stays clean. +## - VEGETATION blotch: coarser, softer, a different grammar entirely. Keyed +## to the vegetation class and marked at half frequency so it reads as +## patches rather than grain. +## +## ALWAYS-ON, and base-layer ONLY (design question (c)). It is terrain +## information in the same sense the elev_q lightness modifier is, not an +## overlay — but the TMP/MST/VEG toggles are ANALYTIC reads, and stippling a +## temperature ramp would corrupt the quantity the user is trying to read. +## +## D-255(e)-legal: pure texture-space dithering of already-derived per-cell +## values. The mark is decided per SERVER CELL by a hash of its own coordinates +## and values — never a sample invented between cells, and identical on a cache +## hit and a cache miss because nothing but (col, row, plane values) enters it. +static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Color: + # Water carries neither mark: relief stipple under a sea reads as noise, + # and vegetation does not grow there. BOTH open water zones, not just Lake + # — an earlier version excluded Lake alone and stippled the entire ocean, + # which is the one surface with no relief to express. + var zone: int = _l8_value(planes.morphology, col, row) + if zone == MORPHOLOGY_LAKE or zone == MORPHOLOGY_OPEN_OCEAN: + return base + var out: Color = base + + var rug: float = _ruggedness(planes, col, row) + if rug > 0.0 and _dither(col, row, 0x51F) < rug: + out = out.darkened(RELIEF_STIPPLE_STRENGTH * rug) + + # Half-frequency lattice: one mark per 2x2 cells, so the vegetation grain is + # visibly coarser than the relief grain rather than a second speckle at the + # same pitch. + var veg: int = _l8_value(planes.vegetation, col, row) + if veg >= VEGETATION_SCRUB and _dither(col >> 1, row >> 1, 0xB33F) < VEGETATION_PATCH_DENSITY: + out = out.darkened(VEGETATION_PATCH_STRENGTH) + return out + + +## Local relief as a 0..1 fraction — the largest elev_q step to a 4-neighbour, +## normalized by the span at which terrain already reads as mountainous. +## 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: + var here: int = _l8_value(planes.elev_q, col, row) + var worst: int = 0 + var b: int = RUGGEDNESS_BASELINE_CELLS + for d: Vector2i in [Vector2i(b, 0), Vector2i(-b, 0), Vector2i(0, b), Vector2i(0, -b)]: + 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) + + +## Deterministic [0,1) dither for a cell, salted per mark type so the relief and +## vegetation lattices never correlate (sharing a hash would stack both marks on +## the same cells and read as one texture at double contrast). +static func _dither(col: int, row: int, salt: int) -> float: + var h: int = (col * 0x1F1F1F1F) ^ (row * 0x9E3779B1) ^ salt + h = (h ^ (h >> 15)) * 0x2545F491 + h = (h ^ (h >> 13)) * 0x27220A95 + return float(absi(h) % 4096) / 4096.0 + + static func _base_color(planes: CellPlanes, col: int, row: int) -> Color: var zone: int = _l8_value(planes.morphology, col, row) var base: Color = AtlasOverlayColors.district_window_morphology_color(zone) @@ -89,6 +203,16 @@ static func _base_color(planes: CellPlanes, col: int, row: int) -> Color: if zone == MORPHOLOGY_LAKE: var lmq: int = _l8_value(planes.lake_margin_q, col, row) return AtlasOverlayColors.lake_margin_lightness(base, lmq) + # The open ocean is a FLAT tone — the same argument the Lake branch above + # makes, finally applied to the other water zone. elev_q on a water cell is + # the bedrock elevation UNDER the water, not the surface, so shading the sea + # by it paints seabed relief the viewer cannot see and has no use for. Near + # a coastline that bedrock rises steeply and quantizes hard, which is + # exactly where it showed: a pale, pixellated, broken fringe hugging every + # shore. One tone for the sea reads as water and lets the coastline itself + # be the edge. + if zone == MORPHOLOGY_OPEN_OCEAN: + return base var eq: int = _l8_value(planes.elev_q, col, row) return AtlasOverlayColors.district_window_elevation_lightness(base, eq) diff --git a/server/src/atlas/step_canvas.rs b/server/src/atlas/step_canvas.rs index 84fe92c7a..ac8dbbcbe 100644 --- a/server/src/atlas/step_canvas.rs +++ b/server/src/atlas/step_canvas.rs @@ -2582,9 +2582,41 @@ mod tests { _ => 0, }; + // Mean |elev_q delta| at several baselines. T-1194's relief stipple + // needs a driver that VARIES across the frame; a baseline shorter + // than the finest terrain octave measures quantization noise + // instead and comes out near-constant, which renders as uniform + // grain rather than as ruggedness. + let w = raw.width as usize; + let h = raw.height as usize; + let mut grad = String::new(); + for base in [1usize, 4, 8, 16] { + let mut sum = 0.0f64; + let mut n = 0.0f64; + let mut hits = 0.0f64; + for r in 0..h { + for c in 0..w.saturating_sub(base) { + let a = raw.elev_q[r * w + c] as i32; + let b = raw.elev_q[r * w + c + base] as i32; + let d = (a - b).abs() as f64; + sum += d; + n += 1.0; + if d >= 2.0 { + hits += 1.0; + } + } + } + grad += &format!( + " d{}[mean {:.2} p>=2 {:.0}%]", + base, + sum / n.max(1.0), + 100.0 * hits / n.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 {}", + morph[uniq {:2}] veg[uniq {:2}] glac[uniq {:2}] courses {}{grad}", rung, spacing, raw.width,