From 172ce124c8fb411acf8c27e78e9fc6bccdca5b8c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Jul 2026 12:13:59 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(simulation):=20T-1137=20windowed=20dis?= =?UTF-8?q?trict=20layer=20=E2=80=94=20queue-served,=20coalesced,=20determ?= =?UTF-8?q?inism-tested=20(D-226=20T-1124=20SS1-SS4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AtlasLayerRequest gains window_center/window_n (serde-default, absent = whole-body, wire back-compat; demux untouched — up_to stays the discriminator). DistrictWindowLayer echoes center/n + six parallel arrays (morphology, elev_q, temp_dc i16 with the region sentinel, moisture_q, vegetation incl Marine=6, glaciation). Serving per the amendment's binding model: NEVER inline — GenWorkItem::DeriveWindow rides the Rayon queue, completion drain caches into DistrictWindowCache (bounded FIFO 256; no staleness by D-227 purity, capacity bound only), serve_district_window polls the cache and returns Pending-shaped None until derived. Per-connection coalescing: submit_window supersedes a still-pending item for the same (ConnectionId, body) — the surviving item is the newer one, proven by dedicated tests. TerrainAnalysis decision (option b, numbers in ticket/PR): re-derive via run_layer1 in the DeriveWindow branch rather than caching ~1.5MB x 50 LRU slots (~100MB permanent, the exact D-203 bloat T-1044's own text guarded against); ~45ms one-time on the Rayon path, invisible to the tick thread. T-1044 confirmed within-cascade-only (cascade.rs:358 still drops the analysis before BodyWorldState) — the fork was open. Window derive loop promoted from aliveness_probe::render_window_panels; determinism promoted from probe-only proof to a real test (two passes byte-identical). New fixture atlas_response_ready_with_window exercises all six arrays incl. the airless sentinel and Marine; three existing fixtures gain district_window: None. Server clamps window_n to 1..=DISTRICT_WINDOW_MAX_N=64 (never trust the wire). 1774/1774 lib + 19/19 bridge_tcp green. Co-Authored-By: Claude Fable 5 --- .../msgpack/atlas_response_not_found.msgpack | 2 +- .../msgpack/atlas_response_pending.msgpack | 2 +- .../msgpack/atlas_response_ready.msgpack | Bin 1088 -> 1105 bytes .../atlas_response_ready_with_window.msgpack | Bin 0 -> 231 bytes server/src/atlas/gen_queue.rs | 305 +++++++- server/src/atlas/layer_proxy.rs | 695 +++++++++++++++++- server/src/atlas/plugin.rs | 31 +- server/src/bridge/mod.rs | 4 + server/tests/bridge_tcp.rs | 2 + server/tests/gen_fixtures.rs | 41 +- 10 files changed, 1062 insertions(+), 20 deletions(-) create mode 100644 client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack diff --git a/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack index 91f93680c..d5b8e8b23 100644 --- a/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack +++ b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack @@ -1 +1 @@ -ˆ§body_id¥ghost¦status¨NotFound¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ²quarter_footprintsÀ \ No newline at end of file +‰§body_id¥ghost¦status¨NotFound¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ¯district_windowÀ²quarter_footprintsÀ \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/atlas_response_pending.msgpack b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack index 0ea79bf10..bd81fb3cb 100644 --- a/client/tests/fixtures/msgpack/atlas_response_pending.msgpack +++ b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack @@ -1 +1 @@ -ˆ§body_id¤GJ1c¦status§Pending¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ²quarter_footprintsÀ \ No newline at end of file +‰§body_id¤GJ1c¦status§Pending¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ¯district_windowÀ²quarter_footprintsÀ \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/atlas_response_ready.msgpack b/client/tests/fixtures/msgpack/atlas_response_ready.msgpack index bd8d0486c5ed0758635309b15d2a703432768fa6..e54a67dfac0ca39540adac52fd6160e10495ddd3 100644 GIT binary patch delta 33 ocmX@Wagl?ub0ec3v*7xa%;J)w%;b{z^31%H{PF{v^_c}30nHB!;{X5v delta 15 Wcmcb}ae#xdV! diff --git a/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack b/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..ccd64e073fa74c3d25900a25d03de6dfc630b36a GIT binary patch literal 231 zcmX|*y=nqM6on^0hL9rUMN(L$NiU=dDboz&-0L_nyLY{FSJ}=|TUpWvk`-1YkSEv& zFh!on#u35p9QY3BTds!$mG^6+fQ+}d;&ZC*nfo6-wCJ1?TxBtv9+l;svQy^E>>h}Jj!GKdesnz4~ zc`cX~8f?A$+kO1c)oX&2K*^YZ_a_DI6dfkd^i|(nsb1@XvHsMRy1w|Gl!nul9uY~w H{^)i93&CQq literal 0 HcmV?d00001 diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 614a28b6f..6b84008c8 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -14,6 +14,7 @@ //! - `AnalyzeBody`: D8 drainage + attractor extraction for a body. //! - `GenerateSkeleton`: Phase 1 QuarterSkeleton for a city. //! - `FillChunk`: Phase 2 chunk fill for a pre-loaded quarter. +//! - `DeriveWindow`: District-resolution window derive (D-226 T-1124, T-1137). //! //! Completion events are delivered to the main thread via //! `GenerationQueue::drain_completions()`, called once per tick from a Bevy @@ -30,11 +31,13 @@ use crossbeam_channel::{Receiver, Sender}; use crate::atlas::attractor_matching::CityRecord; use crate::atlas::body_world_state::BodyWorldState; use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer}; -use crate::atlas::district_profile::BodyParams; +use crate::atlas::district_profile::{BodyParams, ClimateConstants, DistrictPos}; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; +use crate::atlas::layer_proxy::{build_district_window_layer, DistrictWindowLayer}; use crate::atlas::shell::{fill_chunk, FilledChunk}; use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton}; use crate::atlas::trait_catalog_reader::ExteriorCatalog; +use crate::bridge::ConnectionId; use crate::seed::SeedChain; use crate::simulation::generator::{BuildingPropertyTag, CityGenerationContext, QuarterWorldState}; @@ -148,12 +151,71 @@ pub enum GenWorkItem { /// this variant needs no `Box` to stay clippy `large_enum_variant`-clean. block_tags: Vec, }, + /// Derive a district-resolution window (D-226 T-1124 amendment, T-1137). + /// + /// **Binding serving model:** window derives ride this SAME Rayon queue as + /// every other expensive atlas path — never inline on the `PreInput` drain + /// (the amendment's §1 is explicit: a window derive at n=32/64 is + /// ~7–29 ms, which would blow the "cheap channel drain" contract + /// `drain_generation_completions` documents). + /// + /// **TerrainAnalysis availability (T-1137 binding decision, with numbers):** + /// `BodyWorldState` does NOT retain `TerrainAnalysis` after cascade + /// completion (T-1044 scoped its transient-carry fix to *within-cascade* + /// reuse only — `cascade.rs` drops it once `DistrictProfile`+`RoadGraph` + /// finish; see the doc on `CascadeSnapshot::terrain_analysis`). Caching it + /// alongside every `BodyWorldStateCache` entry would cost ~2 MB × 50-body + /// capacity ≈ 100 MB of PERMANENT resident cost, paid by every cached body + /// whether or not a window is ever requested for it — the exact D-203 + /// budget concern T-1044's own ticket text guarded against. Re-deriving via + /// `run_layer1` per window-serving cache miss costs ~45 ms ONE-TIME, paid + /// only when a window is actually requested, and — because this work item + /// already runs off the tick thread on the Rayon queue — that cost is + /// invisible to the main thread; it is the same order of magnitude as one + /// window derive itself, not a multiplier on it. So: re-derive + /// (`heightmap_path`/`sea_level` below), matching `aliveness_probe`'s + /// existing `--render` workaround, NOT a `TerrainAnalysis` field cached on + /// `BodyWorldState`. + DeriveWindow { + body_id: String, + /// Coalescing/routing key (D-226 T-1124 amendment §1 "recommended" + /// per-connection coalescing) — NOT used by `run_work_item` itself + /// (the derive is connection-agnostic), only by + /// `GenerationQueue::submit_window` to decide which still-pending item + /// a new one for the same connection+body supersedes. + conn_id: ConnectionId, + /// Mod-resolved source heightmap PNG (mirrors `AnalyzeBody`). + heightmap_path: PathBuf, + sea_level: f32, + /// This body's `SeedChain` position — `derive_district`'s seed input. + body_seed: SeedChain, + /// Body physical parameters. Boxed for the same `large_enum_variant` + /// reason `AnalyzeBody.body_params` is boxed. + body_params: Box, + /// Window centre + side length in districts. `n` is ALREADY clamped to + /// `[1, DISTRICT_WINDOW_MAX_N]` by the caller (`handle_atlas_request`) + /// before this item is built — never trusted from the wire again here. + center: DistrictPos, + n: u32, + }, } impl GenWorkItem { pub fn body_id(&self) -> Option<&str> { - if let GenWorkItem::AnalyzeBody { body_id, .. } = self { - Some(body_id) + match self { + GenWorkItem::AnalyzeBody { body_id, .. } => Some(body_id), + _ => None, + } + } + + /// Coalescing key for `DeriveWindow` items only — `(connection, body)`. + /// `None` for every other variant (they don't coalesce this way). + pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str)> { + if let GenWorkItem::DeriveWindow { + body_id, conn_id, .. + } = self + { + Some((*conn_id, body_id)) } else { None } @@ -187,6 +249,19 @@ pub enum GenCompletion { /// Boxed to keep `GenCompletion` variant sizes balanced. filled: Box, }, + /// A district window finished deriving (D-226 T-1124 amendment, T-1137). + /// The main thread inserts `layer` into the `DistrictWindowCache` keyed by + /// `(body_id, layer.center, layer.n)` — NOT pushed directly into any + /// in-flight response (the window's requester re-polls per the existing + /// D-225 loop and hits the now-populated cache on its next request; see + /// `handle_atlas_request`'s window branch). + WindowDerived { + body_id: String, + /// Boxed to keep `GenCompletion` variant sizes balanced (six + /// `Vec`s — comparable to `SkeletonGenerated`/`ChunkFilled`'s own + /// boxing rationale). + layer: Box, + }, /// Work item failed — body_id or city_id for logging. Failed { item: GenWorkItem, reason: String }, } @@ -303,6 +378,43 @@ impl GenerationQueue { self.dispatch_next(); } + /// Submit a `DeriveWindow` item with per-connection coalescing (D-226 + /// T-1124 amendment §1, "recommended"): if a `DeriveWindow` item for the + /// SAME `(connection, body)` is still sitting in the pending queue + /// (not yet dispatched to a Rayon worker), it is replaced in place by the + /// new one — a pan-burst that queues several window requests for the same + /// connection+body before the first is dispatched collapses to one derive. + /// + /// Deliberately does **not** attempt to cancel an item already dispatched + /// to a Rayon worker (no cancellation channel exists, and the amendment + /// does not require it — bounding queue buildup is the goal, not + /// interrupting in-flight compute). `item` MUST be a `DeriveWindow` + /// variant; any other variant is submitted via the plain `submit` path + /// with no coalescing (this method still accepts it for caller + /// convenience, but the supersede check is a no-op when + /// `window_supersede_key()` returns `None`). + pub fn submit_window(&self, item: GenWorkItem, priority: GenPriority) { + if let Some(key) = item.window_supersede_key() { + let key = (key.0, key.1.to_string()); + let mut pending = self.pending.lock().unwrap(); + pending.retain(|q| { + q.item + .window_supersede_key() + .map(|k| (k.0, k.1.to_string()) != key) + .unwrap_or(true) + }); + let pos = pending + .iter() + .position(|q| q.priority > priority) + .unwrap_or(pending.len()); + pending.insert(pos, QueuedWork { priority, item }); + drop(pending); + self.dispatch_next(); + } else { + self.submit(item, priority); + } + } + /// Drain all completed items from the channel and dispatch pending work. /// /// Call once per tick from the main thread. Returns all completions @@ -491,6 +603,52 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { filled: Box::new(filled), } } + GenWorkItem::DeriveWindow { + body_id, + conn_id: _, // routing-only (queue-level coalescing); the derive itself is connection-agnostic + heightmap_path, + sea_level, + body_seed, + body_params, + center, + n, + } => match load_heightmap_png(heightmap_path, body_id, *sea_level) { + Ok(hm) => { + // Same GRID_W×GRID_H downsample AnalyzeBody applies (D-202) — the + // window derive must run on the SAME working-grid resolution the + // whole-body cascade uses, or district positions between the two + // views would disagree (derive_district maps DistrictPos through + // ta.w/ta.h, T-1137 decision note). + let working = if hm.width > GRID_W || hm.height > GRID_H { + hm.downsample(GRID_W, GRID_H) + } else { + hm + }; + // Re-derive TerrainAnalysis via run_layer1 (T-1137 binding + // decision — see the DeriveWindow variant doc for the numbers). + // This is the SAME workaround aliveness_probe --render already + // uses when CascadeSnapshot.terrain_analysis is None. + let (_, ta) = crate::atlas::layer1::run_layer1(&working); + let climate = ClimateConstants::default(); + let layer = build_district_window_layer( + *body_seed, + body_id, + body_params, + &ta, + *center, + *n, + &climate, + ); + GenCompletion::WindowDerived { + body_id: body_id.clone(), + layer: Box::new(layer), + } + } + Err(e) => GenCompletion::Failed { + item: item.clone(), + reason: format!("heightmap load failed: {e}"), + }, + }, } } @@ -814,4 +972,145 @@ mod tests { "a chunk containing a building must derive shell voxels" ); } + + // ------------------------------------------------------------------- + // DeriveWindow / submit_window coalescing (D-226 T-1124 amendment, T-1137) + // ------------------------------------------------------------------- + + /// Build a `DeriveWindow` work item pointing at a tiny test heightmap, + /// mirroring `analyze()`'s fixture shape. + fn derive_window(body_id: &str, conn_id: ConnectionId, center: DistrictPos) -> GenWorkItem { + GenWorkItem::DeriveWindow { + body_id: body_id.to_string(), + conn_id, + heightmap_path: test_heightmap_path(), + sea_level: 0.3, + body_seed: SeedChain::for_body(42, body_id), + body_params: Box::new(BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }), + center, + n: 4, + } + } + + /// The full `DeriveWindow` → Rayon → `WindowDerived` round trip: the + /// real re-derive-via-`run_layer1` path (T-1137 binding decision) runs + /// end to end and produces a populated `DistrictWindowLayer`. + #[test] + fn derive_window_round_trip_produces_populated_layer() { + let q = make_queue(); + q.submit( + derive_window("TestBody", ConnectionId(0), (2, -1)), + GenPriority::Immediate, + ); + std::thread::sleep(Duration::from_millis(150)); + let completions = q.drain_completions(); + assert_eq!(completions.len(), 1); + let GenCompletion::WindowDerived { body_id, layer } = &completions[0] else { + panic!("expected WindowDerived, got {:?}", completions[0]); + }; + assert_eq!(body_id, "TestBody"); + assert_eq!(layer.center, (2, -1)); + assert_eq!(layer.n, 4); + assert_eq!(layer.morphology.len(), 16); + assert_eq!(layer.elev_q.len(), 16); + assert_eq!(layer.temp_dc.len(), 16); + assert_eq!(layer.moisture_q.len(), 16); + assert_eq!(layer.vegetation.len(), 16); + assert_eq!(layer.glaciation.len(), 16); + } + + /// `submit_window` coalescing (D-226 T-1124 amendment §1 "recommended"): + /// two `DeriveWindow` items for the SAME `(connection, body)` queued + /// while the pool is saturated collapse to ONE pending entry — the + /// second submission replaces the first rather than queuing alongside it. + #[test] + fn submit_window_coalesces_same_connection_and_body() { + // Single-thread pool: the first item occupies the only worker, so + // subsequent DeriveWindow submissions stay in `pending` long enough + // to inspect (mirrors `priority_ordering_respected_under_saturation`'s + // saturation trick). Uses `analyze()` (real cascade work — measurable + // latency), NOT `FillChunk` (documented "trivially fast" — it can + // complete before the next `submit_window` call even runs, which + // would make `pending_count()` observe 0 instead of 1, a real race + // this test hit before switching occupiers). + let q = GenerationQueue::with_threads(1); + q.submit(analyze("Occupier"), GenPriority::Low); + + let conn = ConnectionId(7); + q.submit_window(derive_window("Coal", conn, (0, 0)), GenPriority::Immediate); + assert_eq!( + q.pending_count(), + 1, + "one DeriveWindow queued behind the saturating item" + ); + + // A second DeriveWindow for the SAME (connection, body) supersedes + // the first — pending count stays at 1, not 2. + q.submit_window(derive_window("Coal", conn, (5, 5)), GenPriority::Immediate); + assert_eq!( + q.pending_count(), + 1, + "same (connection, body) DeriveWindow must supersede, not queue alongside" + ); + + // Drain everything and confirm exactly one WindowDerived for "Coal", + // carrying the SECOND (superseding) center — not the first. + std::thread::sleep(Duration::from_millis(150)); + let mut completions = q.drain_completions(); + std::thread::sleep(Duration::from_millis(150)); + completions.extend(q.drain_completions()); + + let window_completions: Vec<_> = completions + .iter() + .filter_map(|c| { + if let GenCompletion::WindowDerived { body_id, layer } = c { + if body_id == "Coal" { + return Some(layer); + } + } + None + }) + .collect(); + assert_eq!( + window_completions.len(), + 1, + "exactly one WindowDerived for the coalesced body, not two" + ); + assert_eq!( + window_completions[0].center, + (5, 5), + "the surviving item must be the SECOND (superseding) submission" + ); + } + + /// `submit_window` does NOT coalesce across different connections or + /// different bodies — only an exact `(connection, body)` match supersedes. + #[test] + fn submit_window_does_not_coalesce_different_keys() { + let q = GenerationQueue::with_threads(1); + // See `submit_window_coalesces_same_connection_and_body`'s comment on + // why the occupier must be `analyze()`, not `FillChunk`. + q.submit(analyze("Occupier2"), GenPriority::Low); + + // Different connections, same body — must NOT coalesce. + q.submit_window( + derive_window("Shared", ConnectionId(1), (0, 0)), + GenPriority::Immediate, + ); + q.submit_window( + derive_window("Shared", ConnectionId(2), (1, 1)), + GenPriority::Immediate, + ); + assert_eq!( + q.pending_count(), + 2, + "different connections requesting the same body must NOT coalesce" + ); + } } diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 399a95a7e..8d1ae01d1 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -11,16 +11,19 @@ //! Pure handler logic; the bridge wiring (message routing) is the proxy's other //! half. No baking — the heightmap is the only source of truth (D-225). +use bevy_ecs::prelude::Resource; use serde::{Deserialize, Serialize}; use crate::atlas::body_params_reader::BodyParamsReader; use crate::atlas::body_world_state::{BodyWorldState, BodyWorldStateCache, SimTick}; use crate::atlas::cascade::CascadeLayer; use crate::atlas::city_context_reader::CityContextReader; +use crate::atlas::district_profile::DistrictPos; use crate::atlas::gen_queue::{GenPriority, GenWorkItem, GenerationQueue}; use crate::atlas::layer1::Layer1Output; use crate::atlas::road_graph::RoadNodeKind; use crate::atlas::source_resolver::{BodySourceResolver, SourceResolveError}; +use crate::bridge::ConnectionId; use crate::seed::{SeedChain, SeedDomain}; use crate::simulation::generator::{AttractorType, DistrictType, MaintenanceAuthority, ZoningType}; @@ -28,7 +31,15 @@ use crate::simulation::generator::{AttractorType, DistrictType, MaintenanceAutho /// (the loader prefers the chunk; this is only the floor). const DEFAULT_SEA_LEVEL: f32 = 0.3; -/// A client request for a body's generation layers (D-225). +/// Hard server-side clamp on [`AtlasLayerRequest::window_n`] (D-226 T-1124 +/// amendment §4, binding numbers). 64×64 districts ≈ 131 km per side — the +/// same window size `aliveness_probe --render`'s default already proved out +/// server-side (T-1123). **Never trust `window_n` from the wire** — every +/// caller clamps to `[1, DISTRICT_WINDOW_MAX_N]` before deriving. +pub const DISTRICT_WINDOW_MAX_N: u32 = 64; + +/// A client request for a body's generation layers (D-225), extended with an +/// optional district-resolution window query (D-226 T-1124 amendment §1, T-1137). /// /// `up_to` is a forward-compat seam that is **not yet honored**: `run_work_item` /// (`gen_queue.rs`) currently runs the cascade through `CascadeLayer::Region` @@ -39,6 +50,16 @@ const DEFAULT_SEA_LEVEL: f32 = 0.3; pub struct AtlasLayerRequest { pub body_id: String, pub up_to: CascadeLayer, + /// District-window centre (D-226 T-1124 amendment §1, T-1137). `None` = no + /// window requested (whole-body layers only — today's behavior, byte-unchanged + /// for every existing caller thanks to `#[serde(default)]`). + #[serde(default)] + pub window_center: Option, + /// Window side length in districts. Ignored when `window_center` is `None`. + /// Clamped server-side to `[1, DISTRICT_WINDOW_MAX_N]` — **never trusted + /// from the wire** (D-226 T-1124 amendment §4). + #[serde(default)] + pub window_n: u32, } /// Status of a layer response (D-225). @@ -72,7 +93,8 @@ pub struct DistrictGridLayer { /// A layer response: the computed `Layer1Output` + the coarse district grid /// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2) + /// the region climate grid (T-1113) + the quarter-footprint overlay (T-1112, -/// T-1119), or a non-ready status. +/// T-1119) + the district-resolution window query (T-1124, T-1137), or a +/// non-ready status. /// /// Growth ceiling (governance-bounded): the one-`Option`-field-per-layer /// pattern tops out at six fields for the **dense whole-body layer family** @@ -87,15 +109,14 @@ pub struct DistrictGridLayer { /// The D-226 T-1124 amendment (2026-07-18) RESOLVED what carries the next /// addition, and it is NOT this family: a **windowed viewport query** is a /// categorically different payload (keyed on the *request* `(body, center, n)`, -/// re-fetched per pan, not a per-body snapshot). T-1124 specifies a -/// `district_window: Option` field (wiring is a follow-up -/// ticket, T-1137 — not yet added here) that rides on `AtlasLayerResponse` but -/// is explicitly OUTSIDE the whole-body family and does not count against this -/// six-field ceiling (D-226 T-1124 §2). The windowed family has its own hard -/// cap: exactly ONE windowed-query field; a second windowed query (a second -/// viewport, a windowed chunk-preview) is a dedicated response message by rule, -/// not a second `Option` here (D-226 T-1124 §2, symmetric with the -/// request-side five-shape demux ceiling in `bridge/mod.rs`). +/// re-fetched per pan, not a per-body snapshot). `district_window` (wired here, +/// T-1137) rides on `AtlasLayerResponse` but is explicitly OUTSIDE the +/// whole-body family and does not count against the six-field ceiling above +/// (D-226 T-1124 §2). The windowed family has its own hard cap: exactly ONE +/// windowed-query field; a second windowed query (a second viewport, a +/// windowed chunk-preview) is a dedicated response message by rule, not a +/// second `Option` here (D-226 T-1124 §2, symmetric with the request-side +/// five-shape demux ceiling in `bridge/mod.rs`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AtlasLayerResponse { pub body_id: String, @@ -116,6 +137,12 @@ pub struct AtlasLayerResponse { /// The region climate grid for the Atlas overlay (D-243 §3, T-1113). /// `Some` on a cache hit once the Region layer has run; `None` otherwise. pub region_grid: Option, + /// The requested district window (D-226 T-1124 amendment, T-1137), or + /// `None` when the request carried no `window_center` / no window data is + /// cached yet for a pending derive. Distinct from the five layers above: + /// keyed on the REQUEST `(body, center, n)`, not on the body alone — see + /// the struct-level doc. + pub district_window: Option, /// The quarter-footprint overlay (D-226 T-1112 amendment, T-1119). `Some` /// on a cache hit once at least one settlement's quarter skeleton has been /// generated (`state.quarters` non-empty); `None` otherwise, including a @@ -235,6 +262,180 @@ pub fn build_region_grid( }) } +// --------------------------------------------------------------------------- +// DistrictWindowLayer (D-226 T-1124 amendment, T-1137) +// --------------------------------------------------------------------------- + +/// The requested district window: an `n × n` grid of TRUE 2 km districts +/// centred on `center`, derived on-demand via `district_profile::derive_district` +/// (D-226 T-1124 amendment §2). **Echoes `center`/`n` back** — this is the +/// client's race-condition guard, not a convenience field: because +/// `derive_district` is pure and deterministic (D-227), the same `(center, n)` +/// query always yields the same payload, so the echoed tuple *is* the +/// cache/staleness key the client compares against its most recently requested +/// window (`body_id` disambiguation rides the enclosing `AtlasLayerResponse`, +/// not the echo — see the amendment). +/// +/// All six arrays are dense row-major `n × n` (`i = row * n + col`), matching +/// the `DistrictGridLayer`/`RegionGridLayer` indexing convention. Per-cell wire +/// cost is 7 bytes (1+1+2+1+1+1) before MessagePack framing overhead (D-226 +/// T-1124 amendment §4). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DistrictWindowLayer { + pub center: DistrictPos, + pub n: u32, + /// `MorphologyZone` discriminant, the frozen 17-zone vocabulary (D-239 §6). + pub morphology: Vec, + /// 0-100, matches `DistrictGridLayer.elev_q` encoding. + pub elev_q: Vec, + /// Deci-°C, [`REGION_TEMP_NONE_DC`] sentinel — the SAME scheme as + /// `RegionGridLayer.mean_temp_dc`, deliberately not a separate + /// district-tier quantization (one temperature colorizer spans both zoom + /// levels, D-226 T-1124 amendment §2). + pub temp_dc: Vec, + /// 0-100, matches `DistrictGridLayer` precedent. + pub moisture_q: Vec, + /// `VegetationClass` discriminant, 0-6 including `Marine = 6` (T-1126) — + /// any client palette MUST be exhaustive over `Marine` (D-226 T-1124 + /// amendment §3, non-negotiable — the ocean-blind-vegetation bug this + /// field caught at the district tier). + pub vegetation: Vec, + /// `GlaciationGrade` discriminant, 0-4 (T-1127). + pub glaciation: Vec, +} + +/// Key for the server-side window derive cache (T-1137): `(body_id, center, n)`. +/// D-227 purity means a cached window is valid forever for a given body+seed — +/// no staleness/TTL invalidation is needed, only a bound on unbounded growth +/// (see [`DistrictWindowCache`]). +pub type DistrictWindowKey = (String, DistrictPos, u32); + +/// Bounded LRU-ish cache of completed district-window derives (T-1137), a +/// sibling to [`BodyWorldStateCache`] rather than a field on it: windows are +/// keyed on the *request* `(body, center, n)`, not the body alone (see the +/// struct-level doc on [`AtlasLayerResponse`]), so they don't fit the +/// per-body cache's keying at all. Eviction is capacity-only FIFO-by-insertion +/// (not access-recency LRU like `BodyWorldStateCache`) — window requests are +/// comparatively rare and cheap to re-derive on a genuine miss (a background +/// re-submit, never a stall), so exact recency tracking isn't worth the +/// bookkeeping; a simple bound against unbounded growth is enough. +#[derive(Resource, Debug, Default)] +pub struct DistrictWindowCache { + entries: std::collections::BTreeMap, + /// Insertion order, oldest first — the eviction queue. + order: std::collections::VecDeque, + capacity: usize, +} + +/// Default capacity for [`DistrictWindowCache`] — generous relative to +/// `BodyWorldStateCache::CACHE_CAPACITY` (50 bodies) since each entry here is +/// far smaller (a handful of `Vec`/`Vec` at `n ≤ 64`, ≤ 28 KiB raw vs. +/// `BodyWorldState`'s full heightmap + districts + regions), and several +/// windows can legitimately be live per body (a player panning around). +pub const DISTRICT_WINDOW_CACHE_CAPACITY: usize = 256; + +impl DistrictWindowCache { + pub fn new(capacity: usize) -> Self { + Self { + entries: std::collections::BTreeMap::new(), + order: std::collections::VecDeque::new(), + capacity, + } + } + + /// Look up a cached window by its full key. Never mutates — window + /// validity has no time component (D-227), so there is nothing to bump. + pub fn get(&self, key: &DistrictWindowKey) -> Option<&DistrictWindowLayer> { + self.entries.get(key) + } + + /// Insert a completed window derive, evicting the oldest entry first if + /// at capacity. Re-inserting an existing key replaces the value without + /// moving it in the eviction order (D-227: the value can only ever be + /// identical, so this is a no-op in practice, but stays correct either way). + pub fn insert(&mut self, key: DistrictWindowKey, layer: DistrictWindowLayer) { + if !self.entries.contains_key(&key) { + if self.entries.len() >= self.capacity { + if let Some(victim) = self.order.pop_front() { + self.entries.remove(&victim); + } + } + self.order.push_back(key.clone()); + } + self.entries.insert(key, layer); + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +/// Build a [`DistrictWindowLayer`] by deriving every district in the +/// `n × n` window around `center` (T-1137). Mirrors +/// `aliveness_probe::render_window_panels`'s derive loop exactly (the probe +/// this design promotes to a served layer, D-226 T-1124 amendment §2) — same +/// row-major indexing, same `derive_district` call per cell. +/// +/// `n` MUST already be clamped to `[1, DISTRICT_WINDOW_MAX_N]` by the caller — +/// this function trusts it verbatim (the clamp is `handle_atlas_request`'s +/// job, applied once at the wire boundary, not re-checked on every internal +/// caller per the existing codebase convention of clamping at the edge). +pub fn build_district_window_layer( + seed: SeedChain, + body_id: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + center: DistrictPos, + n: u32, + climate: &crate::atlas::district_profile::ClimateConstants, +) -> DistrictWindowLayer { + let n_i = n as i32; + let half = n_i / 2; + let cells = (n * n) as usize; + let mut morphology = vec![0u8; cells]; + let mut elev_q = vec![0u8; cells]; + let mut temp_dc = vec![REGION_TEMP_NONE_DC; cells]; + let mut moisture_q = vec![0u8; cells]; + let mut vegetation = vec![0u8; cells]; + let mut glaciation = vec![0u8; cells]; + for row in 0..n_i { + for col in 0..n_i { + // Row 0 = northmost, matching aliveness_probe's render_window_panels + // (derive_district maps negative wy to negative lat_frac = north). + let dp = (center.0 - half + col, center.1 - half + row); + let prof = crate::atlas::district_profile::derive_district( + seed, body_id, params, ta, dp, climate, + ); + let i = (row * n_i + col) as usize; + morphology[i] = prof.morphology_zone as u8; + elev_q[i] = prof.elev_q.clamp(0, 100) as u8; + temp_dc[i] = match prof.temperature_c { + Some(t) => { + ((t * 10.0).round() as i32).clamp(i16::MIN as i32 + 1, i16::MAX as i32) as i16 + } + None => REGION_TEMP_NONE_DC, + }; + moisture_q[i] = prof.moisture_q.clamp(0, 100) as u8; + vegetation[i] = prof.vegetation_class as u8; + glaciation[i] = prof.glaciation_grade as u8; + } + } + DistrictWindowLayer { + center, + n, + morphology, + elev_q, + temp_dc, + moisture_q, + vegetation, + glaciation, + } +} + // --------------------------------------------------------------------------- // QuarterFootprintLayer (D-226 T-1112 amendment, T-1119) // --------------------------------------------------------------------------- @@ -544,6 +745,97 @@ pub fn build_settlement_layer(state: &BodyWorldState) -> Option Some(SettlementLayer { settlements }) } +/// Resolve `req`'s district-window query, if any (D-226 T-1124 amendment, +/// T-1137). Returns `None` immediately when `req.window_center` is absent (no +/// window requested — the common case, zero cost). +/// +/// **Independent of the whole-body cache state** (the amendment is explicit: +/// "the window derivation depends only on `TerrainAnalysis` + `BodyParams` +/// being resolvable for the body ... not on which whole-body layers the +/// cascade has cached") — so this runs whether `handle_atlas_request` is +/// about to take its cache-hit or cache-miss branch, sharing neither's control +/// flow. +/// +/// Cache hit (`(body_id, center, n)` already in `window_cache`) → `Some` +/// immediately, no queue submission (D-227: a previously-derived window for +/// this body+seed is valid forever, no staleness check needed). Cache miss → +/// submit a `DeriveWindow` work item (queue-based, per the amendment's binding +/// serving model — never inline here) and return `None`; the *next* request +/// for this `(body, center, n)` re-checks the cache and finds it populated +/// once `drain_generation_completions` has processed the completion (the +/// existing D-225 poll-and-recheck-cache pattern every other layer already +/// uses, not a push). +/// +/// `window_n` is clamped to `[1, DISTRICT_WINDOW_MAX_N]` here — the ONE place +/// that clamp is applied; nothing downstream re-checks the wire value. +#[allow(clippy::too_many_arguments)] +fn serve_district_window( + req: &AtlasLayerRequest, + window_cache: &mut DistrictWindowCache, + queue: &GenerationQueue, + resolver: &BodySourceResolver, + body_params_reader: Option<&BodyParamsReader>, + world_seed: u64, + conn_id: ConnectionId, +) -> Option { + let center = req.window_center?; + let n = req.window_n.clamp(1, DISTRICT_WINDOW_MAX_N); + + let key: DistrictWindowKey = (req.body_id.clone(), center, n); + if let Some(layer) = window_cache.get(&key) { + return Some(layer.clone()); + } + + // Miss — resolve heightmap + body params and submit a background derive. + // Read/resolve failures are non-fatal for the window (log + skip): the + // window simply stays None on this response, same as an unrun whole-body + // layer, rather than failing the entire AtlasLayerResponse. + let heightmap_path = match resolver.resolve(&req.body_id) { + Ok(p) => p, + Err(e) => { + tracing::warn!( + body_id = %req.body_id, + error = %e, + "district window request: heightmap resolve failed — window stays None" + ); + return None; + } + }; + let Some(reader) = body_params_reader else { + tracing::warn!( + body_id = %req.body_id, + "district window request: no body_params_reader wired — window stays None" + ); + return None; + }; + let body_params = match reader.read_body_params(&req.body_id) { + Ok(p) => p, + Err(e) => { + tracing::warn!( + body_id = %req.body_id, + error = %e, + "district window request: body_params read failed — window stays None" + ); + return None; + } + }; + + queue.submit_window( + GenWorkItem::DeriveWindow { + body_id: req.body_id.clone(), + conn_id, + heightmap_path, + sea_level: DEFAULT_SEA_LEVEL, + body_seed: SeedChain::for_body(world_seed, &req.body_id), + body_params: Box::new(body_params), + center, + n, + }, + GenPriority::Immediate, + ); + None +} + /// Serve one layer request (D-225). `current_tick` stamps the cache LRU on hit; /// `world_seed` derives the body's `SeedChain` for the enqueued analysis. /// @@ -557,16 +849,35 @@ pub fn build_settlement_layer(state: &BodyWorldState) -> Option /// causing the cascade to stop at `CascadeLayer::Settlement` (pre-T-1032 /// behaviour). A successful read passes `Some(Box::new(params))`, enabling /// the full `CascadeLayer::DistrictProfile` path. +/// +/// `window_cache` + `conn_id` serve the optional district-window query +/// (D-226 T-1124 amendment, T-1137) via [`serve_district_window`] — see that +/// function for the caching/coalescing model. `conn_id` is used ONLY as the +/// window request's coalescing key; nothing else in this function is +/// connection-aware (the D-254 §2 convention this proxy already follows). +#[allow(clippy::too_many_arguments)] pub fn handle_atlas_request( req: &AtlasLayerRequest, cache: &mut BodyWorldStateCache, + window_cache: &mut DistrictWindowCache, queue: &GenerationQueue, resolver: &BodySourceResolver, city_reader: Option<&CityContextReader>, body_params_reader: Option<&BodyParamsReader>, world_seed: u64, current_tick: SimTick, + conn_id: ConnectionId, ) -> AtlasLayerResponse { + let district_window = serve_district_window( + req, + window_cache, + queue, + resolver, + body_params_reader, + world_seed, + conn_id, + ); + // Cache hit — serve immediately. if let Some(state) = cache.get(&req.body_id, current_tick) { let layer1 = Layer1Output { @@ -599,6 +910,7 @@ pub fn handle_atlas_request( road_graph, settlements, region_grid, + district_window, quarter_footprints, }; } @@ -673,6 +985,7 @@ pub fn handle_atlas_request( road_graph: None, settlements: None, region_grid: None, + district_window, quarter_footprints: None, } } @@ -686,6 +999,7 @@ pub fn handle_atlas_request( road_graph: None, settlements: None, region_grid: None, + district_window, quarter_footprints: None, }, Err(e) => AtlasLayerResponse { @@ -696,6 +1010,7 @@ pub fn handle_atlas_request( road_graph: None, settlements: None, region_grid: None, + district_window, quarter_footprints: None, }, } @@ -829,6 +1144,340 @@ mod tests { assert_eq!(grid.moisture_q[1], 5); } + // ----------------------------------------------------------------------- + // DistrictWindowLayer (D-226 T-1124 amendment, T-1137) + // ----------------------------------------------------------------------- + + /// Minimal deterministic heightmap fixture, mirroring + /// `district_profile::tests::test_hm` (T-1137: the window path shares the + /// same on-demand `derive_district` call, so it earns the same fixture + /// shape). + fn window_test_hm() -> crate::atlas::heightmap::BodyHeightmap { + use crate::atlas::heightmap::BodyHeightmap; + let (w, h) = (64u32, 32u32); + let n = (w * h) as usize; + let data = (0..n) + .map(|i| { + let r = (i / w as usize) as f32 / h as f32; + let c = (i % w as usize) as f32 / w as f32; + (r * 0.6 + c * 0.4).min(1.0) + }) + .collect(); + BodyHeightmap { + body_id: "test".into(), + width: w, + height: h, + data, + sea_level: 0.3, + } + } + + fn window_test_ta( + hm: &crate::atlas::heightmap::BodyHeightmap, + ) -> crate::atlas::features::TerrainAnalysis { + use crate::atlas::drainage; + use crate::atlas::features::TerrainAnalysis; + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + TerrainAnalysis::analyze(hm, &dr) + } + + fn window_test_params() -> crate::atlas::district_profile::BodyParams { + crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + } + } + + /// `build_district_window_layer` produces a dense `n × n` row-major grid + /// (the `DistrictGridLayer`/`RegionGridLayer` indexing convention) whose + /// cell count and per-array lengths match `n`, and whose values are + /// pulled straight from the corresponding `derive_district` profile field + /// (T-1137). + #[test] + fn build_district_window_layer_produces_dense_n_by_n_grid() { + let hm = window_test_hm(); + let ta = window_test_ta(&hm); + let params = window_test_params(); + let climate = crate::atlas::district_profile::ClimateConstants::default(); + let seed = SeedChain::root(42).derive(SeedDomain::Body, 1); + + let n = 4u32; + let layer = + build_district_window_layer(seed, "test_body", ¶ms, &ta, (10, -5), n, &climate); + + assert_eq!(layer.center, (10, -5)); + assert_eq!(layer.n, n); + let cells = (n * n) as usize; + assert_eq!(layer.morphology.len(), cells); + assert_eq!(layer.elev_q.len(), cells); + assert_eq!(layer.temp_dc.len(), cells); + assert_eq!(layer.moisture_q.len(), cells); + assert_eq!(layer.vegetation.len(), cells); + assert_eq!(layer.glaciation.len(), cells); + + // Spot-check one cell against a direct derive_district call — the + // window builder must not transform the profile's values, only pack + // them (row 0, col 0 → district (center.0 - n/2, center.1 - n/2)). + let half = (n / 2) as i32; + let dp = (10 - half, -5 - half); + let prof = crate::atlas::district_profile::derive_district( + seed, + "test_body", + ¶ms, + &ta, + dp, + &climate, + ); + assert_eq!(layer.morphology[0], prof.morphology_zone as u8); + assert_eq!(layer.elev_q[0], prof.elev_q.clamp(0, 100) as u8); + assert_eq!(layer.moisture_q[0], prof.moisture_q.clamp(0, 100) as u8); + assert_eq!(layer.vegetation[0], prof.vegetation_class as u8); + assert_eq!(layer.glaciation[0], prof.glaciation_grade as u8); + } + + /// Clamped-window edge: `n = 1` is the minimum valid window (a single + /// district) — no panic, no empty output, exactly one cell per array. + #[test] + fn build_district_window_layer_handles_n_equals_one() { + let hm = window_test_hm(); + let ta = window_test_ta(&hm); + let params = window_test_params(); + let climate = crate::atlas::district_profile::ClimateConstants::default(); + let seed = SeedChain::root(1).derive(SeedDomain::Body, 1); + + let layer = + build_district_window_layer(seed, "test_body", ¶ms, &ta, (0, 0), 1, &climate); + assert_eq!(layer.n, 1); + assert_eq!(layer.morphology.len(), 1); + assert_eq!(layer.elev_q.len(), 1); + assert_eq!(layer.temp_dc.len(), 1); + assert_eq!(layer.moisture_q.len(), 1); + assert_eq!(layer.vegetation.len(), 1); + assert_eq!(layer.glaciation.len(), 1); + } + + /// Determinism spot-check (D-010, T-1123 precedent promoted to a real + /// test per the ticket): two full derive passes over the SAME window are + /// byte-identical, at a window size large enough to exercise many cells + /// (mirrors `aliveness_probe --render`'s own two-pass proof, now pinned + /// as a unit test rather than a probe-only demonstration). + #[test] + fn build_district_window_layer_two_passes_are_byte_identical() { + let hm = window_test_hm(); + let ta = window_test_ta(&hm); + let params = window_test_params(); + let climate = crate::atlas::district_profile::ClimateConstants::default(); + let seed = SeedChain::root(7).derive(SeedDomain::Body, 3); + + let n = 8u32; + let first = + build_district_window_layer(seed, "test_body", ¶ms, &ta, (3, -2), n, &climate); + let second = + build_district_window_layer(seed, "test_body", ¶ms, &ta, (3, -2), n, &climate); + assert_eq!( + first, second, + "two full derive passes over the same (center, n) must be byte-identical (D-010/D-227)" + ); + } + + /// [`DistrictWindowCache`] insert/get round-trips, and a capacity-1 cache + /// evicts the oldest entry FIFO — mirroring `BodyWorldStateCache`'s own + /// `evicts_lru_on_overflow` precedent, adapted to this cache's + /// capacity-only insertion-order eviction (no access-recency tracking, + /// per the struct doc: D-227 means a cached window has no staleness to + /// track, only unbounded growth to bound). + #[test] + fn district_window_cache_insert_get_and_evict() { + let mut cache = DistrictWindowCache::new(2); + let key_a: DistrictWindowKey = ("Alpha".into(), (0, 0), 4); + let key_b: DistrictWindowKey = ("Beta".into(), (1, 1), 4); + let key_c: DistrictWindowKey = ("Gamma".into(), (2, 2), 4); + let mk = |center, n| DistrictWindowLayer { + center, + n, + morphology: vec![0; (n * n) as usize], + elev_q: vec![0; (n * n) as usize], + temp_dc: vec![REGION_TEMP_NONE_DC; (n * n) as usize], + moisture_q: vec![0; (n * n) as usize], + vegetation: vec![0; (n * n) as usize], + glaciation: vec![0; (n * n) as usize], + }; + + assert!(cache.get(&key_a).is_none()); + cache.insert(key_a.clone(), mk((0, 0), 4)); + cache.insert(key_b.clone(), mk((1, 1), 4)); + assert_eq!(cache.len(), 2); + assert!(cache.get(&key_a).is_some()); + assert!(cache.get(&key_b).is_some()); + + // Cache at capacity (2): inserting a third entry evicts key_a (oldest). + cache.insert(key_c.clone(), mk((2, 2), 4)); + assert_eq!(cache.len(), 2); + assert!( + cache.get(&key_a).is_none(), + "key_a should have been evicted" + ); + assert!(cache.get(&key_b).is_some()); + assert!(cache.get(&key_c).is_some()); + } + + /// `handle_atlas_request`'s window branch clamps `window_n` server-side to + /// `[1, DISTRICT_WINDOW_MAX_N]` — a request claiming an oversized `n` on + /// the wire never reaches `build_district_window_layer` un-clamped. This + /// exercises the full request→submit→drain→cache→re-request loop with an + /// out-of-range `window_n`, confirming the CACHED layer (once the + /// background derive completes) carries the CLAMPED `n`, not the + /// requested one. + #[test] + fn handle_atlas_request_clamps_oversized_window_n() { + let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); + let (_db, resolver, params_reader, _root) = resolver_and_params_reader("GJ1c"); + let queue = GenerationQueue::with_threads(1); + + let oversized_req = AtlasLayerRequest { + body_id: "GJ1c".to_string(), + up_to: CascadeLayer::Topography, + window_center: Some((0, 0)), + window_n: DISTRICT_WINDOW_MAX_N * 10, // wildly over the wire — must clamp, not trust + }; + + let resp = handle_atlas_request( + &oversized_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + test_conn_id(), + ); + // First request: window not yet cached → None, but a DeriveWindow + // must have been submitted (checked via the drain below). + assert!(resp.district_window.is_none()); + + // Wait for the Rayon DeriveWindow work item to complete. + std::thread::sleep(Duration::from_millis(300)); + let completions = queue.drain_completions(); + let window_completion = completions.into_iter().find_map(|c| { + if let GenCompletion::WindowDerived { body_id, layer } = c { + if body_id == "GJ1c" { + return Some(layer); + } + } + None + }); + let layer = window_completion.expect("DeriveWindow must complete for GJ1c"); + assert_eq!( + layer.n, DISTRICT_WINDOW_MAX_N, + "server must clamp window_n to DISTRICT_WINDOW_MAX_N, never trust the wire value" + ); + } + + /// `serve_district_window` returns `None` (no window requested) when the + /// request carries no `window_center` — the common case, and the ONLY + /// path every pre-T-1137 caller takes (wire back-compat: an old client's + /// `{body_id, up_to}` frame decodes with `window_center: None` via + /// `#[serde(default)]`). + #[test] + fn handle_atlas_request_no_window_center_leaves_district_window_none() { + let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); + let (_db, resolver) = empty_resolver(); + let queue = GenerationQueue::with_threads(1); + + let resp = handle_atlas_request( + &req("GJ1c"), // window_center: None + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + None, + 42, + 1, + test_conn_id(), + ); + assert!(resp.district_window.is_none()); + assert!( + window_cache.is_empty(), + "no window requested → no DeriveWindow submitted, cache stays empty" + ); + } + + /// `AtlasLayerResponse.district_window` survives a MessagePack round trip + /// (mirrors the existing `atlas_layer_response_with_new_layers_round_trips_msgpack` + /// precedent) — the wire shape every field the amendment specifies: + /// echoed `center`/`n`, all six parallel arrays including the + /// `REGION_TEMP_NONE_DC` sentinel and `VegetationClass::Marine = 6`. + #[test] + fn district_window_layer_round_trips_msgpack_inside_response() { + let window = DistrictWindowLayer { + center: (10, -5), + n: 2, + morphology: vec![0, 8, 14, 16], + elev_q: vec![0, 45, 98, 60], + temp_dc: vec![205, 150, REGION_TEMP_NONE_DC, 80], + moisture_q: vec![90, 55, 0, 100], + vegetation: vec![6, 3, 0, 5], // includes Marine = 6 + glaciation: vec![0, 0, 4, 1], + }; + let resp = AtlasLayerResponse { + body_id: "GJ1c".into(), + status: AtlasLayerStatus::Ready, + layer1: None, + district_grid: None, + road_graph: None, + settlements: None, + region_grid: None, + district_window: Some(window.clone()), + quarter_footprints: None, + }; + + let bytes = rmp_serde::to_vec_named(&resp).expect("encode"); + let decoded: AtlasLayerResponse = rmp_serde::from_slice(&bytes).expect("decode"); + + let dw = decoded + .district_window + .expect("district_window survives round trip"); + assert_eq!(dw, window); + assert_eq!(dw.center, (10, -5)); + assert_eq!(dw.n, 2); + assert_eq!( + dw.temp_dc[2], REGION_TEMP_NONE_DC, + "airless sentinel preserved" + ); + assert_eq!(dw.vegetation[0], 6, "Marine discriminant preserved"); + } + + /// Wire back-compat (D-226 T-1124 amendment §1): a pre-T-1137 request + /// frame carrying only `{body_id, up_to}` — no `window_center`/`window_n` + /// keys at all — decodes cleanly via `#[serde(default)]`, byte-unchanged + /// for every existing caller. + #[test] + fn old_request_frame_without_window_fields_decodes_with_none() { + #[derive(serde::Serialize)] + struct OldAtlasLayerRequest { + body_id: String, + up_to: CascadeLayer, + } + let old = OldAtlasLayerRequest { + body_id: "GJ1c".into(), + up_to: CascadeLayer::Topography, + }; + let bytes = rmp_serde::to_vec_named(&old).expect("encode old-shape frame"); + let decoded: AtlasLayerRequest = rmp_serde::from_slice(&bytes).expect("decode"); + assert_eq!(decoded.body_id, "GJ1c"); + assert_eq!(decoded.up_to, CascadeLayer::Topography); + assert_eq!(decoded.window_center, None); + assert_eq!(decoded.window_n, 0); + } + /// T-1119: `build_quarter_footprint_layer` returns `None` when no /// settlement's quarter skeleton has been generated (`state.quarters` /// empty), mirroring `build_district_grid`/`build_region_grid`'s @@ -1403,6 +2052,7 @@ mod tests { road_graph: build_road_graph_layer(&state), settlements: build_settlement_layer(&state), region_grid: build_region_grid(&state), + district_window: None, quarter_footprints: build_quarter_footprint_layer(&state, world_seed), }; @@ -1439,9 +2089,15 @@ mod tests { AtlasLayerRequest { body_id: body_id.to_string(), up_to: CascadeLayer::Topography, + window_center: None, + window_n: 0, } } + fn test_conn_id() -> ConnectionId { + ConnectionId(1) + } + const REL: &str = "wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"; /// systems.db with one bodies row, + a base root containing a tiny 16-bit @@ -1517,16 +2173,19 @@ mod tests { }); let (_db, resolver) = empty_resolver(); let queue = GenerationQueue::with_threads(1); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); let resp = handle_atlas_request( &req("GJ1c"), &mut cache, + &mut window_cache, &queue, &resolver, None, None, 42, 1, + test_conn_id(), ); assert_eq!(resp.status, AtlasLayerStatus::Ready); assert_eq!(resp.layer1.expect("layer1").body_id, "GJ1c"); @@ -1537,16 +2196,19 @@ mod tests { let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); let (_db, resolver) = resolver_with_body("GJ1c"); let queue = GenerationQueue::with_threads(1); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); let resp = handle_atlas_request( &req("GJ1c"), &mut cache, + &mut window_cache, &queue, &resolver, None, None, 42, 1, + test_conn_id(), ); assert_eq!(resp.status, AtlasLayerStatus::Pending); assert!(resp.layer1.is_none()); @@ -1567,16 +2229,19 @@ mod tests { let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); let (_db, resolver) = empty_resolver(); let queue = GenerationQueue::with_threads(1); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); let resp = handle_atlas_request( &req("ghost"), &mut cache, + &mut window_cache, &queue, &resolver, None, None, 42, 1, + test_conn_id(), ); assert_eq!(resp.status, AtlasLayerStatus::NotFound); } @@ -1651,16 +2316,19 @@ mod tests { let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); let (_db, resolver, params_reader, _root) = resolver_and_params_reader("GJ1c"); let queue = GenerationQueue::with_threads(1); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); let resp = handle_atlas_request( &req("GJ1c"), &mut cache, + &mut window_cache, &queue, &resolver, None, Some(¶ms_reader), 42, 1, + test_conn_id(), ); assert_eq!(resp.status, AtlasLayerStatus::Pending); @@ -1694,12 +2362,14 @@ mod tests { let ready = handle_atlas_request( &req("GJ1c"), &mut cache, + &mut window_cache, &queue, &resolver, None, Some(¶ms_reader), 42, 2, + test_conn_id(), ); assert_eq!(ready.status, AtlasLayerStatus::Ready); assert!( @@ -1714,16 +2384,19 @@ mod tests { let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); let (_db, resolver) = resolver_with_body("GJ1c"); let queue = GenerationQueue::with_threads(1); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); let resp = handle_atlas_request( &req("GJ1c"), &mut cache, + &mut window_cache, &queue, &resolver, None, None, // no body_params_reader 42, 1, + test_conn_id(), ); assert_eq!(resp.status, AtlasLayerStatus::Pending); diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 84b66f109..a24ea3a3d 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -27,7 +27,10 @@ use crate::atlas::city_context_reader::{ use crate::atlas::district_mix::{compute_district_mix, population_tier}; use crate::atlas::district_profile::{DistrictPos, DistrictProfile}; use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue}; -use crate::atlas::layer_proxy::{handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus}; +use crate::atlas::layer_proxy::{ + handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus, DistrictWindowCache, + DISTRICT_WINDOW_CACHE_CAPACITY, +}; use crate::atlas::road_graph::{RoadGraph, RoadNode}; use crate::atlas::scale; use crate::atlas::skeleton_gen::derive_complexity; @@ -61,6 +64,7 @@ impl Plugin for GenerationPlugin { fn build(&self, app: &mut App) { app.insert_resource(GenerationQueue::new()) .insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY)) + .insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY)) .add_systems( Update, drain_generation_completions.in_set(TickPhase::PreInput), @@ -78,10 +82,18 @@ impl Plugin for GenerationPlugin { /// Drain inbound atlas layer requests and serve each through the proxy (#969, /// D-225): cache hit → Ready, miss → resolve + enqueue + Pending. Responses are /// buffered for the bridge to flush in `PostSnapshot`. +/// +/// `window_cache` serves the optional district-window query (D-226 T-1124 +/// amendment, T-1137) — see `handle_atlas_request`/`serve_district_window`. +/// Unlike the rest of `handle_atlas_request`, the window path IS +/// connection-aware (its coalescing key), so `conn_id` — already threaded +/// through this loop for response routing (D-254 §2) — is passed one level +/// further in for that one purpose only. fn serve_atlas_requests( mut requests: ResMut, mut responses: ResMut, mut cache: ResMut, + mut window_cache: ResMut, queue: Res, resolver: Option>, city_reader: Option>, @@ -106,12 +118,14 @@ fn serve_atlas_requests( Some(r) => handle_atlas_request( &req, &mut cache, + &mut window_cache, &queue, &r.0, reader, params_reader, world_seed, tick, + conn_id, ), None => AtlasLayerResponse { body_id: req.body_id.clone(), @@ -121,6 +135,7 @@ fn serve_atlas_requests( road_graph: None, settlements: None, region_grid: None, + district_window: None, quarter_footprints: None, }, }; @@ -209,6 +224,7 @@ pub fn serve_browse_requests( fn drain_generation_completions( queue: Res, mut cache: ResMut, + mut window_cache: ResMut, city_reader: Option>, trait_catalog: Option>, rng: Option>, @@ -390,6 +406,15 @@ fn drain_generation_completions( "FillChunk derived (no Phase-5 consumer yet)" ); } + GenCompletion::WindowDerived { body_id, layer } => { + // D-226 T-1124 amendment, T-1137: cache the completed window — + // NOT pushed into any in-flight response (this drain has no + // notion of which connection(s) are waiting). The requester's + // NEXT poll (the existing D-225 re-request loop) hits + // `handle_atlas_request`'s window branch, which finds this + // entry via `DistrictWindowCache::get` and serves it. + window_cache.insert((body_id, layer.center, layer.n), *layer); + } } } } @@ -832,6 +857,7 @@ mod tests { let mut world = World::new(); world.insert_resource(GenerationQueue::new()); world.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY)); + world.insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY)); world.resource::().submit( GenWorkItem::AnalyzeBody { @@ -876,10 +902,13 @@ mod tests { AtlasLayerRequest { body_id: "GJ1c".to_string(), up_to: CascadeLayer::Topography, + window_center: None, + window_n: 0, }, )])); world.insert_resource(AtlasResponseBuffer::default()); world.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY)); + world.insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY)); world.insert_resource(GenerationQueue::with_threads(1)); // No resolver / SimRng / SimulationTime — all optional in the system. diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index e9d276944..4ecd73c32 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -1199,6 +1199,8 @@ mod inbound_tests { let req = AtlasLayerRequest { body_id: "GJ1c".into(), up_to: CascadeLayer::Topography, + window_center: None, + window_n: 0, }; let frame = rmp_serde::to_vec_named(&req).unwrap(); assert!( @@ -1242,6 +1244,8 @@ mod inbound_tests { let atlas_frame = rmp_serde::to_vec_named(&AtlasLayerRequest { body_id: "GJ1c".into(), up_to: CascadeLayer::Topography, + window_center: None, + window_n: 0, }) .unwrap(); let star_map_frame = rmp_serde::to_vec_named(&StarMapRequest { star_map: true }).unwrap(); diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 53497669c..d54d769ce 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -369,6 +369,8 @@ fn single_tick_drains_all_ready_inbound_frames() { let req = AtlasLayerRequest { body_id: "GJ1c".into(), up_to: CascadeLayer::Topography, + window_center: None, + window_n: 0, }; let payload = rmp_serde::to_vec_named(&req).expect("failed to serialize"); write_framed(&mut stream, &payload).expect("write atlas frame"); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 601a33528..6eabe2667 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -4,9 +4,9 @@ use settled_reach_server::atlas::body_world_state::{DrainageBasin, RiverNetwork}; use settled_reach_server::atlas::layer1::Layer1Output; use settled_reach_server::atlas::layer_proxy::{ - AtlasLayerResponse, AtlasLayerStatus, QuarterFootprintEntry, QuarterFootprintLayer, - RegionGridLayer, RoadGraphEdge, RoadGraphLayer, RoadGraphNode, SettlementEntry, - SettlementLayer, SettlementSizeClass, + AtlasLayerResponse, AtlasLayerStatus, DistrictWindowLayer, QuarterFootprintEntry, + QuarterFootprintLayer, RegionGridLayer, RoadGraphEdge, RoadGraphLayer, RoadGraphNode, + SettlementEntry, SettlementLayer, SettlementSizeClass, REGION_TEMP_NONE_DC, }; use settled_reach_server::atlas::region_profile::{SeasonPhase, WeatherState}; use settled_reach_server::atlas::road_graph::RoadNodeKind; @@ -676,6 +676,7 @@ fn generate_atlas_layer_response_fixtures() { road_graph: Some(road_graph), settlements: Some(settlements), region_grid: Some(region_grid), + district_window: None, quarter_footprints: Some(quarter_footprints), }; write_fixture( @@ -691,6 +692,7 @@ fn generate_atlas_layer_response_fixtures() { road_graph: None, settlements: None, region_grid: None, + district_window: None, quarter_footprints: None, }; write_fixture( @@ -706,10 +708,43 @@ fn generate_atlas_layer_response_fixtures() { road_graph: None, settlements: None, region_grid: None, + district_window: None, quarter_footprints: None, }; write_fixture( "atlas_response_not_found", &rmp_serde::to_vec_named(¬_found).unwrap(), ); + + // D-226 T-1124 amendment, T-1137: a Ready response carrying a populated + // district_window — the windowed-family field, distinct from the five + // whole-body layers above. A small n=2 window keeps the fixture readable + // while exercising every field (including the REGION_TEMP_NONE_DC + // sentinel and VegetationClass::Marine = 6, both non-negotiable per the + // amendment §3). + let window = DistrictWindowLayer { + center: (10, -5), + n: 2, + morphology: vec![0, 8, 14, 16], // OpenOcean, AlluvialPlain, Alpine, Wetland + elev_q: vec![0, 45, 98, 60], + temp_dc: vec![205, 150, REGION_TEMP_NONE_DC, 80], // 20.5°C, 15.0°C, airless sentinel, 8.0°C + moisture_q: vec![90, 55, 0, 100], + vegetation: vec![6, 3, 0, 5], // Marine, Forest, Absent, RiparianThicket + glaciation: vec![0, 0, 4, 1], // None, None, IceCap, Light + }; + let ready_with_window = AtlasLayerResponse { + body_id: "GJ1c".into(), + status: AtlasLayerStatus::Ready, + layer1: None, + district_grid: None, + road_graph: None, + settlements: None, + region_grid: None, + district_window: Some(window), + quarter_footprints: None, + }; + write_fixture( + "atlas_response_ready_with_window", + &rmp_serde::to_vec_named(&ready_with_window).unwrap(), + ); } -- 2.54.0 From 9e1e6db614f4697e9dac59626327ead8f084f284 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Jul 2026 13:32:06 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(ui):=20T-1138=20regional=20map=20scree?= =?UTF-8?q?n=20=E2=80=94=20click-through=20descent,=20fixed=20planetary=20?= =?UTF-8?q?view,=20windowed=20composite=20(D-226=20T-1124=20SS5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entry per Jeroen's 2026-07-21 revision: planetary heightmap is now FIXED — all drag-pan/wheel-zoom input removed (set_view/get_view_* capture API survives for the golden harness); hover shows a not-to-scale bracket reticle with the real extent labeled (a true n=32 rectangle is sub-pixel on the planetary canvas — the honest representation given the morph transition is deferred), and a click that misses every city marker descends (city-click wins — one gesture, two contextual reads, no modifier). Descent pushes a new 'district' nav screen centered on the click point's DistrictPos via atlas_descend_geometry.district_pos_at (the pixel-to-district inverse of the server mapping, verified against scale.rs). Regional mode: atlas_window_viewer draws the composite (morphology x elev_q lightness base; temp/moisture/veg toggles — temp reuses the region-ramp colorizer exactly; Marine=6 transparent; glaciation always-on tint matching apply_ice_tint's REAL gate, None|Light no-op, over the amendment's looser prose — documented); pan-on-held-composite with edge-crossing refetch + border-fade during the queue-based derive wait; zoom never refetches. atlas_window_cache: LRU keyed (body_id, center, n), touch-on-read, evict-only, no freshness (D-227). atlas_window_request mirrors the generation-proxy pending-retry shape for None-until-derived. Codec: window params omitted from the wire when absent — byte-identical for every existing caller. Live-verified against the T-1137 server in-worktree: real round-trip on a GJ380c coastal district (6 fields x 1024 cells), echo staleness guard, genuine ~1.4s background-derive wait, pan-edge refetch to an adjacent window, cache-hit on re-descent with zero network. Full client suite 3194/3194; gdlint clean on all 18 files. Open follow-ups flagged in-code: header location label always falls back to coordinates (nearest-settlement needs a join the district window does not carry); atlas_standalone.gd's 'atlas_app.gd is never modified' doc line is now imprecise. Co-Authored-By: Claude Fable 5 --- client/scripts/autoloads/sim_bridge.gd | 13 +- client/scripts/protocol/atlas_map_protocol.gd | 48 +- client/scripts/protocol/protocol.gd | 16 +- client/tests/test_atlas_data_delivery.gd | 70 +++ client/tests/test_atlas_descend_entry.gd | 162 ++++++ client/tests/test_atlas_window_cache.gd | 121 +++++ client/tests/test_atlas_window_colors.gd | 170 ++++++ client/tests/test_atlas_window_viewer.gd | 204 +++++++ client/ui/implant/apps/atlas/atlas_app.gd | 32 +- .../apps/atlas/atlas_descend_geometry.gd | 107 ++++ .../apps/atlas/atlas_overlay_colors.gd | 144 +++++ client/ui/implant/apps/atlas/atlas_viewer.gd | 117 +++-- .../implant/apps/atlas/atlas_window_cache.gd | 79 +++ .../implant/apps/atlas/atlas_window_legend.gd | 134 +++++ .../apps/atlas/atlas_window_overlay.gd | 158 ++++++ .../apps/atlas/atlas_window_request.gd | 182 +++++++ .../implant/apps/atlas/atlas_window_viewer.gd | 497 ++++++++++++++++++ .../apps/atlas/screens/district_screen.gd | 40 ++ .../apps/atlas/screens/regional_screen.gd | 6 + 19 files changed, 2228 insertions(+), 72 deletions(-) create mode 100644 client/tests/test_atlas_descend_entry.gd create mode 100644 client/tests/test_atlas_window_cache.gd create mode 100644 client/tests/test_atlas_window_colors.gd create mode 100644 client/tests/test_atlas_window_viewer.gd create mode 100644 client/ui/implant/apps/atlas/atlas_descend_geometry.gd create mode 100644 client/ui/implant/apps/atlas/atlas_window_cache.gd create mode 100644 client/ui/implant/apps/atlas/atlas_window_legend.gd create mode 100644 client/ui/implant/apps/atlas/atlas_window_overlay.gd create mode 100644 client/ui/implant/apps/atlas/atlas_window_request.gd create mode 100644 client/ui/implant/apps/atlas/atlas_window_viewer.gd create mode 100644 client/ui/implant/apps/atlas/screens/district_screen.gd diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 0f03e41b9..8137afc9b 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -425,10 +425,19 @@ func send_named_action(action_name: String, action_data: Variant = null) -> void ## Request a body's generation-cascade layers from the server (#960, D-225). ## Live mode only — sends an AtlasLayerRequest frame; the response arrives via the ## atlas_layers_received signal. No-op in test mode (no server connection). -func request_atlas_layers(body_id: String, up_to: String = "Topography") -> void: +## +## window_center/window_n (T-1138, D-226 T-1124 amendment §1): optional +## windowed district-resolution regional-map query, riding alongside any +## up_to value (the window derivation only needs TerrainAnalysis/BodyParams, +## not a specific whole-body layer to be cached first). Omitted by every +## whole-body-layer caller (show_body()'s existing request), so their wire +## traffic is byte-unchanged. +func request_atlas_layers( + body_id: String, up_to: String = "Topography", window_center: Variant = null, window_n: int = 0 +) -> void: if test_mode or _bridge == null or state != ConnectionState.CONNECTED: return - var bytes := Protocol.encode_atlas_layer_request(body_id, up_to) + var bytes := Protocol.encode_atlas_layer_request(body_id, up_to, window_center, window_n) if bytes.is_empty(): return var err: int = _bridge.send_message(bytes) diff --git a/client/scripts/protocol/atlas_map_protocol.gd b/client/scripts/protocol/atlas_map_protocol.gd index cc6a0e5d9..bbda6245a 100644 --- a/client/scripts/protocol/atlas_map_protocol.gd +++ b/client/scripts/protocol/atlas_map_protocol.gd @@ -16,10 +16,33 @@ class_name AtlasMapProtocol ## A bare map {body_id, up_to} — NOT the Vec array — so the server's ## frame demux routes it to the atlas proxy. up_to is a CascadeLayer unit variant ## (bare string: "Heightmap" | "Topography"). +## +## `window_center`/`window_n` (T-1138, D-226 T-1124 amendment §1): the windowed +## district-resolution regional-map query. Both are OMITTED from the encoded +## map (not sent as null) when window_center is null — this is what makes +## `#[serde(default)]` on the Rust side decode absence as `window_center: None` +## for every whole-body-only caller (request_atlas_layers()'s existing call +## sites), byte-identical to pre-T-1138 wire traffic. window_center is a +## DistrictPos, wire-encoded as the same [row, col] int-pair convention every +## other position field on this channel already uses (road_graph node +## positions, settlement positions, Layer-1 river-cell positions) — there is +## no separate DistrictPos struct-map on the wire, just a 2-element array. +## window_n is left unclamped here — §1 is explicit the server clamps to +## [1, DISTRICT_WINDOW_MAX_N] itself and never trusts the wire value; the +## client-side default/cap constants (DISTRICT_WINDOW_DEFAULT_N/MAX_N) live on +## the regional-window viewer, not duplicated into the codec. static func encode_atlas_layer_request( - mp, body_id: String, up_to: String = "Topography" + mp, + body_id: String, + up_to: String = "Topography", + window_center: Variant = null, + window_n: int = 0 ) -> PackedByteArray: var msg := {"body_id": body_id, "up_to": up_to} + if window_center != null: + var center: Vector2i = window_center + msg["window_center"] = [center.x, center.y] + msg["window_n"] = window_n var result = mp.encode(msg) if result.status != null: push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status) @@ -37,12 +60,22 @@ static func encode_atlas_layer_request( ## the L4 quarter-footprint aggregates, same passthrough pattern — ## QuarterFootprintLayer.entries is a BTreeMap on ## the wire, decoding to a Dictionary with int keys (city_id), no reshaping. -## Key names "road_graph"/"settlements"/"region_grid"/"quarter_footprints" are -## the CONFIRMED wire contract — identical to server/src/atlas/layer_proxy.rs -## AtlasLayerResponse's field names (region_grid pinned 2026-07-14, -## quarter_footprints pinned 2026-07-18; round-tripped by -## test_atlas_overlays.gd and the server's msgpack round-trip tests). This -## remains the one client-side spot to touch if the contract ever changes. +## district_window (T-1138, D-226 T-1124 amendment §2): the windowed +## DistrictWindowLayer — a DISTINCT payload by design (keyed on the request's +## (body, center, n), not the body alone), but the wire passthrough is the +## same shape as every sibling: raw.get() with no reshaping, `None` on the +## wire decodes to GDScript `null` exactly like every other Option field here. +## The response's `center`/`n` echo (inside the layer dict itself) is the +## client's race-condition/staleness guard (§2) — read by the window cache, +## not unwrapped here. +## Key names "road_graph"/"settlements"/"region_grid"/"quarter_footprints"/ +## "district_window" are the CONFIRMED wire contract — identical to +## server/src/atlas/layer_proxy.rs AtlasLayerResponse's field names +## (region_grid pinned 2026-07-14, quarter_footprints pinned 2026-07-18, +## district_window per the D-226 T-1124 amendment §2 struct; round-tripped by +## test_atlas_overlays.gd/test_atlas_data_delivery.gd and the server's msgpack +## round-trip tests). This remains the one client-side spot to touch if the +## contract ever changes. static func atlas_response_from_raw(raw: Variant) -> Variant: if not raw is Dictionary or not raw.has("status"): return null @@ -64,6 +97,7 @@ static func atlas_response_from_raw(raw: Variant) -> Variant: "settlements": raw.get("settlements"), "region_grid": raw.get("region_grid"), "quarter_footprints": raw.get("quarter_footprints"), + "district_window": raw.get("district_window"), } diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index e697aef34..c9989a350 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -776,16 +776,24 @@ static func encode_request_bookmark_catalog() -> PackedByteArray: ## above. Every function below is a thin delegate under its ORIGINAL public ## name — external callers (sim_bridge.gd, test_atlas_overlays.gd, ## test_atlas_data_delivery.gd) are unaffected by the move. +## window_center/window_n (T-1138, D-226 T-1124 amendment §1): optional +## windowed district-resolution regional-map query — see +## atlas_map_protocol.gd's encode_atlas_layer_request doc for the wire shape. +## Omitted callers (every whole-body-layer call site predating T-1138) are +## byte-unchanged. static func encode_atlas_layer_request( - body_id: String, up_to: String = "Topography" + body_id: String, + up_to: String = "Topography", + window_center: Variant = null, + window_n: int = 0 ) -> PackedByteArray: - return _amp().encode_atlas_layer_request(_mp(), body_id, up_to) + return _amp().encode_atlas_layer_request(_mp(), body_id, up_to, window_center, window_n) ## Decode an AtlasLayerResponse (#969, D-225). Returns a Dictionary ## {body_id, status, error, layer1, district_grid, road_graph, settlements, -## region_grid, quarter_footprints}, or null if the bytes are not an atlas -## response (no "status" key — e.g. an ObserverSnapshot). +## region_grid, quarter_footprints, district_window}, or null if the bytes are +## not an atlas response (no "status" key — e.g. an ObserverSnapshot). static func decode_atlas_layer_response(bytes: PackedByteArray) -> Variant: return atlas_response_from_raw(decode_raw(bytes)) diff --git a/client/tests/test_atlas_data_delivery.gd b/client/tests/test_atlas_data_delivery.gd index e0986150d..7c84fa5aa 100644 --- a/client/tests/test_atlas_data_delivery.gd +++ b/client/tests/test_atlas_data_delivery.gd @@ -12,6 +12,12 @@ class_name TestAtlasDataDelivery extends GdUnitTestSuite +# T-1138: REGION_TEMP_NONE_DC sentinel reused verbatim for district_window's +# temp_dc field (D-226 T-1124 amendment §2 — one colorizer/sentinel scheme +# across both zoom levels). No class_name on atlas_overlay_colors.gd (review +# #8 precedent elsewhere in this suite) — preloaded by path. +const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") + # ============================================================================= # Encode/decode round-trips @@ -177,6 +183,70 @@ func test_atlas_response_road_graph_and_settlements_default_null() -> void: assert_that((decoded as Dictionary).get("settlements")).is_null() +# ============================================================================= +# T-1138 (D-226 T-1124 amendment): windowed district-resolution regional map +# ============================================================================= + + +## §1: window_center/window_n are OMITTED (not sent as null) when no window is +## requested — this is what makes an old-shaped call (every whole-body-layer +## call site) byte-identical to pre-T-1138 wire traffic. +func test_encode_atlas_layer_request_omits_window_fields_by_default() -> void: + var bytes := Protocol.encode_atlas_layer_request("GJ1c", "Topography") + var decoded = Messagepack.decode(bytes) + assert_that(decoded.status == null).is_true() + assert_bool(decoded.value.has("window_center")).is_false() + assert_bool(decoded.value.has("window_n")).is_false() + + +## §1: a windowed request carries window_center as a [row, col] pair (the same +## int-pair convention every other position field on this channel already +## uses — road_graph nodes, settlements, Layer-1 river cells) and window_n +## verbatim, unclamped (the server owns the [1, DISTRICT_WINDOW_MAX_N] clamp). +func test_encode_atlas_layer_request_carries_window_params() -> void: + var bytes := Protocol.encode_atlas_layer_request( + "GJ1c", "Topography", Vector2i(140, 260), 32 + ) + var decoded = Messagepack.decode(bytes) + assert_that(decoded.status == null).is_true() + assert_that(decoded.value.get("body_id")).is_equal("GJ1c") + assert_that(decoded.value.get("window_center")).is_equal([140, 260]) + assert_that(decoded.value.get("window_n")).is_equal(32) + + +## §2: district_window is a distinct payload (echoes center/n for the +## client's staleness guard) but the codec passthrough is the same shape as +## every sibling layer — raw.get(), no reshaping. Field types follow the +## established district_grid/region_grid convention: u8 arrays decode as +## PackedByteArray, i16 (temp_dc, REGION_TEMP_NONE_DC sentinel scheme) as a +## plain Array (test_region_grid_round_trips' mean_temp_dc precedent). +func test_atlas_response_district_window_passthrough() -> void: + var window := { + "center": [140, 260], + "n": 32, + "morphology": PackedByteArray([8, 14, 0, 5]), + "elev_q": PackedByteArray([40, 62, 5, 88]), + "temp_dc": [120, 95, AtlasOverlayColors.REGION_TEMP_NONE_DC, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + var raw := {"body_id": "GJ1c", "status": "Ready", "district_window": window} + var decoded: Variant = Protocol.atlas_response_from_raw(raw) + assert_that(decoded).is_not_null() + assert_that((decoded as Dictionary).get("district_window")).is_equal(window) + + +## A body with no window requested (or not yet derived — §1's background-queue +## serving model: the completion may not have landed yet) must decode with +## district_window absent -> null, same "layer hasn't produced yet" contract +## every other Option layer already has. +func test_atlas_response_district_window_default_null() -> void: + var decoded: Variant = Protocol.atlas_response_from_raw({"body_id": "GJ1c", "status": "Ready"}) + assert_that(decoded).is_not_null() + assert_that((decoded as Dictionary).get("district_window")).is_null() + + # ============================================================================= # SimBridge routing — receive_bytes emits the right signal with the right value # ============================================================================= diff --git a/client/tests/test_atlas_descend_entry.gd b/client/tests/test_atlas_descend_entry.gd new file mode 100644 index 000000000..dde9e334f --- /dev/null +++ b/client/tests/test_atlas_descend_entry.gd @@ -0,0 +1,162 @@ +## T-1138 (D-226 T-1124 amendment §5 entry revision): tests for the planetary +## AtlasViewer's click-through descent — the fixed view (no drag-pan/wheel- +## zoom), the pixel-to-DistrictPos inverse mapping (atlas_descend_geometry.gd), +## and the city-click-wins disambiguation rule. +class_name TestAtlasDescendEntry +extends GdUnitTestSuite + +const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") + + +# ============================================================================= +# atlas_descend_geometry.gd — pure geometry (no scene tree needed) +# ============================================================================= + + +## The default n=32 window's real footprint is 32 * 2.048 km = 65.536 km, so +## the label reads "~66 x 66 km" (rounded) — pins the number the amendment's +## "honest labeling" resolution depends on. +func test_reticle_label_extent_matches_district_window_default_n() -> void: + var label: Dictionary = AtlasDescendGeometry.reticle_label(Vector2(100.0, 100.0)) + var expected_km: float = 32.0 * 2048.0 / 1000.0 + assert_str(label["text"]).is_equal("~%.0f × %.0f km" % [expected_km, expected_km]) + + +func test_reticle_label_position_offsets_from_center() -> void: + var center := Vector2(50.0, 60.0) + var label: Dictionary = AtlasDescendGeometry.reticle_label(center) + var pos: Vector2 = label["position"] + assert_float(pos.x).is_greater(center.x) # offset to the right, per §5's resolution + + +## Eight segments (four L-shaped bracket corners, two arms each) — every +## segment's "from" endpoint is exactly one of the four corner anchors, and no +## segment has zero length (a degenerate reticle would be invisible). +func test_reticle_segments_has_eight_nonzero_segments() -> void: + var segments: Array = AtlasDescendGeometry.reticle_segments(Vector2(200.0, 150.0)) + assert_int(segments.size()).is_equal(8) + for seg: Array in segments: + assert_that(seg[0]).override_failure_message( + "a reticle segment must not be zero-length" + ).is_not_equal(seg[1]) + + +## The reticle's overall bounding box is centered on `center` and sized by +## DESCEND_RETICLE_SIZE — a regression guard against an off-center or +## mis-scaled bracket. +func test_reticle_segments_centered_on_input_point() -> void: + var center := Vector2(300.0, 300.0) + var segments: Array = AtlasDescendGeometry.reticle_segments(center) + var min_pt := Vector2(INF, INF) + var max_pt := Vector2(-INF, -INF) + for seg: Array in segments: + for p: Vector2 in seg: + min_pt.x = minf(min_pt.x, p.x) + min_pt.y = minf(min_pt.y, p.y) + max_pt.x = maxf(max_pt.x, p.x) + max_pt.y = maxf(max_pt.y, p.y) + var bbox_center: Vector2 = (min_pt + max_pt) * 0.5 + assert_float(bbox_center.x).is_equal_approx(center.x, 0.01) + assert_float(bbox_center.y).is_equal_approx(center.y, 0.01) + + +# ============================================================================= +# district_pos_at — pixel-to-DistrictPos inverse mapping +# ============================================================================= + + +## No radius (tiny test body fallback, matches the server's own derive_district +## fallback): the district grid IS the heightmap grid 1:1, so a canvas point +## maps to the nearest integer district coordinate directly. +func test_district_pos_at_no_radius_is_1to1_pixel_mapping() -> void: + var pos: Vector2i = AtlasDescendGeometry.district_pos_at( + Vector2(12.4, 7.6), 100.0, 100.0, 0.0 + ) + assert_that(pos).is_equal(Vector2i(12, 8)) + + +func test_district_pos_at_zero_texture_size_is_safe() -> void: + var pos: Vector2i = AtlasDescendGeometry.district_pos_at(Vector2(10.0, 10.0), 0.0, 0.0, 6371.0) + assert_that(pos).is_equal(Vector2i.ZERO) + + +## The equatorial center of the texture (px = tex_w/2) is district column +## district_cols/2 on a body with a radius (equator wraps at district (0,0) +## per the server's own doc: "district (0,0) sits at lon 0 / the equator"). +## The vertical center (py = tex_h/2) is the equator row (row 0). +func test_district_pos_at_center_of_texture_is_near_equator_row_zero() -> void: + var radius_km := 6371.0 + var tex_w := 1024.0 + var tex_h := 512.0 + var pos: Vector2i = AtlasDescendGeometry.district_pos_at( + Vector2(0.0, tex_h * 0.5), tex_w, tex_h, radius_km + ) + assert_int(pos.y).override_failure_message( + "the vertical texture center must map to row 0 (the equator)" + ).is_equal(0) + + +## Panning across the texture's full width sweeps through the FULL district +## column range (not clamped to a tiny sub-range) — a coarse sanity check that +## district_cols is actually being derived from the body's circumference, not +## left at some degenerate default. +func test_district_pos_at_sweeps_full_column_range_across_texture_width() -> void: + var radius_km := 6371.0 + var tex_w := 1024.0 + var tex_h := 512.0 + var left: Vector2i = AtlasDescendGeometry.district_pos_at( + Vector2(0.0, tex_h * 0.5), tex_w, tex_h, radius_km + ) + var right: Vector2i = AtlasDescendGeometry.district_pos_at( + Vector2(tex_w - 1.0, tex_h * 0.5), tex_w, tex_h, radius_km + ) + # An Earth-radius body has thousands of equatorial districts (circumference + # ~40,075 km / 2.048 km per district ≈ 19,568) — near-full-width should + # sweep a large fraction of that, not a handful of columns. + assert_int(absi(right.x - left.x)).is_greater(1000) + + +# ============================================================================= +# AtlasViewer — fixed view (no drag-pan/wheel-zoom) + click-through descent +# ============================================================================= + + +## T-1120 capture API (set_view/get_view_offset/get_view_zoom) must survive +## the removal of user pan/zoom — this is the ticket's own explicit note, and +## the visual-golden harness depends on it. +func test_set_view_still_works_after_pan_zoom_removal() -> void: + var v: AtlasViewer = auto_free(AtlasViewer.new()) + add_child(v) + v.set_view(3.0, Vector2(50.0, -20.0)) + assert_that(v.get_view_zoom()).is_equal_approx(3.0, 0.001) + assert_that(v.get_view_offset()).is_equal(Vector2(50.0, -20.0)) + + +## Clicking (with no heightmap loaded — the guard AtlasViewer's _gui_input +## checks first) must not emit a descend request — there is nothing to +## descend into yet. +func test_no_descend_signal_without_a_loaded_heightmap() -> void: + var v: AtlasViewer = auto_free(AtlasViewer.new()) + add_child(v) + var received: Array = [] + v.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c)) + # _heightmap_texture stays null (no show_body() call) — _gui_input's + # early-return guard should prevent any click handling at all. + var mb := InputEventMouseButton.new() + mb.button_index = MOUSE_BUTTON_LEFT + mb.pressed = true + mb.position = Vector2(100.0, 100.0) + v._gui_input(mb) + assert_int(received.size()).is_equal(0) + + +## RegionalScreen forwards AtlasViewer's district_descend_requested verbatim — +## the nav-stack wiring atlas_app.gd depends on. +func test_regional_screen_forwards_district_descend_requested() -> void: + var screen: RegionalScreen = auto_free(RegionalScreen.new()) + add_child(screen) + var received: Array = [] + screen.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c)) + screen._viewer.district_descend_requested.emit(Vector2i(5, 7)) + assert_int(received.size()).is_equal(1) + assert_that(received[0]).is_equal(Vector2i(5, 7)) diff --git a/client/tests/test_atlas_window_cache.gd b/client/tests/test_atlas_window_cache.gd new file mode 100644 index 000000000..1471f7823 --- /dev/null +++ b/client/tests/test_atlas_window_cache.gd @@ -0,0 +1,121 @@ +## T-1138 (D-226 T-1124 amendment §4): tests for the client-side +## DistrictWindowLayer LRU cache — keyed on (body_id, center, n), LRU-evict +## only (D-227 determinism means no freshness check is ever needed). +class_name TestAtlasWindowCache +extends GdUnitTestSuite + +const AtlasWindowCache := preload("res://ui/implant/apps/atlas/atlas_window_cache.gd") + + +func test_make_key_distinguishes_body_center_and_n() -> void: + var k1 := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32) + var k2 := AtlasWindowCache.make_key("GJ1d", Vector2i(10, 20), 32) # different body + var k3 := AtlasWindowCache.make_key("GJ1c", Vector2i(11, 20), 32) # different center + var k4 := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 64) # different n + assert_str(k1).is_not_equal(k2) + assert_str(k1).is_not_equal(k3) + assert_str(k1).is_not_equal(k4) + + +func test_miss_returns_null_and_has_reports_false() -> void: + var cache := AtlasWindowCache.new() + assert_that(cache.get_window("GJ1c", Vector2i(0, 0), 32)).is_null() + assert_bool(cache.has("GJ1c", Vector2i(0, 0), 32)).is_false() + + +func test_put_then_get_round_trips_exact_window() -> void: + var cache := AtlasWindowCache.new() + var window := {"center": [10, 20], "n": 32, "morphology": PackedByteArray([1, 2, 3])} + cache.put("GJ1c", Vector2i(10, 20), 32, window) + assert_bool(cache.has("GJ1c", Vector2i(10, 20), 32)).is_true() + assert_that(cache.get_window("GJ1c", Vector2i(10, 20), 32)).is_equal(window) + + +## D-227: a window fetched once is valid FOREVER for that (body, center, n) — +## no expiry, no invalidation path. Repeated get_window() calls across a +## simulated time gap (just repeated calls here — there is no clock in this +## cache at all) must keep returning the same stored value. +func test_cached_window_never_expires() -> void: + var cache := AtlasWindowCache.new() + var window := {"center": [0, 0], "n": 32} + cache.put("GJ1c", Vector2i(0, 0), 32, window) + for _i in range(50): + assert_that(cache.get_window("GJ1c", Vector2i(0, 0), 32)).is_equal(window) + + +## Different (body_id, center, n) tuples never collide — this is the whole +## point of the composite key (§4: "cacheable client-side keyed on +## (body_id, center, n)"). +func test_different_bodies_same_center_do_not_collide() -> void: + var cache := AtlasWindowCache.new() + var window_a := {"body": "GJ1c"} + var window_b := {"body": "GJ1d"} + cache.put("GJ1c", Vector2i(10, 20), 32, window_a) + cache.put("GJ1d", Vector2i(10, 20), 32, window_b) + assert_that(cache.get_window("GJ1c", Vector2i(10, 20), 32)).is_equal(window_a) + assert_that(cache.get_window("GJ1d", Vector2i(10, 20), 32)).is_equal(window_b) + + +## Overwriting the same key replaces the value (e.g. a re-fetch of an +## already-cached window from a server that somehow returns a different +## payload — should never happen under D-227, but the cache itself must not +## silently keep the stale one on an explicit put()). +func test_put_overwrites_existing_key() -> void: + var cache := AtlasWindowCache.new() + cache.put("GJ1c", Vector2i(0, 0), 32, {"v": 1}) + cache.put("GJ1c", Vector2i(0, 0), 32, {"v": 2}) + assert_int(cache.size()).is_equal(1) + assert_that(cache.get_window("GJ1c", Vector2i(0, 0), 32)).is_equal({"v": 2}) + + +# ============================================================================= +# LRU eviction +# ============================================================================= + + +func test_eviction_drops_least_recently_used_on_overflow() -> void: + var cache := AtlasWindowCache.new(2) # max 2 entries + cache.put("GJ1c", Vector2i(0, 0), 32, {"id": "a"}) + cache.put("GJ1c", Vector2i(1, 0), 32, {"id": "b"}) + cache.put("GJ1c", Vector2i(2, 0), 32, {"id": "c"}) # evicts (0,0) — oldest, untouched + + assert_int(cache.size()).is_equal(2) + assert_bool(cache.has("GJ1c", Vector2i(0, 0), 32)).override_failure_message( + "oldest entry should have been evicted" + ).is_false() + assert_bool(cache.has("GJ1c", Vector2i(1, 0), 32)).is_true() + assert_bool(cache.has("GJ1c", Vector2i(2, 0), 32)).is_true() + + +## get_window() touches an entry (moves it to most-recently-used) — reading an +## entry must protect it from the NEXT eviction, otherwise "LRU" degrades to +## FIFO the moment anything reads from the cache (the common case: Esc-then- +## re-enter and pan-back are exactly "read a recently-cached window", §4). +func test_get_touches_entry_and_protects_it_from_eviction() -> void: + var cache := AtlasWindowCache.new(2) + cache.put("GJ1c", Vector2i(0, 0), 32, {"id": "a"}) + cache.put("GJ1c", Vector2i(1, 0), 32, {"id": "b"}) + cache.get_window("GJ1c", Vector2i(0, 0), 32) # touch (0,0) — now most-recently-used + cache.put("GJ1c", Vector2i(2, 0), 32, {"id": "c"}) # should evict (1,0), not (0,0) + + assert_bool(cache.has("GJ1c", Vector2i(0, 0), 32)).override_failure_message( + "touched entry should survive eviction" + ).is_true() + assert_bool(cache.has("GJ1c", Vector2i(1, 0), 32)).override_failure_message( + "untouched entry should be the one evicted" + ).is_false() + + +func test_max_entries_clamped_to_at_least_one() -> void: + var cache := AtlasWindowCache.new(0) + cache.put("GJ1c", Vector2i(0, 0), 32, {"id": "a"}) + cache.put("GJ1c", Vector2i(1, 0), 32, {"id": "b"}) + assert_int(cache.size()).is_equal(1) + + +func test_clear_empties_the_cache() -> void: + var cache := AtlasWindowCache.new() + cache.put("GJ1c", Vector2i(0, 0), 32, {"id": "a"}) + cache.clear() + assert_int(cache.size()).is_equal(0) + assert_bool(cache.has("GJ1c", Vector2i(0, 0), 32)).is_false() diff --git a/client/tests/test_atlas_window_colors.gd b/client/tests/test_atlas_window_colors.gd new file mode 100644 index 000000000..0ddb20746 --- /dev/null +++ b/client/tests/test_atlas_window_colors.gd @@ -0,0 +1,170 @@ +## T-1138 (D-226 T-1124 amendment §5): pure color-ramp tests for the +## regional-window base layer + toggle overlays. Every function under test +## lives on atlas_overlay_colors.gd (no class_name — preloaded by path, +## matching test_atlas_overlays.gd's own AtlasOverlayColors const). +class_name TestAtlasWindowColors +extends GdUnitTestSuite + +const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") + + +# ============================================================================= +# Base layer: morphology hue + elev_q lightness modulation +# ============================================================================= + + +## MORPHOLOGY_RGB_OPAQUE must be the SAME 17-entry hue table as the planetary +## overlay's MORPHOLOGY_COLORS (D-226 T-1124 amendment §5: "reusing the T-1123 +## probe's 17-entry MORPHOLOGY_RGB hues verbatim") — only alpha differs (the +## window base is opaque; the planetary overlay draws semi-transparent over a +## heightmap texture). +func test_district_window_morphology_hues_match_planetary_overlay() -> void: + assert_int(AtlasOverlayColors.MORPHOLOGY_RGB_OPAQUE.size()).is_equal( + AtlasOverlayColors.MORPHOLOGY_COLORS.size() + ) + for zone in range(AtlasOverlayColors.MORPHOLOGY_RGB_OPAQUE.size()): + var opaque: Color = AtlasOverlayColors.MORPHOLOGY_RGB_OPAQUE[zone] + var overlay: Color = AtlasOverlayColors.MORPHOLOGY_COLORS[zone] + assert_float(opaque.r).override_failure_message( + "zone %d hue mismatch (r)" % zone + ).is_equal_approx(overlay.r, 0.005) + assert_float(opaque.g).override_failure_message( + "zone %d hue mismatch (g)" % zone + ).is_equal_approx(overlay.g, 0.005) + assert_float(opaque.b).override_failure_message( + "zone %d hue mismatch (b)" % zone + ).is_equal_approx(overlay.b, 0.005) + assert_float(opaque.a).override_failure_message( + "district-window base layer must be OPAQUE (a=1.0), zone %d" % zone + ).is_equal_approx(1.0, 0.001) + + +func test_district_window_morphology_color_out_of_range_is_magenta_sentinel() -> void: + var c: Color = AtlasOverlayColors.district_window_morphology_color(999) + assert_float(c.r).is_equal_approx(1.0, 0.001) + assert_float(c.g).is_equal_approx(0.0, 0.001) + assert_float(c.b).is_equal_approx(1.0, 0.001) + + +## elev_q=0 -> 0.7x lightness; elev_q=100 -> 1.0x lightness (full base color); +## elev_q=50 -> 0.85x (the midpoint of the documented [0.7, 1.0] range). +func test_district_window_elevation_lightness_modulation() -> void: + var base := Color(0.4, 0.4, 0.4, 1.0) + var low: Color = AtlasOverlayColors.district_window_elevation_lightness(base, 0) + var mid: Color = AtlasOverlayColors.district_window_elevation_lightness(base, 50) + var high: Color = AtlasOverlayColors.district_window_elevation_lightness(base, 100) + + assert_float(low.r).is_equal_approx(0.4 * 0.7, 0.001) + assert_float(mid.r).is_equal_approx(0.4 * 0.85, 0.001) + assert_float(high.r).is_equal_approx(0.4 * 1.0, 0.001) + # Alpha is untouched by the lightness multiply. + assert_float(low.a).is_equal_approx(1.0, 0.001) + + +func test_district_window_elevation_lightness_clamps_out_of_range_elev_q() -> void: + var base := Color(0.4, 0.4, 0.4, 1.0) + var below: Color = AtlasOverlayColors.district_window_elevation_lightness(base, -20) + var above: Color = AtlasOverlayColors.district_window_elevation_lightness(base, 500) + assert_float(below.r).is_equal_approx(0.4 * 0.7, 0.001) + assert_float(above.r).is_equal_approx(0.4 * 1.0, 0.001) + + +# ============================================================================= +# Vegetation ramp (Marine transparent, D-226 T-1124 amendment §3/§5) +# ============================================================================= + + +## Marine (VegetationClass discriminant 6, T-1126) MUST render transparent — +## "non-negotiable inclusion, not a nice-to-have" per the amendment §3. This is +## the one assertion in this suite with the highest stakes: a regression here +## re-introduces the exact ocean-blind-vegetation bug T-1126 was created to fix. +func test_vegetation_marine_is_transparent() -> void: + var c: Color = AtlasOverlayColors.vegetation_color(AtlasOverlayColors.VEGETATION_MARINE) + assert_that(c).is_equal(Color.TRANSPARENT) + assert_int(AtlasOverlayColors.VEGETATION_MARINE).is_equal(6) + + +## Every VegetationClass discriminant (0 Absent .. 5 RiparianThicket, excluding +## 6 Marine which is transparent) must resolve to a DISTINCT, non-transparent +## color — the amendment's exhaustive-disposition mandate (§3) extended to the +## full field-list, matching quarter_notch_kind's own "every variant reads as +## something" test precedent in test_atlas_overlays.gd. +func test_vegetation_ramp_is_exhaustive_and_distinct() -> void: + var seen: Array = [] + for vc in range(0, 6): # 0..5, Marine (6) handled separately above + var c: Color = AtlasOverlayColors.vegetation_color(vc) + assert_float(c.a).override_failure_message( + "VegetationClass %d must not be transparent" % vc + ).is_greater(0.0) + assert_that(seen).override_failure_message( + "VegetationClass %d collides with an earlier entry's color" % vc + ).not_contains([c]) + seen.append(c) + + +func test_vegetation_color_unrecognized_falls_back_to_absent_reading() -> void: + var unknown: Color = AtlasOverlayColors.vegetation_color(999) + var absent: Color = AtlasOverlayColors.vegetation_color(0) + assert_that(unknown).is_equal(absent) + + +# ============================================================================= +# Glaciation ice-tint modifier (D-226 T-1124 amendment §5, ported apply_ice_tint) +# ============================================================================= + + +## None (0) and Light (1) must return the base color UNCHANGED — this is the +## load-bearing divergence from the amendment's own prose summary ("gated on +## glaciation_grade >= Light"): the REFERENCE implementation the amendment +## names for porting (aliveness_probe::apply_ice_tint) excludes Light too, +## because grade 1 is glacial-erosion signatures, not visible ice. This test +## pins that the port, not the summary, is what shipped. +func test_glaciation_tint_none_and_light_are_no_ops() -> void: + var base := Color(0.3, 0.5, 0.3, 0.8) + _assert_color_approx(AtlasOverlayColors.glaciation_tint(base, 0), base) # None + _assert_color_approx(AtlasOverlayColors.glaciation_tint(base, 1), base) # Light + + +## Moderate (2) / Heavy (3) / IceCap (4) blend increasingly toward +## GLACIATION_ICE_WHITE — each grade strictly closer to white than the last +## (monotonic tint strength), matching apply_ice_tint's 0.30/0.50/0.70 alphas. +func test_glaciation_tint_increases_with_grade() -> void: + var base := Color(0.1, 0.1, 0.1, 1.0) # far from ice-white, maximizes signal + var white: Color = AtlasOverlayColors.GLACIATION_ICE_WHITE + + var moderate: Color = AtlasOverlayColors.glaciation_tint(base, 2) + var heavy: Color = AtlasOverlayColors.glaciation_tint(base, 3) + var ice_cap: Color = AtlasOverlayColors.glaciation_tint(base, 4) + + var dist_moderate: float = _color_distance(base, moderate) + var dist_heavy: float = _color_distance(base, heavy) + var dist_ice_cap: float = _color_distance(base, ice_cap) + + assert_float(dist_heavy).override_failure_message( + "Heavy should tint more than Moderate" + ).is_greater(dist_moderate) + assert_float(dist_ice_cap).override_failure_message( + "IceCap should tint more than Heavy" + ).is_greater(dist_heavy) + + # IceCap (alpha 0.70) should land noticeably closer to white than to base. + assert_float(_color_distance(ice_cap, white)).is_less(_color_distance(ice_cap, base)) + + +func test_glaciation_tint_out_of_range_grade_is_no_op() -> void: + var base := Color(0.3, 0.5, 0.3, 0.8) + _assert_color_approx(AtlasOverlayColors.glaciation_tint(base, 999), base) + + +func _assert_color_approx(actual: Color, expected: Color) -> void: + assert_float(actual.r).is_equal_approx(expected.r, 0.0001) + assert_float(actual.g).is_equal_approx(expected.g, 0.0001) + assert_float(actual.b).is_equal_approx(expected.b, 0.0001) + assert_float(actual.a).is_equal_approx(expected.a, 0.0001) + + +## Godot 4's Color has no distance_to() — plain Euclidean over RGB (alpha +## deliberately excluded: the tint blend leaves alpha untouched, so including +## it would just add a constant offset with no signal). +static func _color_distance(a: Color, b: Color) -> float: + return Vector3(a.r, a.g, a.b).distance_to(Vector3(b.r, b.g, b.b)) diff --git a/client/tests/test_atlas_window_viewer.gd b/client/tests/test_atlas_window_viewer.gd new file mode 100644 index 000000000..22d7ab41f --- /dev/null +++ b/client/tests/test_atlas_window_viewer.gd @@ -0,0 +1,204 @@ +## T-1138 (D-226 T-1124 amendment §1-§5): tests for AtlasWindowViewer + its +## companion request/cache orchestration (atlas_window_request.gd) — pure +## logic against hand-built AtlasLayerResponse-shaped dicts, matching the +## ticket's "unit tests against hand-built response dicts" instruction. Live +## end-to-end verification against a real spawned server is separate +## (companion-run evidence, not gdUnit — this file never touches SimBridge's +## live-mode path, only the response-handling/cache/overlay logic that path +## eventually feeds). +class_name TestAtlasWindowViewer +extends GdUnitTestSuite + +# atlas_window_request.gd has no class_name (review #8 precedent throughout +# this cluster) — preloaded once here, not re-load()ed per test (gdlint +# duplicated-load). +const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") + + +## Build a hand-authored DistrictWindowLayer dict (n=2, matching the shape +## district_grid/region_grid fixtures already use elsewhere in this suite). +static func _mock_window(center: Vector2i, n: int = 2) -> Dictionary: + return { + "center": [center.x, center.y], + "n": n, + "morphology": PackedByteArray([8, 14, 0, 1]), + "elev_q": PackedByteArray([40, 90, 5, 60]), + "temp_dc": [120, 95, -32768, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + + +static func _mock_response(body_id: String, window: Variant) -> Dictionary: + return {"body_id": body_id, "status": "Ready", "district_window": window} + + +# ============================================================================= +# AtlasWindowViewer — entry + overlay defs +# ============================================================================= + + +func test_enter_with_no_response_leaves_window_null_and_pending() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20)) + assert_that(v.get_district_window()).is_null() + + +## Feeding a matching Ready response (via the SAME SimBridge.atlas_layers_received +## routing path the viewer subscribes to in _ready()) must populate the window. +func test_enter_then_matching_response_populates_window() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + + var window: Dictionary = _mock_window(Vector2i(10, 20), 2) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) + + assert_that(v.get_district_window()).is_equal(window) + + +## A response for a DIFFERENT body must not populate the window — the +## body_id scoping AtlasWindowRequest.on_response() checks. +func test_response_for_different_body_is_ignored() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + + var window: Dictionary = _mock_window(Vector2i(10, 20), 2) + SimBridge.atlas_layers_received.emit(_mock_response("GJ_wrong_body", window)) + + assert_that(v.get_district_window()).is_null() + + +## A response whose echoed (center, n) does NOT match what was last asked for +## is stale — §2's race-condition guard. Simulates a superseded-by-a-later-pan +## response arriving after the fact. +func test_response_with_mismatched_echo_is_discarded_as_stale() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + + var stale_window: Dictionary = _mock_window(Vector2i(99, 99), 2) # wrong center + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", stale_window)) + + assert_that(v.get_district_window()).is_null() + + +## §1: an as-yet-underived window rides as `district_window: None` inside a +## Ready response — this is NOT an error, the viewer just keeps waiting +## (get_district_window() stays null, no crash, no window content shown). +func test_ready_response_with_null_district_window_keeps_waiting() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", null)) + assert_that(v.get_district_window()).is_null() + + +func test_overlay_defs_include_the_three_toggle_ids() -> void: + var ids: Array = [] + for d: Dictionary in AtlasWindowViewer.OVERLAY_DEFS: + ids.append(d["id"]) + assert_that(ids).contains(["gen_dw_temp", "gen_dw_moisture", "gen_dw_veg"]) + + +## Glaciation is explicitly NOT a toggle id (§5: "an always-on modifier, not +## a toggle") — a regression here would silently re-introduce it as a switch. +func test_overlay_defs_do_not_include_glaciation() -> void: + var ids: Array = [] + for d: Dictionary in AtlasWindowViewer.OVERLAY_DEFS: + ids.append(d["id"]) + assert_that(ids).not_contains(["gen_dw_glaciation", "gen_dw_ice"]) + + +func test_set_overlay_visible_toggles_and_is_overlay_visible_reflects_it() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + assert_bool(v.is_overlay_visible("gen_dw_temp")).is_false() + v.set_overlay_visible("gen_dw_temp", true) + assert_bool(v.is_overlay_visible("gen_dw_temp")).is_true() + + +func test_set_overlay_visible_unknown_id_is_a_noop() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.set_overlay_visible("not_a_real_overlay", true) + assert_bool(v.is_overlay_visible("not_a_real_overlay")).is_false() + + +# ============================================================================= +# T-1120 capture-API parity (the ticket's explicit note: must survive here too) +# ============================================================================= + + +func test_set_view_and_getters_round_trip() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.set_view(2.5, Vector2(30.0, -10.0)) + assert_that(v.get_view_zoom()).is_equal_approx(2.5, 0.001) + assert_that(v.get_view_offset()).is_equal(Vector2(30.0, -10.0)) + + +func test_set_view_clamps_to_min_max_zoom() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.set_view(0.01, Vector2.ZERO) + assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MIN_ZOOM, 0.001) + v.set_view(1000.0, Vector2.ZERO) + assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MAX_ZOOM, 0.001) + + +# ============================================================================= +# AtlasWindowRequest — cache reuse (§4's Esc-then-re-enter / pan-back hit) +# ============================================================================= + + +func test_window_request_cache_hit_emits_synchronously_no_pending() -> void: + var owner_stub := RefCounted.new() + var req = auto_free(AtlasWindowRequest.new(owner_stub)) + add_child(req) + + # Prime the cache directly (bypassing the network path) — the ticket's + # own instruction: unit test against hand-built response dicts. + req.get_cache().put("GJ380c", Vector2i(1, 1), 2, _mock_window(Vector2i(1, 1), 2)) + + var received: Array = [] + req.window_ready.connect(func(w: Dictionary) -> void: received.append(w)) + req.request_now("GJ380c", Vector2i(1, 1), 2) + + assert_int(received.size()).is_equal(1) + assert_bool(req.is_pending()).override_failure_message( + "a cache hit must never leave the request pending" + ).is_false() + + +func test_window_request_cache_miss_leaves_pending_true() -> void: + var owner_stub := RefCounted.new() + var req = auto_free(AtlasWindowRequest.new(owner_stub)) + add_child(req) + req.request_now("GJ380c", Vector2i(5, 5), 2) + assert_bool(req.is_pending()).is_true() + + +## on_response() with a matching Ready+window response resolves the pending +## request AND populates the cache — verified by a second request_now() call +## for the same (center, n) becoming a cache hit with zero additional pending. +func test_on_response_resolves_and_populates_cache_for_next_request() -> void: + var owner_stub := RefCounted.new() + var req = auto_free(AtlasWindowRequest.new(owner_stub)) + add_child(req) + + req.request_now("GJ380c", Vector2i(2, 2), 2) + assert_bool(req.is_pending()).is_true() + + var window: Dictionary = _mock_window(Vector2i(2, 2), 2) + req.on_response(_mock_response("GJ380c", window)) + assert_bool(req.is_pending()).is_false() + + # Re-request the SAME (body, center, n) — must be a cache hit, no pending. + req.request_now("GJ380c", Vector2i(2, 2), 2) + assert_bool(req.is_pending()).override_failure_message( + "a second request for an already-resolved window must hit the cache" + ).is_false() diff --git a/client/ui/implant/apps/atlas/atlas_app.gd b/client/ui/implant/apps/atlas/atlas_app.gd index 85fa9a3ec..7e79fd34d 100644 --- a/client/ui/implant/apps/atlas/atlas_app.gd +++ b/client/ui/implant/apps/atlas/atlas_app.gd @@ -13,6 +13,7 @@ var _reach_screen = null # ReachScreen var _system_screen = null # SystemScreen var _planet_screen = null # PlanetScreen var _regional_screen = null # RegionalScreen +var _district_screen = null # DistrictScreen (T-1138) func _ready() -> void: @@ -47,8 +48,13 @@ func on_install() -> void: _regional_screen = RegionalScreen.new() _regional_screen.back_requested.connect(_on_regional_back) _regional_screen.economics_link_requested.connect(_forward_economics_link) + _regional_screen.district_descend_requested.connect(_on_district_descend_requested) register_screen("regional", _regional_screen) + _district_screen = DistrictScreen.new() + _district_screen.back_requested.connect(_on_district_back) + register_screen("district", _district_screen) + nav.set_default("reach") @@ -65,7 +71,12 @@ func _unhandled_key_input(event: InputEvent) -> void: return if not event.is_pressed() or event.is_echo(): return - if current_screen_id() == "regional": + # "regional" (the planetary heightmap, AtlasViewer) and "district" (the + # windowed regional-window screen, AtlasWindowViewer, T-1138) both handle + # their own Esc via _gui_input — same delegation shape for both, so a + # stray M/other unhandled key on either screen doesn't ALSO fire this + # app's own _handle_key underneath the viewer's own handling. + if current_screen_id() == "regional" or current_screen_id() == "district": return _handle_key(event as InputEventKey) get_viewport().set_input_as_handled() @@ -137,6 +148,25 @@ func _on_regional_back() -> void: nav.pop() +## T-1138: the planetary click-through descent (§5 entry revision) — pushes +## the "district" screen centered on the click point's derived DistrictPos. +## A real nav.push() (not a swap-in-place, unlike the superseded zoom- +## threshold design) so Esc's existing nav.pop() path (DistrictScreen's own +## back_requested -> _on_district_back below) returns to exactly the +## planetary body the player descended from, at whatever crumb depth got +## them there (reach -> system -> regional -> district). +func _on_district_descend_requested(district_center: Vector2i) -> void: + nav.push("district", { + "body": nav.current_payload().get("body", {}), + "system": nav.current_payload().get("system", {}), + "district_center": district_center, + }) + + +func _on_district_back() -> void: + nav.pop() + + func _forward_economics_link(system_id: String) -> void: economics_link_requested.emit(system_id) diff --git a/client/ui/implant/apps/atlas/atlas_descend_geometry.gd b/client/ui/implant/apps/atlas/atlas_descend_geometry.gd new file mode 100644 index 000000000..4202052f3 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_descend_geometry.gd @@ -0,0 +1,107 @@ +extends RefCounted + +## Pure geometry helpers for AtlasViewer's T-1138 descent affordance (D-226 +## T-1124 amendment §5 entry revision) — factored out to keep atlas_viewer.gd +## under gdlint's max-file-lines cap, same rationale/shape as +## atlas_overlay_colors.gd's split from atlas_marker_overlay.gd (draw_line()/ +## draw_string() are CanvasItem instance methods called implicitly on `self`, +## so the actual draw calls stay on AtlasViewer — only the pure lookups/math +## that decide WHERE/WHAT to draw move here): +## const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") +## +## D-243: 2,048 m per district side — the source-canonical unit the server's +## derive_district()/DISTRICT_WINDOW_DEFAULT_N (32) both key off of. +const DISTRICT_M: float = 2048.0 +const DISTRICT_WINDOW_DEFAULT_N: int = 32 + +## Fixed on-screen reticle size (px) — deliberately NOT scaled to the +## window's true planetary footprint. +## +## The true footprint of a DISTRICT_WINDOW_DEFAULT_N=32 window is +## 32 * 2.048 km = ~65.5 km per side (~131 km at the n=64 cap) — at every +## zoom level AtlasViewer's _fit_to_view() ever produces for a whole-planet +## heightmap (MAX_ZOOM=8.0 on a texture that already spans the full body), +## that distance is on the order of a handful of PIXELS. A true-extent +## rectangle would therefore be visually indistinguishable from a dot +## regardless of zoom — not an honest representation, just an illegible one; +## the amendment explicitly rejects "implying more coverage than real" but a +## sub-pixel rectangle fails the OPPOSITE way (implying almost no coverage, +## which is equally dishonest about what a click actually captures). +## +## The resolution (D-226 T-1124 amendment §5's open design point, resolved +## here): a small FIXED-SIZE bracket reticle (reads clearly at any zoom, same +## idiom as the marker overlay's fixed-size POI glyphs elsewhere on this map) +## plus a text label giving the REAL extent in km — "honest" comes from the +## label's number, not from the reticle's pixel size pretending to be to +## scale. This is a reticle, explicitly not scaled to true size, with the +## real extent stated next to it — the amendment's second named option +## (chosen over a true-extent rectangle + zoom-in cut). +const DESCEND_RETICLE_SIZE: float = 28.0 +const COLOR_DESCEND_RETICLE: Color = Color(0.70, 0.88, 1.0, 0.85) # matches COLOR_GATE_MARKER family + + +## The eight line segments (as [from, to] pairs) for the reticle's four +## L-shaped bracket corners — reads as "this is a bounded region", distinct +## from the circular city-marker glyphs and diamond gate markers already on +## this map (D-226's hue=type/shape=identity instinct applied to interaction +## affordances). Flat segment-pair array so the caller's draw_line() loop is +## a one-liner, not a struct AtlasViewer needs to know the shape of. +static func reticle_segments(center: Vector2) -> Array: + var half: float = DESCEND_RETICLE_SIZE * 0.5 + var arm: float = half * 0.5 + var corners: Array = [ + center + Vector2(-half, -half), + center + Vector2(half, -half), + center + Vector2(half, half), + center + Vector2(-half, half), + ] + var h_dirs: Array = [Vector2(1, 0), Vector2(-1, 0), Vector2(-1, 0), Vector2(1, 0)] + var v_dirs: Array = [Vector2(0, 1), Vector2(0, 1), Vector2(0, -1), Vector2(0, -1)] + var segments: Array = [] + for i in range(4): + segments.append([corners[i], corners[i] + h_dirs[i] * arm]) + segments.append([corners[i], corners[i] + v_dirs[i] * arm]) + return segments + + +## Label position (offset from the reticle center, to the right of it) + the +## real-extent text — "~65 x 65 km" for the default n=32 window. +static func reticle_label(center: Vector2) -> Dictionary: + var half: float = DESCEND_RETICLE_SIZE * 0.5 + var extent_km: float = float(DISTRICT_WINDOW_DEFAULT_N) * DISTRICT_M / 1000.0 + return { + "position": center + Vector2(half + 6.0, 4.0), + "text": "~%.0f × %.0f km" % [extent_km, extent_km], + } + + +## Inverse of the server's derive_district() pixel mapping +## (server/src/atlas/district_profile.rs) — a `true_district_of_pixel`-style +## function, per the amendment's §5 carry-over wording. The server's forward +## mapping (body has a radius) is: +## px = ((dx * DISTRICT_M) / circumference_m mod 1.0) * tex_w +## py = (0.5 + clamp(dy * DISTRICT_M / meridian_m, -0.5, 0.5)) * tex_h +## which is a linear scaling of the same world-metre fraction the equatorial/ +## meridian district COUNT already IS (district_cols = round(circumference_m +## / DISTRICT_M), the same value build_district_grid()'s `cols` converges to +## for a body tiled edge-to-edge) — so inverting is "pixel fraction * district +## count", not a re-derivation of the server's geodesy. Self-contained: does +## NOT depend on district_grid (the whole-body layer) having arrived yet, so +## descent works immediately on entry even before that async layer resolves. +## The "no radius" branch (tiny test bodies, body_radius_km absent/<=0) +## mirrors the server's own fallback: the district grid IS the heightmap +## grid 1:1. +static func district_pos_at( + canvas_pt: Vector2, tex_w: float, tex_h: float, body_radius_km: float +) -> Vector2i: + if tex_w <= 0.0 or tex_h <= 0.0: + return Vector2i.ZERO + if body_radius_km <= 0.0: + return Vector2i(roundi(canvas_pt.x), roundi(canvas_pt.y)) + var circumference_m: float = TAU * body_radius_km * 1000.0 + var meridian_m: float = PI * body_radius_km * 1000.0 + var district_cols: float = roundf(circumference_m / DISTRICT_M) + var district_rows_half: float = roundf((meridian_m / DISTRICT_M) * 0.5) + var col: int = roundi((canvas_pt.x / tex_w) * district_cols) + var row: int = roundi(((canvas_pt.y / tex_h) - 0.5) * district_rows_half * 2.0) + return Vector2i(col, row) diff --git a/client/ui/implant/apps/atlas/atlas_overlay_colors.gd b/client/ui/implant/apps/atlas/atlas_overlay_colors.gd index fe0e61cb3..eff411d7c 100644 --- a/client/ui/implant/apps/atlas/atlas_overlay_colors.gd +++ b/client/ui/implant/apps/atlas/atlas_overlay_colors.gd @@ -86,6 +86,82 @@ const QUARTER_GLYPH_DENSITY_SCALE: float = 6.0 const COLOR_QUARTER_LOW_DENSITY: Color = Color(0.55, 0.48, 0.30, 0.6) # dim gold const COLOR_QUARTER_HIGH_DENSITY: Color = Color(0.94, 0.82, 0.38, 1.0) # COLOR_SETTLEMENT gold +## T-1138 (D-226 T-1124 amendment §5) — the regional-window base layer's +## MORPHOLOGY_RGB hues, port of aliveness_probe.rs's 17-entry table (same +## file/discriminant order as MORPHOLOGY_COLORS above, but FULL ALPHA — those +## colors carry a 0.55 alpha for the "semi-transparent overlay ON the +## heightmap" planetary-view use case; the window's morphology layer IS the +## base terrain read (no heightmap texture underneath it to show through), so +## the district-window base needs opaque versions of the identical hues, not +## a second independently-chosen palette. +const MORPHOLOGY_RGB_OPAQUE: Array = [ + Color(0.102, 0.200, 0.451, 1.0), # 0 OpenOcean + Color(0.200, 0.400, 0.651, 1.0), # 1 Lake + Color(0.451, 0.549, 0.502, 1.0), # 2 TidalFlat + Color(0.851, 0.780, 0.451, 1.0), # 3 DuneStrand + Color(0.502, 0.502, 0.549, 1.0), # 4 CliffCoast + Color(0.302, 0.400, 0.502, 1.0), # 5 Fjord + Color(0.400, 0.651, 0.600, 1.0), # 6 Delta + Color(0.302, 0.549, 0.549, 1.0), # 7 Estuarine + Color(0.302, 0.600, 0.302, 1.0), # 8 AlluvialPlain + Color(0.451, 0.702, 0.400, 1.0), # 9 RiverBank + Color(0.349, 0.600, 0.502, 1.0), # 10 MeanderReach + Color(0.549, 0.600, 0.451, 1.0), # 11 BraidedPlain + Color(0.502, 0.549, 0.302, 1.0), # 12 ValleyFloor + Color(0.549, 0.451, 0.302, 1.0), # 13 MountainPass + Color(0.800, 0.820, 0.851, 1.0), # 14 Alpine + Color(0.451, 0.149, 0.122, 1.0), # 15 Volcanic + Color(0.251, 0.451, 0.400, 1.0), # 16 Wetland +] + +## Elevation lightness modulation (D-226 T-1124 amendment §5): one +## `0.7 + 0.3*(elev_q/100)` multiply per cell — relief read without a second +## draw call, hue=type / lightness=elevation. +const DISTRICT_WINDOW_ELEV_LIGHTNESS_BASE: float = 0.7 +const DISTRICT_WINDOW_ELEV_LIGHTNESS_RANGE: float = 0.3 + +## T-1138 — vegetation green-family ramp (D-226 T-1124 amendment §5). +## VegetationClass discriminants (server/src/atlas/district_profile.rs, +## T-1126): 0 Absent, 1 Barren, 2 Scrub, 3 Forest, 4 RiparianScrub, +## 5 RiparianThicket, 6 Marine. Marine is handled by the caller (transparent — +## see vegetation_color() below), not a table entry, since "transparent" is +## not a color decision but a "don't draw" decision. +const COLOR_VEGETATION_ABSENT: Color = Color(0.35, 0.32, 0.28, 0.55) # airless/no branch — dim neutral +const COLOR_VEGETATION_BARREN: Color = Color(0.55, 0.50, 0.38, 0.55) # sparse — dry olive-tan +const COLOR_VEGETATION_SCRUB: Color = Color(0.45, 0.58, 0.32, 0.65) # transitional — olive-green +const COLOR_VEGETATION_FOREST: Color = Color(0.20, 0.50, 0.24, 0.75) # closed-canopy — deep green +# Riparian bands read as a saturated variant of their base class (D-226 T-1124 +# amendment §3's exhaustive-disposition mandate — every VegetationClass +# variant needs SOME reading, not just the three density-ladder rungs). +const COLOR_VEGETATION_RIPARIAN_SCRUB: Color = Color(0.35, 0.65, 0.45, 0.80) # scrub + water = teal-green +const COLOR_VEGETATION_RIPARIAN_THICKET: Color = Color(0.15, 0.55, 0.38, 0.85) # forest + water = teal-dark-green +const VEGETATION_MARINE: int = 6 # T-1126 — Marine renders transparent, see vegetation_color() + +## T-1138 — glaciation ice-tint MODIFIER endpoint + per-grade alpha (D-226 +## T-1124 amendment §5, porting aliveness_probe.rs's apply_ice_tint() +## unchanged in mechanism). GlaciationGrade discriminants (T-1127): +## 0 None, 1 Light, 2 Moderate, 3 Heavy, 4 IceCap. +## +## The amendment's own prose summarizes the gate as "glaciation_grade >= +## Light"; the REFERENCE implementation it names or explicitly asks to be +## ported (apply_ice_tint, aliveness_probe.rs:602) returns the base color +## UNCHANGED for None *and* Light, with its own code comment explaining why: +## grade 1 is glacial-erosion signatures (U-valleys, moraines — landform +## history visible on coasts as warm as +5C mean), not ice cover; visible ice +## starts at grade >= Moderate, which is what D-239 §5 itself gates glacial +## forms on. This table follows the port (the load-bearing instruction), +## not the summary. GRADE_LIGHT is kept as an explicit key mapped to 0.0 — +## not "absent from the dict" — so a caller iterating grades sees the +## "no tint, and here is why" decision instead of an implicit fallback. +const GLACIATION_ICE_WHITE: Color = Color(0.894, 0.941, 0.980, 1.0) # 228,240,250 / 255 +const GLACIATION_TINT_ALPHA: Dictionary = { + 0: 0.0, # None + 1: 0.0, # Light — erosion signatures only, not visible ice (see doc above) + 2: 0.30, # Moderate + 3: 0.50, # Heavy + 4: 0.70, # IceCap +} + static func morphology_color(zone: int) -> Color: if zone >= 0 and zone < MORPHOLOGY_COLORS.size(): @@ -162,3 +238,71 @@ static func quarter_notch_kind(district_type: String) -> String: return district_type.to_lower() _: return "plain" + + +## T-1138 — the regional-window base layer's hue read: MORPHOLOGY_RGB_OPAQUE +## looked up by MorphologyZone discriminant, out-of-palette falls back to +## magenta (matches aliveness_probe.rs's own out-of-palette sentinel — a +## palette/enum drift bug should be LOUD, not silently grey). +static func district_window_morphology_color(zone: int) -> Color: + if zone >= 0 and zone < MORPHOLOGY_RGB_OPAQUE.size(): + return MORPHOLOGY_RGB_OPAQUE[zone] + return Color(1.0, 0.0, 1.0, 1.0) + + +## Lightness modulation by elev_q (D-226 T-1124 amendment §5): multiply RGB by +## `0.7 + 0.3*(elev_q/100)`, alpha untouched. elev_q is clamped to [0, 100] +## before the multiply so an out-of-range value (shouldn't happen — server +## already clamps per the district_grid precedent) can't push lightness +## outside [0.7, 1.0]. +static func district_window_elevation_lightness(base: Color, elev_q: int) -> Color: + var clamped: int = clampi(elev_q, 0, 100) + var lightness: float = ( + DISTRICT_WINDOW_ELEV_LIGHTNESS_BASE + + DISTRICT_WINDOW_ELEV_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 +## transparent result rather than drawing a zero-alpha rect (cheaper, and +## avoids relying on alpha blending to do the "don't draw" work). Every other +## discriminant (including out-of-range, e.g. a future VegetationClass +## variant this table hasn't caught up to yet) falls back to the Absent +## reading rather than a magenta sentinel — vegetation is deliberately a +## SOFTER failure mode than morphology (an unrecognized vegetation class is +## "no distinguishable vegetation signal", not a palette/enum drift bug of +## the same severity as an unrecognized terrain zone). +static func vegetation_color(vegetation_class: int) -> Color: + match vegetation_class: + VEGETATION_MARINE: + return Color.TRANSPARENT + 1: + return COLOR_VEGETATION_BARREN + 2: + return COLOR_VEGETATION_SCRUB + 3: + return COLOR_VEGETATION_FOREST + 4: + return COLOR_VEGETATION_RIPARIAN_SCRUB + 5: + return COLOR_VEGETATION_RIPARIAN_THICKET + _: + return COLOR_VEGETATION_ABSENT + + +## Glaciation ice-tint MODIFIER (D-226 T-1124 amendment §5, porting +## aliveness_probe.rs's apply_ice_tint()) — blends `base` toward glacial +## ice-white by the per-grade alpha in GLACIATION_TINT_ALPHA. Grades None/Light +## return `base` unchanged (alpha 0.0 lerp is a no-op, but the explicit +## lookup-then-lerp keeps this one code path for every grade rather than an +## early-return special case, matching the port's single lerp_rgb call site). +## Out-of-range grade values are treated as None (no tint) — the same "softer +## failure than morphology" reasoning as vegetation_color() above. +static func glaciation_tint(base: Color, glaciation_grade: int) -> Color: + var alpha: float = float(GLACIATION_TINT_ALPHA.get(glaciation_grade, 0.0)) + if alpha <= 0.0: + return base + return base.lerp(GLACIATION_ICE_WHITE, alpha) diff --git a/client/ui/implant/apps/atlas/atlas_viewer.gd b/client/ui/implant/apps/atlas/atlas_viewer.gd index 44cdfc668..c08e299c3 100644 --- a/client/ui/implant/apps/atlas/atlas_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_viewer.gd @@ -1,35 +1,30 @@ class_name AtlasViewer extends Control -## Atlas regional viewer — heightmap PNG with pan/zoom + marker overlay (#835, D-191). +## Atlas regional viewer — heightmap PNG with marker overlay (#835, D-191). ## ## Lives as a child of RegionalScreen, shown when the atlas nav stack is at -## "regional". Receives body/system context via show_body(). Emits back_pressed -## and economics_link_requested so RegionalScreen can route them. +## "regional". Receives body/system context via show_body(). Emits back_pressed, +## economics_link_requested, and district_descend_requested so RegionalScreen +## can route them. ## ## Design notes: -## - Heightmap texture is drawn on a Node2D _canvas child. Pan = _canvas.position, -## zoom = _canvas.scale. MarkerOverlay is a child of _canvas so markers auto- -## follow the same transform. +## - Heightmap texture is drawn on a Node2D _canvas child. MarkerOverlay is a +## child of _canvas so markers auto-follow the same transform. ## - markers.json schema (D-191 §8): cities, roads, railroads, pois, plus rivers, ## oceans, mountain_ranges with `center: [row, col]` and optional names. ## - Empty markers case (server #832/#833 not yet shipped): bare heightmap renders ## fine, no sidebar opens, overlays draw nothing. ## - Overlays (#836) plug into _overlay_visibility dict and _draw_overlays(). -## -## Navigation: -## Mouse drag pan the map -## Mouse wheel zoom in / out (centered on cursor) -## Click city open city data panel -## R reset view -## Esc back to body entry +## - T-1138: view is FIXED (set_view() is capture-API-only); descent screen has the reticle math. +## Navigation: Hover reticle · Click descend · Click city data (wins) · R reset(no-op) · Esc back signal back_pressed signal economics_link_requested(system_id: String) +signal district_descend_requested(district_center: Vector2i) # T-1138 const MIN_ZOOM: float = 0.5 const MAX_ZOOM: float = 8.0 -const ZOOM_STEP: float = 1.15 const PANEL_WIDTH: float = 320.0 const PANEL_MARGIN: float = 16.0 @@ -37,6 +32,8 @@ const PANEL_MARGIN: float = 16.0 # wrapping overlay bar must not overlap. const OVERLAY_BAR_HEADER_RESERVE: float = 360.0 +const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") # T-1138 + # ── Colors ──────────────────────────────────────────────────────────────────── const COLOR_BG: Color = Color("#0d1117") const COLOR_HEIGHTMAP_TINT: Color = Color(0.85, 0.88, 0.95, 1.0) @@ -215,16 +212,15 @@ var _tex_h: float = 512.0 # if the bridge wasn't connected yet when _load_markers first asked. var _city_names_pending_body: String = "" -# ── Pan/zoom state ──────────────────────────────────────────────────────────── +# ── View state (T-1138: FIXED — set only by _fit_to_view()/set_view()) ───── var _view_offset: Vector2 = Vector2.ZERO var _view_zoom: float = 1.0 -var _dragging: bool = false -var _drag_start_mouse: Vector2 -var _drag_start_offset: Vector2 # ── Selection ───────────────────────────────────────────────────────────────── var _selected_city: Dictionary = {} var _hovered_city: Dictionary = {} +var _hover_screen_pos: Vector2 = Vector2.ZERO # T-1138 descent reticle cursor tracking +var _hover_active: bool = false # ── Overlay visibility (#836 plugs in here) ─────────────────────────────────── ## Runtime state derived from OVERLAY_DEFS in _ready() — always-on overlays @@ -366,14 +362,9 @@ func get_view_offset() -> Vector2: return _view_offset -## Programmatic view control (T-1120 — added for the Atlas screenshot capture -## harness, which needs deterministic zoom/pan without simulating mouse wheel -## events). Mirrors _zoom_at()/_fit_to_view()'s clamp-then-_apply_transform -## shape: zoom is clamped to [MIN_ZOOM, MAX_ZOOM] exactly like every other -## mutator (_zoom_at, _fit_to_view), so this can't push the view out of the -## range the rest of the viewer assumes. Offset is caller-supplied verbatim — -## same as _fit_to_view()'s computed centering offset, there's no meaningful -## clamp for a pan translation. +## Programmatic view control (T-1120 capture harness) — survives T-1138's +## removal of user drag-pan/wheel-zoom; the harness still overrides the FIXED +## view deterministically. Clamps zoom to [MIN_ZOOM, MAX_ZOOM] like _fit_to_view(). func set_view(zoom: float, offset: Vector2) -> void: _view_zoom = clampf(zoom, MIN_ZOOM, MAX_ZOOM) _view_offset = offset @@ -630,17 +621,6 @@ func _apply_transform() -> void: _overlay_node.queue_redraw() -func _zoom_at(mouse_pos: Vector2, factor: float) -> void: - var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM) - if is_equal_approx(new_zoom, _view_zoom): - return - # Keep the texture point under cursor fixed while zooming - var local_before: Vector2 = (mouse_pos - _view_offset) / _view_zoom - _view_zoom = new_zoom - _view_offset = mouse_pos - local_before * _view_zoom - _apply_transform() - - ## Convert grid coordinates (from markers.json) to canvas-space (texture pixels). func grid_to_canvas(grid_point: Vector2) -> Vector2: if _grid_w <= 0.0 or _grid_h <= 0.0: @@ -665,6 +645,26 @@ func screen_to_canvas(screen_point: Vector2) -> Vector2: func _draw() -> void: draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) + if _hover_active and _heightmap_texture != null: + _draw_descend_reticle() + + +## T-1138 NOT-TO-SCALE reticle (§5's open design point). Draw calls only — +## geometry/rationale in atlas_descend_geometry.gd (draw_line must run on `self`). +func _draw_descend_reticle() -> void: + var center: Vector2 = _hover_screen_pos + for seg: Array in AtlasDescendGeometry.reticle_segments(center): + draw_line(seg[0], seg[1], AtlasDescendGeometry.COLOR_DESCEND_RETICLE, 1.5) + var label: Dictionary = AtlasDescendGeometry.reticle_label(center) + draw_string( + ThemeDB.fallback_font, + label["position"], + label["text"], + HORIZONTAL_ALIGNMENT_LEFT, + -1, + 10, + AtlasDescendGeometry.COLOR_DESCEND_RETICLE + ) func _build_screen_header() -> void: @@ -686,7 +686,7 @@ func _refresh_screen_header() -> void: var body_name: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")) var sys_name: String = _dict_str(_system, "proper_name", _dict_str(_system, "system_id", "—")) var title: String = "ATLAS — %s · %s" % [body_name.to_upper(), sys_name.to_upper()] - var hint: String = "drag pan · wheel zoom · r reset · click city data · esc back" + var hint: String = "click descend · click city data · esc back" _screen_header.set_content(title, hint) @@ -734,27 +734,13 @@ func _gui_input(event: InputEvent) -> void: if event is InputEventMouseButton: var mb := event as InputEventMouseButton - if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: - _zoom_at(mb.position, ZOOM_STEP) - elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: - _zoom_at(mb.position, 1.0 / ZOOM_STEP) - elif mb.button_index == MOUSE_BUTTON_LEFT: - if mb.pressed: - if not _try_click_city(mb.position): - _dragging = true - _drag_start_mouse = mb.position - _drag_start_offset = _view_offset - else: - _dragging = false + if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: + if not _try_click_city(mb.position): + _descend_at(mb.position) elif event is InputEventMouseMotion: if _is_over_ui((event as InputEventMouseMotion).global_position): return - var mm := event as InputEventMouseMotion - if _dragging: - _view_offset = _drag_start_offset + (mm.position - _drag_start_mouse) - _apply_transform() - else: - _update_hover(mm.position) + _update_hover((event as InputEventMouseMotion).position) func _handle_key(event: InputEventKey) -> void: @@ -770,7 +756,7 @@ func _handle_key(event: InputEventKey) -> void: _fit_to_view() -func _try_click_city(screen_pos: Vector2) -> bool: +func _try_click_city(screen_pos: Vector2) -> bool: # T-1138: a city's hit-radius wins over descent var city: Dictionary = _find_city_at(screen_pos) if city.is_empty(): return false @@ -781,11 +767,17 @@ func _try_click_city(screen_pos: Vector2) -> bool: return true +func _descend_at(screen_pos: Vector2) -> void: # T-1138: every point maps to a DistrictPos + district_descend_requested.emit(_district_pos_at(screen_pos)) + + func _update_hover(screen_pos: Vector2) -> void: var new_hover: Dictionary = _find_city_at(screen_pos) if new_hover != _hovered_city: _hovered_city = new_hover - _overlay_node.queue_redraw() + _hover_screen_pos = screen_pos + _hover_active = true + _overlay_node.queue_redraw() func _find_city_at(screen_pos: Vector2) -> Dictionary: @@ -817,6 +809,12 @@ func city_canvas_pos(city: Dictionary) -> Vector2: return Vector2.ZERO +func _district_pos_at(screen_pos: Vector2) -> Vector2i: # T-1138, geometry: atlas_descend_geometry.gd + var canvas_pt: Vector2 = screen_to_canvas(screen_pos) + var radius_km: float = float(_body.get("body_radius_km", 0.0)) + return AtlasDescendGeometry.district_pos_at(canvas_pt, _tex_w, _tex_h, radius_km) + + # ============================================================================= # City data panel (sidebar) # ============================================================================= @@ -996,3 +994,6 @@ func _notification(what: int) -> void: _position_overlay_bar() if _legend_panel: _legend_panel.reposition() + elif what == NOTIFICATION_MOUSE_EXIT and _hover_active: + _hover_active = false # T-1138: hide the reticle when the cursor leaves + _overlay_node.queue_redraw() diff --git a/client/ui/implant/apps/atlas/atlas_window_cache.gd b/client/ui/implant/apps/atlas/atlas_window_cache.gd new file mode 100644 index 000000000..0d36ec498 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_window_cache.gd @@ -0,0 +1,79 @@ +extends RefCounted + +## Client-side LRU cache for DistrictWindowLayer responses (T-1138, D-226 +## T-1124 amendment §4 "Client cache policy"). +## +## Keyed on (body_id, center, n) — D-227's determinism guarantee (same seed + +## body + position -> same derived output, always) means a previously-fetched +## window is valid FOREVER for that body+seed. This is an LRU-evict-only +## cache: no freshness check, no TTL, no invalidation path at all. The only +## reason an entry ever leaves is capacity pressure. +## +## Godot's Dictionary preserves insertion order, so "move to the end on +## touch, evict from the front on overflow" is the whole LRU implementation — +## no separate linked-list/counter bookkeeping needed. +## +## Consumed via explicit load() by path (no class_name), matching +## atlas_overlay_bar.gd/atlas_legend_panel.gd (review #8 precedent +## elsewhere in this app): the owner constructs one instance and holds it, +## same shape as those two. + +const DEFAULT_MAX_ENTRIES: int = 24 + +var _max_entries: int = DEFAULT_MAX_ENTRIES +var _entries: Dictionary = {} # key String -> DistrictWindowLayer Dictionary + + +func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void: + _max_entries = maxi(1, max_entries) + + +## Build the cache key from the three fields D-227 makes sufficient: +## body_id (which world+body), center (a [row, col] pair or Vector2i), and n +## (window side length). String-keyed rather than a nested Dictionary/Array +## key — Godot Dictionary keys compare by value for primitives but a +## consistent stringification sidesteps any Vector2i-vs-Array identity +## mismatch between what a caller happens to hand in. +static func make_key(body_id: String, center: Vector2i, n: int) -> String: + return "%s:%d,%d:%d" % [body_id, center.x, center.y, n] + + +## True if a window is already cached for this exact (body, center, n). +func has(body_id: String, center: Vector2i, n: int) -> bool: + return _entries.has(make_key(body_id, center, n)) + + +## Fetch a cached window, touching it (move-to-most-recently-used). Returns +## null on a miss — callers must not confuse this with a real "None" server +## response, which is a different concept (§1: an as-yet-underived window is +## carried as `district_window: None` inside a `Ready` AtlasLayerResponse, +## not a cache state). +func get_window(body_id: String, center: Vector2i, n: int) -> Variant: + var key := make_key(body_id, center, n) + if not _entries.has(key): + return null + var value: Variant = _entries[key] + # Touch: erase + re-insert moves the key to the end (most-recently-used). + _entries.erase(key) + _entries[key] = value + return value + + +## Store a window, evicting the least-recently-used entry(ies) if over +## capacity. Overwriting an existing key also counts as a touch. +func put(body_id: String, center: Vector2i, n: int, window: Dictionary) -> void: + var key := make_key(body_id, center, n) + if _entries.has(key): + _entries.erase(key) + _entries[key] = window + while _entries.size() > _max_entries: + var oldest_key: String = _entries.keys()[0] + _entries.erase(oldest_key) + + +func size() -> int: + return _entries.size() + + +func clear() -> void: + _entries.clear() diff --git a/client/ui/implant/apps/atlas/atlas_window_legend.gd b/client/ui/implant/apps/atlas/atlas_window_legend.gd new file mode 100644 index 000000000..d98c426ec --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_window_legend.gd @@ -0,0 +1,134 @@ +extends ImplantPanel + +## Legend for AtlasWindowViewer's regional-window overlays (T-1138, D-226 +## T-1124 amendment §5). Mirrors atlas_legend_panel.gd's data-driven shape — +## one spec entry per overlay id, refresh() shows only the active ones — but +## scoped to the district-window screen's OWN toggle set (gen_dw_temp/ +## gen_dw_moisture/gen_dw_veg) plus the two always-on layers that need a key +## even though they have no toggle id of their own: the morphology base +## (folded to ~5 family rows, per §5's "not everything earns permanent screen +## space" instinct) and the glaciation ice-tint modifier. +## +## No `class_name` on purpose, matching atlas_legend_panel.gd (review #8 +## precedent): the owner (AtlasWindowViewer) passes itself to _init(). + +const PANEL_MARGIN: float = 16.0 +const LEGEND_PANEL_WIDTH: float = 260.0 + +const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") + +## The morphology base layer folds its 17 zones into ~5 family rows (§5: +## "mirroring T-1112's 'not everything earns permanent screen space' +## discipline") — the full 17-zone mapping stays in the city-click sidebar's +## reach, not duplicated here. Representative hue per family, picked from +## MORPHOLOGY_RGB_OPAQUE's own entries rather than a fresh set of colors. +const MORPHOLOGY_FAMILY_ROWS: Array = [ + {"label": "water", "zones": [0, 1]}, # OpenOcean, Lake + {"label": "coastal / transition", "zones": [2, 3, 4, 5, 6, 7]}, # TidalFlat..Estuarine + {"label": "plains / river", "zones": [8, 9, 10, 11, 12]}, # AlluvialPlain..ValleyFloor + {"label": "upland", "zones": [13, 14]}, # MountainPass, Alpine + {"label": "volcanic / wetland", "zones": [15, 16]}, # Volcanic, Wetland +] + +const GLACIATION_ROWS: Array = [ + {"grade": 0, "label": "none"}, + {"grade": 1, "label": "light (erosion signatures — no tint)"}, + {"grade": 2, "label": "moderate"}, + {"grade": 3, "label": "heavy"}, + {"grade": 4, "label": "ice cap"}, +] + +var _viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref) + + +func _init(viewer_ref = null) -> void: + _viewer = viewer_ref + custom_minimum_size.x = LEGEND_PANEL_WIDTH + mouse_filter = Control.MOUSE_FILTER_IGNORE + visible = false + + +func reposition() -> void: + position = Vector2(PANEL_MARGIN, 60.0) + + +## Always shows the base-layer key (morphology + elevation reading, always +## on) plus glaciation (always-on modifier), then whichever toggle overlay is +## currently active, if any. +func refresh() -> void: + if _viewer == null: + return + clear() + visible = true + + add_component(ImplantHeader.new("REGIONAL LEGEND", "district window · 2.048 km/cell")) + add_component(ImplantSeparator.new()) + + _add_morphology_section() + add_component(ImplantSeparator.new()) + _add_glaciation_section() + + var active_id: String = _active_toggle_id() + if not active_id.is_empty(): + add_component(ImplantSeparator.new()) + _add_toggle_section(active_id) + + reposition() + + +func _add_morphology_section() -> void: + add_component( + ImplantTextBlock.new("TERRAIN — hue = type, lightness = elevation (always on)") + ) + for row: Dictionary in MORPHOLOGY_FAMILY_ROWS: + var zones: Array = row.get("zones", []) + var swatch: Color = ( + AtlasOverlayColors.district_window_morphology_color(zones[0]) if not zones.is_empty() else Color.TRANSPARENT + ) + var text: String = "â–¦ %s" % str(row.get("label", "")) + add_component(ImplantDataRow.new(text, swatch)) + + +func _add_glaciation_section() -> void: + add_component(ImplantTextBlock.new("ICE TINT — always-on modifier over any layer")) + for row: Dictionary in GLACIATION_ROWS: + var swatch: Color = AtlasOverlayColors.glaciation_tint( + Color(0.3, 0.3, 0.3, 1.0), int(row.get("grade", 0)) + ) + var text: String = "â–¦ %s" % str(row.get("label", "")) + add_component(ImplantDataRow.new(text, swatch)) + + +func _add_toggle_section(overlay_id: String) -> void: + match overlay_id: + "gen_dw_temp": + add_component(ImplantTextBlock.new("TEMPERATURE — cold->hot ramp (region colorizer, reused)")) + add_component( + ImplantDataRow.new("â–¦ cold", AtlasOverlayColors.COLOR_REGION_TEMP_COLD) + ) + add_component(ImplantDataRow.new("â–¦ hot", AtlasOverlayColors.COLOR_REGION_TEMP_HOT)) + add_component(ImplantDataRow.new("â–¦ airless — no reading (skipped)", Color.TRANSPARENT)) + "gen_dw_moisture": + add_component(ImplantTextBlock.new("MOISTURE — dry->wet ramp")) + add_component(ImplantDataRow.new("â–¦ dry", Color(0.78, 0.62, 0.35, 1.0))) + add_component(ImplantDataRow.new("â–¦ wet", Color(0.25, 0.72, 0.65, 1.0))) + "gen_dw_veg": + add_component(ImplantTextBlock.new("VEGETATION — green-family ramp")) + add_component(ImplantDataRow.new("â–¦ barren", AtlasOverlayColors.COLOR_VEGETATION_BARREN)) + add_component(ImplantDataRow.new("â–¦ scrub", AtlasOverlayColors.COLOR_VEGETATION_SCRUB)) + add_component(ImplantDataRow.new("â–¦ forest", AtlasOverlayColors.COLOR_VEGETATION_FOREST)) + add_component( + ImplantDataRow.new( + "â–¦ riparian band", AtlasOverlayColors.COLOR_VEGETATION_RIPARIAN_THICKET + ) + ) + add_component( + ImplantDataRow.new("â–¦ marine — transparent (shows water below)", Color.TRANSPARENT) + ) + + +func _active_toggle_id() -> String: + for overlay_id in ["gen_dw_temp", "gen_dw_moisture", "gen_dw_veg"]: + if _viewer.is_overlay_visible(overlay_id): + return overlay_id + return "" diff --git a/client/ui/implant/apps/atlas/atlas_window_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_overlay.gd new file mode 100644 index 000000000..70aff7689 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_window_overlay.gd @@ -0,0 +1,158 @@ +class_name AtlasWindowOverlay +extends Node2D + +## Draws the DistrictWindowLayer composite for AtlasWindowViewer (T-1138, +## D-226 T-1124 amendment §5). Child of AtlasWindowViewer._canvas so it +## inherits the pan transform (zoom is client-side texture zoom on the +## already-held composite, §5 — never a re-fetch). +## +## Draw order (bottom to top), matching the amendment's compositing model: +## 1. Base layer — morphology hue, lightness-modulated by elev_q. Always on, +## no toggle id (§5: "it IS this screen's terrain layer"). +## 2. Toggle overlays (mutually independent, at most one drawn per cell — +## each REPLACES the base read for that cell rather than blending, so +## switching between temp/moisture/veg never fights the base hue): +## gen_dw_temp / gen_dw_moisture / gen_dw_veg. +## 3. Glaciation ice-tint MODIFIER — composited over whichever layer is +## showing (base or a toggle), always-on, not a toggle id of its own. +## +## Reads window data via viewer.get_district_window() (a Dictionary or null) — this +## overlay draws nothing until the viewer has a window (border-fade during +## the wait is the VIEWER's job, drawn separately underneath this node, not +## here — this node is purely "draw the composite when there is one"). + +const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") +const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC + +## Moisture ramp reuses SUB_BIOME_COLORS' dry-sand->wet-teal ENDPOINTS (§5) — +## not the categorical lookup itself (that's keyed by sub-biome NAME, not a +## 0-100 quantity). Endpoints pulled from the existing dry/wet entries in that +## table: Desert (arid/dry) and TropicalWet (wet/coastal). +const COLOR_MOISTURE_DRY: Color = Color(0.78, 0.62, 0.35, 1.0) # sand — matches SUB_BIOME_COLORS.Desert +const COLOR_MOISTURE_WET: Color = Color(0.25, 0.72, 0.65, 1.0) # teal — matches SUB_BIOME_COLORS.TropicalWet + +var viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref) + + +func _draw() -> void: + if viewer == null: + return + var window: Variant = viewer.get_district_window() + if not window is Dictionary: + return + var w: Dictionary = window + var n: int = int(w.get("n", 0)) + if n <= 0: + return + + var morphology: Variant = w.get("morphology") + var elev_q: Variant = w.get("elev_q") + if not (morphology is PackedByteArray or morphology is Array): + return + + var cell_px: float = viewer.get_cell_pixel_size() + var active_toggle: String = _active_toggle_overlay() + var glaciation: Variant = w.get("glaciation") + + for row in range(n): + for col in range(n): + var i: int = row * n + col + if i >= morphology.size(): + continue + var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle) + if cell_color.a <= 0.0: + continue # Marine-transparent or otherwise "don't draw" (cheaper than a 0-alpha rect) + cell_color = _apply_glaciation(cell_color, glaciation, i) + # +0.5 overdraw avoids hairline seams between adjacent cells — + # same idiom as _draw_gen_district/_draw_gen_region_grid. + draw_rect(Rect2(col * cell_px, row * cell_px, cell_px + 0.5, cell_px + 0.5), cell_color) + + +## Which of the three mutually-exclusive toggle overlays (if any) is active. +## At most one draws — §5 does not describe blending two toggles together, +## and doing so would fight the "one colorizer, one read" legibility goal the +## whole layer design optimizes for. First-match-wins on ties (should never +## happen — the overlay bar toggles independently, but this keeps the draw +## deterministic instead of implicitly depending on dictionary iteration +## order if more than one somehow ends up true). +func _active_toggle_overlay() -> String: + if viewer.is_overlay_visible("gen_dw_temp"): + return "gen_dw_temp" + if viewer.is_overlay_visible("gen_dw_moisture"): + return "gen_dw_moisture" + if viewer.is_overlay_visible("gen_dw_veg"): + return "gen_dw_veg" + return "" + + +func _cell_color( + w: Dictionary, i: int, morphology_zone: int, elev_q: Variant, active_toggle: String +) -> Color: + match active_toggle: + "gen_dw_temp": + return _temp_cell_color(w.get("temp_dc"), i) + "gen_dw_moisture": + return _moisture_cell_color(w.get("moisture_q"), i) + "gen_dw_veg": + return _veg_cell_color(w.get("vegetation"), i) + _: + return _base_cell_color(morphology_zone, elev_q, i) + + +## Base layer: morphology hue, lightness-modulated by elev_q (§5's one +## `0.7 + 0.3*(elev_q/100)` multiply per cell). +func _base_cell_color(morphology_zone: int, elev_q: Variant, i: int) -> Color: + var base: Color = AtlasOverlayColors.district_window_morphology_color(morphology_zone) + var eq: int = _dense_int(elev_q, i, 50) + return AtlasOverlayColors.district_window_elevation_lightness(base, eq) + + +## gen_dw_temp: reuses T-1118's region_temp_color() EXACTLY — same i16 +## deci-°C domain, same REGION_TEMP_NONE_DC sentinel disposition (skip the +## cell entirely, matching _draw_gen_region_grid's airless treatment) — one +## colorizer across both zoom levels, per the amendment's consistency ruling. +func _temp_cell_color(temp_dc: Variant, i: int) -> Color: + if temp_dc == null: + return Color.TRANSPARENT + var t: int = _dense_int(temp_dc, i, REGION_TEMP_NONE_DC) + if t == REGION_TEMP_NONE_DC: + return Color.TRANSPARENT # airless — no reading, skip the cell (matches region-grid precedent) + return AtlasOverlayColors.region_temp_color(t) + + +## gen_dw_moisture: dry-sand -> wet-teal ramp over the existing SUB_BIOME_COLORS +## endpoints (§5). +func _moisture_cell_color(moisture_q: Variant, i: int) -> Color: + if moisture_q == null: + return Color.TRANSPARENT + var m: int = clampi(_dense_int(moisture_q, i, 50), 0, 100) + return COLOR_MOISTURE_DRY.lerp(COLOR_MOISTURE_WET, float(m) / 100.0) + + +## gen_dw_veg: green-family ramp, Marine transparent (§3/§5 — non-negotiable +## per the amendment; see atlas_overlay_colors.gd's vegetation_color() doc). +func _veg_cell_color(vegetation: Variant, i: int) -> Color: + if vegetation == null: + return Color.TRANSPARENT + return AtlasOverlayColors.vegetation_color(_dense_int(vegetation, i, 0)) + + +## Glaciation: an always-on MODIFIER (never a toggle id), composited over +## whichever layer is currently showing — the base or one of the three +## toggles (§5). +func _apply_glaciation(cell_color: Color, glaciation: Variant, i: int) -> Color: + if glaciation == null: + return cell_color + var grade: int = _dense_int(glaciation, i, 0) + return AtlasOverlayColors.glaciation_tint(cell_color, grade) + + +## Reads element `i` from a dense numeric array field regardless of whether +## the messagepack decode produced a PackedByteArray (u8 fields) or a plain +## Array (i16 temp_dc — rmp_serde without serde_bytes, matching the existing +## region_grid _dense_int precedent in test_atlas_overlays.gd, now needed at +## RUNTIME here too, not just in a test helper). +static func _dense_int(arr: Variant, i: int, fallback: int) -> int: + if (arr is Array or arr is PackedByteArray) and i < arr.size(): + return int(arr[i]) + return fallback diff --git a/client/ui/implant/apps/atlas/atlas_window_request.gd b/client/ui/implant/apps/atlas/atlas_window_request.gd new file mode 100644 index 000000000..c37ef8002 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_window_request.gd @@ -0,0 +1,182 @@ +extends Node + +## District-window request orchestration for AtlasWindowViewer (T-1138, D-226 +## T-1124 amendment §1/§4). Owns the cache, the pan-triggered re-request +## policy, the post-drag-release debounce, and the retry loop for the +## queue-based background derive (PR #185 finding: a window response arrives +## on a LATER TICK, not synchronously — the exact same "None until derived, +## re-poll" contract atlas_generation_proxy.gd's Layer1 path already handles, +## reused here rather than re-invented). +## +## This script has no `class_name` on purpose, matching every other +## viewer-owned helper in this cluster (atlas_overlay_bar.gd/ +## atlas_legend_panel.gd/atlas_generation_proxy.gd, review #8 precedent): the +## owner (AtlasWindowViewer) passes itself to _init(), and a `class_name` + +## required-arg _init() combo is a Godot editor footgun. `extends Node` (not +## RefCounted) because it needs get_tree() for the debounce/retry timers — +## added as a child via +## load("res://ui/implant/apps/atlas/atlas_window_request.gd").new(self). +## +## §4 policy fixed here (the constants + the debounce, NOT the pan-edge +## detection — that's the viewer's job, since it owns the screen-to-district +## geometry): +## - DISTRICT_WINDOW_DEFAULT_N = 32 (client's interactive default, half the +## server's DISTRICT_WINDOW_MAX_N = 64 hard cap — §4 pins both numbers; +## the cap itself is a server-side clamp this client never needs to +## duplicate, only stay under so a request is never silently clamped in +## a way the client didn't expect). +## - 150ms post-drag-release debounce — long enough to collapse a +## flick-and-resettle into one request, short enough that a deliberate +## single pan-and-stop never feels delayed (§4/§5 wording, identical). +## - Cache-hit is instant (no request at all) — §4's "D-227 makes exact- +## repeat the common case for Esc-then-re-enter and pan-back" is what +## makes this the common path, not the minority one. + +signal window_ready(window: Dictionary) # emitted on a cache hit OR a fresh Ready response + +const AtlasWindowCache := preload("res://ui/implant/apps/atlas/atlas_window_cache.gd") + +const DISTRICT_WINDOW_DEFAULT_N: int = 32 +const DEBOUNCE_DELAY: float = 0.15 # 150ms, §4/§5 +const RETRY_DELAY: float = 0.5 # matches atlas_generation_proxy.gd's GEN_RETRY_DELAY +const MAX_RETRIES: int = 20 # ~10s ceiling, matches atlas_generation_proxy.gd's GEN_MAX_RETRIES + +var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref) +var _cache = null # AtlasWindowCache +var _body_id: String = "" +var _center: Vector2i = Vector2i.ZERO +var _n: int = DISTRICT_WINDOW_DEFAULT_N +var _pending: bool = false +var _retries: int = 0 +var _debounce_timer: Timer = null + + +func _init(owner_ref = null) -> void: + _owner = owner_ref + _cache = AtlasWindowCache.new() + + +func _ready() -> void: + _debounce_timer = Timer.new() + _debounce_timer.name = "DebounceTimer" + _debounce_timer.one_shot = true + _debounce_timer.wait_time = DEBOUNCE_DELAY + _debounce_timer.timeout.connect(_on_debounce_timeout) + add_child(_debounce_timer) + + +## Reset for a fresh entry into the regional window mode (new body/center) — +## clears in-flight retry bookkeeping but NOT the cache (D-227: a cached +## window is valid forever regardless of which body/center the viewer is +## currently showing; clearing on every entry would throw away exactly the +## Esc-then-re-enter hit §4 promises). +func reset() -> void: + _pending = false + _retries = 0 + if _debounce_timer: + _debounce_timer.stop() + + +## Entry point + pan re-request: request the window centered on `center` +## (a DistrictPos-equivalent Vector2i) for `body_id`. Cache hit -> immediate +## synchronous window_ready emit, no network traffic at all. Cache miss -> +## fire the request now (the caller — either the initial entry or a +## debounce-fired pan — has already decided this call SHOULD fire; the 150ms +## debounce itself lives in request_debounced() below, not here, so this +## function is also the one entry-mechanic click-through uses directly with +## no debounce at all, matching §5's "first window" contract). +func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEFAULT_N) -> void: + _body_id = body_id + _center = center + _n = n + _debounce_timer.stop() # a direct request supersedes any pending debounced one + + var cached: Variant = _cache.get_window(body_id, center, n) + if cached != null: + _pending = false + _retries = 0 + window_ready.emit(cached) + return + + _pending = true + _retries = 0 + SimBridge.request_atlas_layers(body_id, "Topography", center, n) + + +## Pan-triggered re-request (§4/§5: "150ms after the last drag-release, not +## per-drag-frame"). The viewer calls this on every pan-edge-crossing +## candidate; only the LAST call within the debounce window actually fires +## (Timer.start() on an already-running one-shot timer restarts it — Godot's +## documented behavior — so a flick-and-resettle collapses to one request). +func request_debounced(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEFAULT_N) -> void: + _body_id = body_id + _center = center + _n = n + _debounce_timer.start() + + +func _on_debounce_timeout() -> void: + request_now(_body_id, _center, _n) + + +## Handle an AtlasLayerResponse (routed by the owning viewer from its own +## SimBridge.atlas_layers_received subscription — this object has no signal +## connection of its own, matching atlas_generation_proxy.gd's on_response() +## shape). Ignores responses for a stale body/center/n (the player panned or +## navigated away while a request was in flight) — the echoed center/n IS the +## staleness guard (§2), compared here against what THIS object most recently +## asked for. +func on_response(response: Dictionary) -> void: + if str(response.get("body_id", "")) != _body_id: + return + if str(response.get("status", "")) != "Ready": + return # Pending/NotFound/Error on the WHOLE response — not a window signal either way + var window: Variant = response.get("district_window") + if window == null: + # §1: an as-yet-underived window rides as `district_window: None` inside + # a Ready response — this is the "still generating" signal, not an + # error. Re-poll until the background derive lands or the retry + # ceiling is hit (queue-based serving, PR #185 — the response lands + # on a LATER tick, never this same round-trip). + if not _pending: + return + if _retries < MAX_RETRIES: + _retries += 1 + _schedule_retry() + else: + _pending = false # gave up — caller's border-fade / empty state persists + return + + var w: Dictionary = window + var echoed_center := _vec_from_center(w.get("center", [0, 0])) + var echoed_n := int(w.get("n", 0)) + if echoed_center != _center or echoed_n != _n: + return # stale — answers a window we've since panned away from (§2) + + _pending = false + _retries = 0 + _cache.put(_body_id, _center, _n, w) + window_ready.emit(w) + + +func _schedule_retry() -> void: + var timer := get_tree().create_timer(RETRY_DELAY) + timer.timeout.connect( + func() -> void: + if _pending: + SimBridge.request_atlas_layers(_body_id, "Topography", _center, _n) + ) + + +func is_pending() -> bool: + return _pending + + +func get_cache() -> Variant: + return _cache + + +static func _vec_from_center(center: Variant) -> Vector2i: + if center is Array and center.size() >= 2: + return Vector2i(int(center[0]), int(center[1])) + return Vector2i.ZERO diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd new file mode 100644 index 000000000..a6f0bdf65 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_window_viewer.gd @@ -0,0 +1,497 @@ +class_name AtlasWindowViewer +extends Control + +## Regional district-resolution window viewer (T-1138, D-226 T-1124 +## amendment). Entered via a click-through from the planetary AtlasViewer +## (the 2026-07-21 §5 entry revision — NOT a zoom-threshold LOD swap). +## Renders a DistrictWindowLayer composite: morphology base layer lightness- +## modulated by elev_q, three switchable climate/vegetation overlays, and an +## always-on glaciation ice-tint modifier (drawing itself is +## AtlasWindowOverlay's job — this Control owns input, request orchestration, +## chrome, and the pan/zoom transform). +## +## Design notes (mirroring AtlasViewer's own split, D-226 §5): +## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom +## = _canvas.scale — the SAME transform idiom as the planetary viewer. +## - Zoom is ALWAYS client-side on the already-held composite (§5: "the +## composite is a texture the client zooms client-side... from already- +## held data") — it NEVER triggers a re-request. Only a pan past the held +## window's edge does (§4/§5). +## - _window_request (atlas_window_request.gd) owns the cache/debounce/ +## retry — this Control decides WHEN to call it (pan-edge detection, +## entry), never talks to SimBridge directly itself. +## +## Navigation: +## Mouse drag pan within/across the window +## Mouse wheel zoom the held composite (client-side only, never refetches) +## Esc back to the planetary view + +signal back_pressed + +const PANEL_MARGIN: float = 16.0 +const OVERLAY_BAR_HEADER_RESERVE: float = 360.0 + +const MIN_ZOOM: float = 0.5 +const MAX_ZOOM: float = 8.0 +const ZOOM_STEP: float = 1.15 + +## Pixel size of one district cell at zoom=1.0 — a fixed on-screen scale +## (unlike AtlasViewer's heightmap, there is no source texture dictating a +## native pixel size; this constant IS the native size). 16px/cell at n=64 +## gives a ~1024px-wide composite before zoom, comfortably inside a +## 1280x720+ viewport at the DEFAULT_N=32 interactive default (512px) too. +const CELL_PIXEL_SIZE: float = 16.0 + +const COLOR_BG: Color = Color("#0d1117") +## Border-fade target (§5 "what renders during the wait"): the underlying +## whole-body heightmap's own background tint, so the newly-exposed edge +## reads as "real data seen through", not a placeholder block. Reuses +## AtlasViewer's own COLOR_HEIGHTMAP_TINT-adjacent dim value rather than +## inventing a new one — this IS a dimmer/less-certain read of the same +## planetary data, not a different visual language. +const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55) + +## D-243: 2,048 m per district side. +const DISTRICT_M: float = 2048.0 + +const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") + +# ── Overlay definitions (T-1138 — reuses atlas_overlay_bar.gd/ +# atlas_legend_panel.gd's existing duck-typed viewer interface: both call +# only get_overlay_defs()/is_overlay_visible()/set_overlay_visible(), so +# this Control is a drop-in "viewer" for either component without +# subclassing AtlasViewer). Glaciation is deliberately NOT here — it is an +# always-on modifier per §5, not a toggle id. +const OVERLAY_DEFS: Array = [ + { + "id": "gen_dw_temp", + "label": "TMP", + "group": "toggle", + "tooltip": "Temperature — region-ramp colorizer, reused from the planetary climate overlay." + }, + { + "id": "gen_dw_moisture", + "label": "MST", + "group": "toggle", + "tooltip": "Moisture — dry-to-wet ramp." + }, + { + "id": "gen_dw_veg", + "label": "VEG", + "group": "toggle", + "tooltip": "Vegetation — green-family ramp. Marine reads transparent (open water)." + }, +] + +# ── Context (set by enter()) ────────────────────────────────────────────── +var _body: Dictionary = {} +var _system: Dictionary = {} +var _implant_theme = null + +# ── Held window + geometry ──────────────────────────────────────────────── +var _window: Variant = null # current DistrictWindowLayer Dictionary, or null while waiting +var _held_center: Vector2i = Vector2i.ZERO +var _held_n: int = 32 + +# ── Pan/zoom state (mirrors AtlasViewer's own fields exactly) ──────────── +var _view_offset: Vector2 = Vector2.ZERO +var _view_zoom: float = 1.0 +var _dragging: bool = false +var _drag_start_mouse: Vector2 +var _drag_start_offset: Vector2 + +# ── Overlay visibility ───────────────────────────────────────────────────── +var _overlay_visibility: Dictionary = {} + +# ── Child nodes ──────────────────────────────────────────────────────────── +var _canvas: Node2D = null +var _overlay_node: AtlasWindowOverlay = null +var _screen_header: ImplantHeader = null +var _overlay_bar = null +var _legend_panel = null +var _window_request = null # AtlasWindowRequest + + +func _ready() -> void: + anchor_right = 1.0 + anchor_bottom = 1.0 + grow_horizontal = Control.GROW_DIRECTION_BOTH + grow_vertical = Control.GROW_DIRECTION_BOTH + mouse_filter = Control.MOUSE_FILTER_STOP + focus_mode = Control.FOCUS_ALL + + _implant_theme = load("res://ui/implant/default_implant.tres") + + for def: Dictionary in OVERLAY_DEFS: + _overlay_visibility[def["id"]] = false + + _canvas = Node2D.new() + _canvas.name = "WindowCanvas" + add_child(_canvas) + + _overlay_node = AtlasWindowOverlay.new() + _overlay_node.name = "WindowOverlay" + _overlay_node.viewer = self + _canvas.add_child(_overlay_node) + + _window_request = AtlasWindowRequest.new(self) + _window_request.name = "WindowRequest" + add_child(_window_request) + _window_request.window_ready.connect(_on_window_ready) + + _build_screen_header() + _build_overlay_bar() + _build_legend_panel() + + SimBridge.atlas_layers_received.connect(_on_atlas_layers_received) + + +func _exit_tree() -> void: + if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received): + SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received) + + +## Enter the window screen centered on `district_center` (a DistrictPos- +## equivalent Vector2i, from the planetary click-through's derived position — +## §5's "pan center read as click point"). n defaults to the client's +## interactive default (32), half the server's hard cap. +func enter( + body: Dictionary, + system: Dictionary, + district_center: Vector2i, + n: int = AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N +) -> void: + _body = body + _system = system + _held_center = district_center + _held_n = n + _window = null + _view_zoom = 1.0 + _view_offset = Vector2.ZERO + _apply_transform() + _window_request.reset() + _window_request.request_now(_dict_str(_body, "body_id", ""), district_center, n) + _refresh_screen_header() + grab_focus() + queue_redraw() + _overlay_node.queue_redraw() + + +func leave() -> void: + pass + + +## Named get_district_window(), NOT get_window() — Node already defines +## get_window() -> Window (the containing OS window); shadowing it with an +## incompatible return type is a Godot parse error (confirmed the hard way). +func get_district_window() -> Variant: + return _window + + +## District-cell pixel size at zoom=1.0 — AtlasWindowOverlay reads this +## rather than hardcoding CELL_PIXEL_SIZE itself, so the viewer stays the +## single source of geometry truth (same "viewer owns the transform, overlay +## only draws" split as AtlasViewer/AtlasMarkerOverlay). +func get_cell_pixel_size() -> float: + return CELL_PIXEL_SIZE + + +func is_overlay_visible(overlay_id: String) -> bool: + return bool(_overlay_visibility.get(overlay_id, false)) + + +func set_overlay_visible(overlay_id: String, visible_state: bool) -> void: + if not _overlay_visibility.has(overlay_id): + push_warning("AtlasWindowViewer: unknown overlay id '%s'" % overlay_id) + return + _overlay_visibility[overlay_id] = visible_state + _overlay_node.queue_redraw() + _legend_panel.refresh() + + +func get_overlay_defs() -> Array: + return OVERLAY_DEFS + + +# ============================================================================= +# Window response routing +# ============================================================================= + + +func _on_atlas_layers_received(response: Dictionary) -> void: + _window_request.on_response(response) + + +func _on_window_ready(window: Dictionary) -> void: + # Only adopt the window if it still matches what THIS viewer is currently + # showing — AtlasWindowRequest already filtered by its own last-asked + # (center, n) via the echo (§2), but a cache-hit path can fire + # synchronously from enter() before _held_center is what the signal + # handler expects in a re-entrant call; comparing again here is cheap and + # removes any ordering assumption between enter()'s two calls. + var w_center := _vec_from_center(window.get("center", [0, 0])) + var w_n := int(window.get("n", 0)) + if w_center != _held_center or w_n != _held_n: + return + _window = window + _refresh_screen_header() + queue_redraw() + _overlay_node.queue_redraw() + + +# ============================================================================= +# View transform (mirrors AtlasViewer's own — pan is real, zoom is client-side +# only and NEVER triggers a re-request per §5) +# ============================================================================= + + +func _apply_transform() -> void: + _canvas.position = _view_offset + _canvas.scale = Vector2(_view_zoom, _view_zoom) + queue_redraw() + _overlay_node.queue_redraw() + + +func _zoom_at(mouse_pos: Vector2, factor: float) -> void: + var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM) + if is_equal_approx(new_zoom, _view_zoom): + return + var local_before: Vector2 = (mouse_pos - _view_offset) / _view_zoom + _view_zoom = new_zoom + _view_offset = mouse_pos - local_before * _view_zoom + _apply_transform() + + +## Programmatic view control (T-1120 capture-API parity — must survive on +## every viewer this app exposes, per the ticket's explicit note, even one +## that never got user pan/zoom to begin with on the OTHER seam this ticket +## removes it from). +func get_view_zoom() -> float: + return _view_zoom + + +func get_view_offset() -> Vector2: + return _view_offset + + +func set_view(zoom: float, offset: Vector2) -> void: + _view_zoom = clampf(zoom, MIN_ZOOM, MAX_ZOOM) + _view_offset = offset + _apply_transform() + + +# ============================================================================= +# Pan-edge re-request (§4/§5): re-request ONLY when the view pans past the +# held window's edge; zoom never refetches. +# ============================================================================= + + +## After a drag delta, check whether the screen-center now maps to a +## DistrictPos outside the held window's extent — if so, float a NEW window +## centered on that point (§5 "windows float on the pan center... not +## grid-snapped") via the debounced request path. +func _maybe_refloat_window() -> void: + if _held_n <= 0: + return + var screen_center: Vector2 = size * 0.5 + var canvas_pt: Vector2 = (screen_center - _view_offset) / _view_zoom + var cell: Vector2 = canvas_pt / CELL_PIXEL_SIZE + # cell is in [0, _held_n) local window space when centered — half-window + # offset from _held_center converts back to absolute district space. + var half: float = float(_held_n) / 2.0 + var abs_col: float = float(_held_center.x) - half + cell.x + var abs_row: float = float(_held_center.y) - half + cell.y + var new_center := Vector2i(roundi(abs_col), roundi(abs_row)) + if new_center == _held_center: + return + # Edge-crossing check: only re-request if the screen-center point has + # actually left the CURRENTLY HELD window's extent — a pan that stays + # inside the window (even if the nominal "nearest DistrictPos to center" + # ticked over by one cell near a boundary) must not spam a request every + # frame. §4: "re-requests only when a pan carries the view past the held + # window's edge." + var local_col: float = abs_col - float(_held_center.x) + half + var local_row: float = abs_row - float(_held_center.y) + half + var inside: bool = ( + local_col >= 0.0 + and local_col < float(_held_n) + and local_row >= 0.0 + and local_row < float(_held_n) + ) + if inside: + return + _held_center = new_center + _window_request.request_debounced(_dict_str(_body, "body_id", ""), new_center, _held_n) + + +# ============================================================================= +# Drawing (background + border-fade + header) +# ============================================================================= + + +func _draw() -> void: + draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) + if _window == null: + # §5 "what renders during the wait": a border-fade to the underlying + # whole-body context rather than black/a spinner. This viewer has no + # resident whole-body texture of its own (that lives on AtlasViewer, + # which this screen has navigated away from) — the honest available + # substitute is a dim fade wash over the held composite's last-known + # extent, reusing _gen_pending_indicator (via the request object's own + # is_pending()) for the "still working" cue rather than new dressing. + _draw_border_fade() + + +func _draw_border_fade() -> void: + if not _window_request or not _window_request.is_pending(): + return + var extent: float = float(_held_n) * CELL_PIXEL_SIZE * _view_zoom + var top_left: Vector2 = _view_offset + draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_BORDER_FADE) + + +func _build_screen_header() -> void: + _screen_header = ImplantHeader.new() + _screen_header.position = Vector2(PANEL_MARGIN, 16.0) + _screen_header.custom_minimum_size.x = 320.0 + _screen_header.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_screen_header) + if _implant_theme: + _screen_header.apply_implant_theme(_implant_theme) + + +## D-169/D-170 implant chrome (§5): location label (nearest settlement when +## the window is over/near one, else a coordinate/region label — the window +## is NOT settlement-anchored) + extent-in-real-units subtitle, e.g. +## "4.1 x 4.1 km . 2.0 km/cell". +func _refresh_screen_header() -> void: + if _screen_header == null: + return + var location_label: String = _location_label() + var extent_km: float = float(_held_n) * DISTRICT_M / 1000.0 + var extent_line: String = "%.1f x %.1f km · %.1f km/cell" % [ + extent_km, extent_km, DISTRICT_M / 1000.0 + ] + var title: String = "REGIONAL — %s" % location_label.to_upper() + _screen_header.set_content(title, extent_line) + + +## Coordinate/region label — no settlement join exists at this layer yet +## (the district window carries no settlement data of its own; that lives on +## the planetary gen_l3_settlements overlay, a different screen). This is +## deliberately the coordinate fallback branch always, until a future ticket +## wires a settlement-proximity join — recorded as an open follow-up, not +## silently guessed at. +func _location_label() -> String: + return "district (%d, %d)" % [_held_center.x, _held_center.y] + + +# ============================================================================= +# Input +# ============================================================================= + + +## No city panel / sidebar in this mode (yet) — the window carries no +## settlement join of its own (see _location_label's doc), so there is +## nothing to hit-test against and this always reads false. Wired into +## _gui_input exactly where AtlasViewer's own _is_over_ui is (same guard +## shape) so a future sidebar addition only needs to change THIS function's +## body, not every call site. +func _is_over_ui(_pos: Vector2) -> bool: + return false + + +func _gui_input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and not event.is_echo(): + _handle_key(event as InputEventKey) + return + + if ( + event is InputEventMouseButton + and _is_over_ui((event as InputEventMouseButton).global_position) + ): + return + + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: + _zoom_at(mb.position, ZOOM_STEP) + elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: + _zoom_at(mb.position, 1.0 / ZOOM_STEP) + elif mb.button_index == MOUSE_BUTTON_LEFT: + if mb.pressed: + _dragging = true + _drag_start_mouse = mb.position + _drag_start_offset = _view_offset + else: + _dragging = false + elif event is InputEventMouseMotion: + var mm := event as InputEventMouseMotion + if _dragging: + _view_offset = _drag_start_offset + (mm.position - _drag_start_mouse) + _apply_transform() + _maybe_refloat_window() + + +func _handle_key(event: InputEventKey) -> void: + if event.keycode == KEY_ESCAPE: + back_pressed.emit() + + +# ============================================================================= +# Overlay bar / legend (reuses atlas_overlay_bar.gd/atlas_legend_panel.gd — +# both call only get_overlay_defs()/is_overlay_visible()/set_overlay_visible(), +# so this Control is a drop-in viewer for either component) +# ============================================================================= + + +func _build_overlay_bar() -> void: + var BarScript := load("res://ui/implant/apps/atlas/atlas_overlay_bar.gd") + _overlay_bar = BarScript.new(self) + _overlay_bar.name = "WindowOverlayBar" + add_child(_overlay_bar) + _position_overlay_bar() + + +func _position_overlay_bar() -> void: + var sz: Vector2 = get_rect().size + if sz == Vector2.ZERO: + sz = Vector2(1280.0, 720.0) + var avail_w: float = maxf(sz.x - OVERLAY_BAR_HEADER_RESERVE - PANEL_MARGIN * 2.0, 200.0) + _overlay_bar.position = Vector2(sz.x - avail_w - PANEL_MARGIN, PANEL_MARGIN) + _overlay_bar.size = Vector2(avail_w, 0.0) + + +func _build_legend_panel() -> void: + var LegendScript := load("res://ui/implant/apps/atlas/atlas_window_legend.gd") + _legend_panel = LegendScript.new(self) + _legend_panel.name = "WindowLegend" + _legend_panel.theme_resource = _implant_theme + add_child(_legend_panel) + _legend_panel.refresh() + + +func _notification(what: int) -> void: + if what == NOTIFICATION_RESIZED: + if _overlay_bar: + _position_overlay_bar() + if _legend_panel: + _legend_panel.reposition() + + +## Safely extract a string field from a dict, falling back when missing or +## null (matches atlas_viewer.gd's own _dict_str verbatim). +static func _dict_str(d: Dictionary, key: String, fallback: String) -> String: + var v: Variant = d.get(key) + if v == null: + return fallback + var s: String = str(v) + if s.is_empty(): + return fallback + return s + + +static func _vec_from_center(center: Variant) -> Vector2i: + if center is Array and center.size() >= 2: + return Vector2i(int(center[0]), int(center[1])) + return Vector2i.ZERO diff --git a/client/ui/implant/apps/atlas/screens/district_screen.gd b/client/ui/implant/apps/atlas/screens/district_screen.gd new file mode 100644 index 000000000..1b009ef06 --- /dev/null +++ b/client/ui/implant/apps/atlas/screens/district_screen.gd @@ -0,0 +1,40 @@ +class_name DistrictScreen +extends Control +## Regional district-window viewer screen for AtlasApp (T-1138, D-226 T-1124 +## amendment). Thin wrapper around AtlasWindowViewer, mirroring +## RegionalScreen's own shape exactly — enter/leave are the nav interface. +## +## Entered via a click-through from AtlasViewer (the "regional" screen), +## carrying the derived DistrictPos the player clicked (§5's entry-revision: +## "pan center read as click point"). Esc goes back to "regional" (the +## planetary heightmap for the same body) — a nav.pop(), not a fresh push, so +## the planetary view's own pan/zoom-removed FIXED state is exactly where the +## player left it. + +signal back_requested + +var _viewer: AtlasWindowViewer = null + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _viewer = AtlasWindowViewer.new() + _viewer.name = "AtlasWindowViewer" + add_child(_viewer) + _viewer.back_pressed.connect(_on_viewer_back) + + +func enter(payload: Dictionary) -> void: + var body: Dictionary = payload.get("body", {}) + var system: Dictionary = payload.get("system", {}) + var center: Vector2i = payload.get("district_center", Vector2i.ZERO) + _viewer.enter(body, system, center) + + +func leave() -> void: + pass + + +func _on_viewer_back() -> void: + back_requested.emit() diff --git a/client/ui/implant/apps/atlas/screens/regional_screen.gd b/client/ui/implant/apps/atlas/screens/regional_screen.gd index b0d6bdd49..b32a54848 100644 --- a/client/ui/implant/apps/atlas/screens/regional_screen.gd +++ b/client/ui/implant/apps/atlas/screens/regional_screen.gd @@ -5,6 +5,7 @@ extends Control signal back_requested signal economics_link_requested(system_id: String) +signal district_descend_requested(district_center: Vector2i) # T-1138, forwarded from AtlasViewer var _viewer: AtlasViewer = null @@ -17,6 +18,7 @@ func _ready() -> void: add_child(_viewer) _viewer.back_pressed.connect(_on_viewer_back) _viewer.economics_link_requested.connect(_on_viewer_economics_link) + _viewer.district_descend_requested.connect(_on_viewer_district_descend) func enter(payload: Dictionary) -> void: @@ -35,3 +37,7 @@ func _on_viewer_back() -> void: func _on_viewer_economics_link(system_id: String) -> void: economics_link_requested.emit(system_id) + + +func _on_viewer_district_descend(district_center: Vector2i) -> void: + district_descend_requested.emit(district_center) -- 2.54.0 From df33e9f2fb2bfd895854c9f58c28d6f4700848ba Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Jul 2026 13:56:58 +0200 Subject: [PATCH 3/5] fix(simulation): per-body TerrainAnalysis LRU + full-path determinism test (PR #187 C1/C3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 (Tyre): the DeriveWindow branch re-ran the ~45ms run_layer1 for EVERY uncached window — the dominant pan-around-one-body path paid ~52ms per new window while the doc claimed one-time-per-body. TerrainAnalysisCache: private per-body LRU (capacity 8, ~2MB/entry, true access-recency eviction — which body the player keeps panning IS a recency signal) held as Arc> on GenerationQueue itself, the in_flight field pattern, because run_work_item executes on a Rayon worker where the main-thread caches are unreachable. DeriveWindow now calls get_or_derive; the variant doc states the real model (first window per body pays ~45ms once; subsequent windows any center/n hit the LRU; eviction re-pays). End-to-end test proves two windows on one body via two connections share exactly one cache entry. C3 (Tyre): the determinism test reused one TerrainAnalysis — proving the packer, not the derivation. New test runs run_layer1 twice independently, asserts field-by-field analysis agreement, then byte-identical packed windows end to end (D-227 save-critical proof). Packer-only test kept alongside. atlas:: 564/564; full lib 1778/1778; timing-sensitive suites 3x stable. Co-Authored-By: Claude Fable 5 --- server/src/atlas/gen_queue.rs | 335 +++++++++++++++++++++++++++++--- server/src/atlas/layer_proxy.rs | 55 ++++++ 2 files changed, 366 insertions(+), 24 deletions(-) diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 6b84008c8..79b88fefe 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -32,6 +32,7 @@ use crate::atlas::attractor_matching::CityRecord; use crate::atlas::body_world_state::BodyWorldState; use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer}; use crate::atlas::district_profile::{BodyParams, ClimateConstants, DistrictPos}; +use crate::atlas::features::TerrainAnalysis; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; use crate::atlas::layer_proxy::{build_district_window_layer, DistrictWindowLayer}; use crate::atlas::shell::{fill_chunk, FilledChunk}; @@ -159,23 +160,32 @@ pub enum GenWorkItem { /// ~7–29 ms, which would blow the "cheap channel drain" contract /// `drain_generation_completions` documents). /// - /// **TerrainAnalysis availability (T-1137 binding decision, with numbers):** - /// `BodyWorldState` does NOT retain `TerrainAnalysis` after cascade - /// completion (T-1044 scoped its transient-carry fix to *within-cascade* - /// reuse only — `cascade.rs` drops it once `DistrictProfile`+`RoadGraph` - /// finish; see the doc on `CascadeSnapshot::terrain_analysis`). Caching it - /// alongside every `BodyWorldStateCache` entry would cost ~2 MB × 50-body - /// capacity ≈ 100 MB of PERMANENT resident cost, paid by every cached body - /// whether or not a window is ever requested for it — the exact D-203 - /// budget concern T-1044's own ticket text guarded against. Re-deriving via - /// `run_layer1` per window-serving cache miss costs ~45 ms ONE-TIME, paid - /// only when a window is actually requested, and — because this work item - /// already runs off the tick thread on the Rayon queue — that cost is - /// invisible to the main thread; it is the same order of magnitude as one - /// window derive itself, not a multiplier on it. So: re-derive - /// (`heightmap_path`/`sea_level` below), matching `aliveness_probe`'s - /// existing `--render` workaround, NOT a `TerrainAnalysis` field cached on - /// `BodyWorldState`. + /// **TerrainAnalysis availability (T-1137 binding decision, with numbers; + /// corrected 2026-07-21 per PR #187 review — Tyre C1):** `BodyWorldState` + /// does NOT retain `TerrainAnalysis` after cascade completion (T-1044 + /// scoped its transient-carry fix to *within-cascade* reuse only — + /// `cascade.rs` drops it once `DistrictProfile`+`RoadGraph` finish; see the + /// doc on `CascadeSnapshot::terrain_analysis`). Caching it alongside every + /// `BodyWorldStateCache` entry would cost ~2 MB × 50-body capacity ≈ + /// 100 MB of PERMANENT resident cost, paid by every cached body whether or + /// not a window is ever requested for it — the exact D-203 budget concern + /// T-1044's own ticket text guarded against. So `run_work_item` re-derives + /// via `run_layer1` (matching `aliveness_probe`'s existing `--render` + /// workaround) rather than persisting a field on `BodyWorldState` — but + /// NOT unconditionally on every work item: the actual model is a small + /// **per-body LRU** (`TerrainAnalysisCache`, capacity 8, ~16 MB worst + /// case), consulted before every re-derive. The FIRST `DeriveWindow` on a + /// body pays the full ~45 ms `run_layer1` cost and populates that body's + /// cache entry; EVERY SUBSEQUENT window on the SAME body (any `center`/`n`, + /// not just an exact repeat — that narrower case is what + /// `DistrictWindowCache` in `layer_proxy.rs` already catches) hits the LRU + /// and skips straight to the ~7–29 ms per-window pack in + /// `build_district_window_layer`. An LRU eviction re-pays the ~45 ms on the + /// next window for that body. Because every `DeriveWindow` work item still + /// runs off the tick thread on the Rayon queue regardless of hit or miss, + /// both costs are invisible to the main thread either way — the LRU's + /// value is throughput/worker-occupancy (bounding how many ~45 ms re-derives + /// a pan-burst across one body can force), not tick-thread latency. DeriveWindow { body_id: String, /// Coalescing/routing key (D-226 T-1124 amendment §1 "recommended" @@ -284,6 +294,14 @@ struct QueuedWork { /// Submit work with `submit()`. Drain completions with `drain_completions()` /// once per tick. The Rayon thread pool runs tasks in priority order. /// +/// Also owns the queue-scoped `TerrainAnalysisCache` (T-1137, PR #187 review +/// C1) — a small per-body LRU consulted by every `DeriveWindow` work item +/// before paying the `run_layer1` re-derive cost. Lives here (not as a +/// separate `Resource`) because it must be reachable from `run_work_item` +/// while it executes on a Rayon worker thread, the same reason +/// `in_flight`/`in_flight_count` are `Arc>` fields on this struct +/// rather than plain fields. +/// /// Priority is respected because `dispatch_next()` is gated on pool saturation /// via `in_flight_count`: it only dispatches when fewer than `n_threads` tasks /// are running. This applies to all work item types — `in_flight` (body-id set) @@ -305,6 +323,9 @@ pub struct GenerationQueue { /// Thread count — caps concurrent dispatches so pending items accumulate /// and priority ordering is consulted before the pool has free threads. n_threads: usize, + /// Per-body `TerrainAnalysis` LRU shared by every `DeriveWindow` work item + /// on this queue (T-1137, PR #187 review C1) — see [`TerrainAnalysisCache`]. + terrain_cache: Arc>, } impl std::fmt::Debug for GenerationQueue { @@ -344,6 +365,9 @@ impl GenerationQueue { in_flight: Arc::new(Mutex::new(std::collections::BTreeSet::new())), in_flight_count: Arc::new(Mutex::new(0)), n_threads, + terrain_cache: Arc::new(Mutex::new(TerrainAnalysisCache::new( + TERRAIN_ANALYSIS_CACHE_CAPACITY, + ))), } } @@ -468,9 +492,10 @@ impl GenerationQueue { let tx = self.completion_tx.clone(); let in_flight = Arc::clone(&self.in_flight); let in_flight_count = Arc::clone(&self.in_flight_count); + let terrain_cache = Arc::clone(&self.terrain_cache); self.pool.spawn(move || { - let completion = run_work_item(&item); + let completion = run_work_item(&item, &terrain_cache); // Un-mark body dedup set (AnalyzeBody only). if let Some(body_id) = item.body_id() { @@ -490,6 +515,108 @@ impl Default for GenerationQueue { } } +// --------------------------------------------------------------------------- +// TerrainAnalysisCache (T-1137, PR #187 review — Tyre C1) +// --------------------------------------------------------------------------- + +/// Small per-body LRU of re-derived [`TerrainAnalysis`] (~1.5–2 MB/entry), +/// consulted by the `DeriveWindow` execution path before paying the ~45 ms +/// `run_layer1` re-derive cost (T-1137 binding decision — see the +/// `DeriveWindow` variant doc on why `TerrainAnalysis` is re-derived rather +/// than cached on `BodyWorldState` at all). +/// +/// **Why this exists (PR #187 review finding, binding):** the original +/// T-1137 landing called `run_layer1` unconditionally on every `DeriveWindow` +/// work item — nothing memoized it within a body, so the *dominant* usage +/// pattern (panning around ONE body, many windows) paid the ~45 ms re-derive +/// on every single window instead of just the first. This cache closes that +/// gap: first window on a body pays the full re-derive and populates the +/// entry; every subsequent window on the SAME body (until eviction) hits the +/// cache and skips straight to the ~7–29 ms `build_district_window_layer` +/// pack (§4's actual per-window number). +/// +/// **Shape:** `Arc>` — lives on [`GenerationQueue`] alongside +/// `in_flight`/`in_flight_count` (the same "shared state cloned into every +/// Rayon closure" pattern), because `run_work_item` executes ON a Rayon +/// worker thread, potentially concurrently with other workers when +/// `n_threads > 1`; a queue-owned, plain (non-`Resource`) cache is the +/// correct home — `handle_atlas_request`'s `DistrictWindowCache` is main- +/// thread-only (D-225 poll loop) and cannot be reused here. +/// +/// **Capacity (8, ~16 MB worst case):** deliberately small relative to +/// `BodyWorldStateCache::CACHE_CAPACITY` (50) — this caches a re-derive +/// shortcut for bodies actually being window-browsed RIGHT NOW, not a +/// body-indexed store meant to grow with session length. True LRU +/// (access-recency, mirroring `BodyWorldStateCache`'s own eviction policy) +/// rather than `DistrictWindowCache`'s FIFO-by-insertion: unlike a +/// D-227-pure derived window (valid forever, no recency signal to track), +/// which body a player keeps panning around IS a recency signal, so +/// access-order eviction is the right fit here. +#[derive(Debug)] +struct TerrainAnalysisCache { + entries: std::collections::BTreeMap, + /// Monotonic access counter (substitutes for `BodyWorldStateCache`'s + /// `SimTick` — there is no tick concept on a background Rayon thread). + clock: u64, + capacity: usize, +} + +/// Default capacity for [`TerrainAnalysisCache`] (PR #187 review — Tyre C1 +/// binding numbers: "capacity ~8, ~2MB/entry = ~16MB worst case"). +const TERRAIN_ANALYSIS_CACHE_CAPACITY: usize = 8; + +impl TerrainAnalysisCache { + fn new(capacity: usize) -> Self { + Self { + entries: std::collections::BTreeMap::new(), + clock: 0, + capacity, + } + } + + /// Look up a cached `TerrainAnalysis` for `body_id`, re-deriving via + /// `run_layer1` on a miss and inserting the result (evicting the LRU + /// entry first if at capacity). Bumps the access clock on both a hit and + /// a fresh insert (both are "this body was just used"). + fn get_or_derive( + &mut self, + body_id: &str, + heightmap: &crate::atlas::heightmap::BodyHeightmap, + ) -> TerrainAnalysis { + self.clock += 1; + let now = self.clock; + if let Some((ta, last_used)) = self.entries.get_mut(body_id) { + *last_used = now; + return ta.clone(); + } + + let (_, ta) = crate::atlas::layer1::run_layer1(heightmap); + + if self.entries.len() >= self.capacity && !self.entries.contains_key(body_id) { + if let Some(victim) = self + .entries + .iter() + .min_by_key(|(_, (_, last_used))| *last_used) + .map(|(id, _)| id.clone()) + { + self.entries.remove(&victim); + } + } + self.entries.insert(body_id.to_string(), (ta.clone(), now)); + ta + } + + #[cfg(test)] + fn len(&self) -> usize { + self.entries.len() + } + + #[cfg(test)] + fn contains(&self, body_id: &str) -> bool { + self.entries.contains_key(body_id) + } +} + // --------------------------------------------------------------------------- // Work execution stub // --------------------------------------------------------------------------- @@ -500,7 +627,15 @@ impl Default for GenerationQueue { /// runs the real plan phase (#957, D-229) producing the skeleton + block tags; /// `FillChunk` runs the real derive phase (T-987, D-230) producing the building /// shell from the pre-resolved tags. -fn run_work_item(item: &GenWorkItem) -> GenCompletion { +/// +/// `terrain_cache` serves `DeriveWindow`'s `TerrainAnalysis` re-derive +/// shortcut (T-1137, PR #187 review C1) — unused by every other variant +/// (they don't touch `TerrainAnalysis` at all, or — `AnalyzeBody` — derive it +/// once already as part of the normal in-cascade path, T-1044). +fn run_work_item( + item: &GenWorkItem, + terrain_cache: &Arc>, +) -> GenCompletion { match item { GenWorkItem::AnalyzeBody { body_id, @@ -624,11 +759,18 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { } else { hm }; - // Re-derive TerrainAnalysis via run_layer1 (T-1137 binding - // decision — see the DeriveWindow variant doc for the numbers). - // This is the SAME workaround aliveness_probe --render already - // uses when CascadeSnapshot.terrain_analysis is None. - let (_, ta) = crate::atlas::layer1::run_layer1(&working); + // TerrainAnalysis via the per-body LRU (T-1137 binding decision + + // PR #187 review C1): first window on a body pays the ~45 ms + // run_layer1 re-derive and populates the cache entry; every + // subsequent window on the SAME body (until eviction) hits the + // cache and skips straight to the ~7–29 ms per-window pack below. + // This is the memoized form of the SAME workaround + // aliveness_probe --render uses when CascadeSnapshot.terrain_analysis + // is None (it has no cache — a one-shot CLI run doesn't need one). + let ta = terrain_cache + .lock() + .unwrap() + .get_or_derive(body_id, &working); let climate = ClimateConstants::default(); let layer = build_district_window_layer( *body_seed, @@ -1113,4 +1255,149 @@ mod tests { "different connections requesting the same body must NOT coalesce" ); } + + // ------------------------------------------------------------------- + // TerrainAnalysisCache (T-1137, PR #187 review — Tyre C1) + // ------------------------------------------------------------------- + + fn window_test_hm() -> crate::atlas::heightmap::BodyHeightmap { + use crate::atlas::heightmap::BodyHeightmap; + let (w, h) = (32u32, 16u32); + let n = (w * h) as usize; + let data = (0..n) + .map(|i| { + let r = (i / w as usize) as f32 / h as f32; + let c = (i % w as usize) as f32 / w as f32; + (r * 0.6 + c * 0.4).min(1.0) + }) + .collect(); + BodyHeightmap { + body_id: "test".into(), + width: w, + height: h, + data, + sea_level: 0.3, + } + } + + /// A miss re-derives and populates the entry; a subsequent hit for the + /// SAME body returns an equal `TerrainAnalysis` (D-227: the same + /// heightmap always derives to the same analysis) WITHOUT growing the + /// cache — `len()` stays at 1, proving the second call short-circuited + /// past `run_layer1` rather than deriving-then-overwriting. + #[test] + fn terrain_analysis_cache_hit_reuses_entry() { + let mut cache = TerrainAnalysisCache::new(8); + let hm = window_test_hm(); + + assert!(!cache.contains("BodyA")); + let first = cache.get_or_derive("BodyA", &hm); + assert_eq!(cache.len(), 1); + assert!(cache.contains("BodyA")); + + let second = cache.get_or_derive("BodyA", &hm); + assert_eq!( + cache.len(), + 1, + "a hit must not insert a second entry for the same body" + ); + assert_eq!( + first.ocean_mask, second.ocean_mask, + "same heightmap → identical re-derived analysis (D-227)" + ); + assert_eq!(first.slope_deg, second.slope_deg); + assert_eq!(first.elev_pct, second.elev_pct); + } + + /// Different bodies get independent entries, and a capacity-2 cache + /// evicts the LEAST-RECENTLY-USED entry — not insertion order — when a + /// third body is derived. Mirrors `BodyWorldStateCache`'s own + /// `update_last_accessed_on_get` precedent: touching "BodyA" again before + /// the third insert must save it from eviction. + #[test] + fn terrain_analysis_cache_evicts_lru_not_fifo() { + let mut cache = TerrainAnalysisCache::new(2); + let hm = window_test_hm(); + + cache.get_or_derive("BodyA", &hm); + cache.get_or_derive("BodyB", &hm); + assert_eq!(cache.len(), 2); + + // Touch BodyA again — it is now the MOST recently used, so BodyB + // (untouched since its own insert) is the true LRU victim. + cache.get_or_derive("BodyA", &hm); + + // Insert a third body — capacity 2 forces an eviction. + cache.get_or_derive("BodyC", &hm); + assert_eq!(cache.len(), 2); + assert!( + cache.contains("BodyA"), + "recently re-touched BodyA must survive eviction" + ); + assert!( + !cache.contains("BodyB"), + "BodyB (true LRU — untouched since its own insert) must be evicted" + ); + assert!(cache.contains("BodyC")); + } + + /// End-to-end: two `DeriveWindow` work items for the SAME body, submitted + /// through the real `GenerationQueue` (not the bare `TerrainAnalysisCache` + /// unit above), share ONE `TerrainAnalysisCache` entry — the fix for the + /// PR #187 review C1 finding (the original landing called `run_layer1` + /// unconditionally on every `DeriveWindow`, so panning around one body + /// paid the ~45 ms re-derive on every window instead of just the first). + /// Both windows must still complete correctly (content assertions, not + /// timing — a wall-clock assertion would be flaky); the cache-population + /// count is the load-bearing proof of reuse. + #[test] + fn two_windows_on_same_body_share_one_terrain_analysis_entry() { + let q = GenerationQueue::with_threads(2); + let conn_a = ConnectionId(1); + let conn_b = ConnectionId(2); + // Two DIFFERENT connections so submit_window's coalescing (which + // supersedes same-connection/same-body pending items) doesn't collapse + // these into one work item — the point here is two DISTINCT completed + // derives sharing the cache, not coalescing (already covered above). + q.submit_window( + derive_window("SharedBody", conn_a, (0, 0)), + GenPriority::Immediate, + ); + q.submit_window( + derive_window("SharedBody", conn_b, (10, 10)), + GenPriority::Immediate, + ); + + std::thread::sleep(Duration::from_millis(200)); + let completions = q.drain_completions(); + let windows: Vec<_> = completions + .iter() + .filter_map(|c| { + if let GenCompletion::WindowDerived { body_id, layer } = c { + if body_id == "SharedBody" { + return Some(layer); + } + } + None + }) + .collect(); + assert_eq!(windows.len(), 2, "both windows must complete"); + let centers: std::collections::BTreeSet<_> = windows.iter().map(|l| l.center).collect(); + assert_eq!( + centers, + std::collections::BTreeSet::from([(0, 0), (10, 10)]), + "both distinct windows survived, not coalesced" + ); + + // The queue's own TerrainAnalysisCache must hold exactly ONE entry + // for "SharedBody" — both derives shared it rather than each + // re-deriving independently. + let cache = q.terrain_cache.lock().unwrap(); + assert_eq!( + cache.len(), + 1, + "two DeriveWindow items for the same body must share one TerrainAnalysis entry" + ); + assert!(cache.contains("SharedBody")); + } } diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 8d1ae01d1..c4bee2ea7 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -1283,6 +1283,61 @@ mod tests { ); } + /// FULL-PATH determinism (PR #187 review — Tyre C3, binding, load-bearing + /// for save-file lineage under D-227): the test above reuses ONE `ta` for + /// both passes, which only proves `build_district_window_layer` (the + /// packer) is a pure function of its arguments — it says nothing about + /// whether re-running `run_layer1` itself (the D8 drainage pass + + /// `TerrainAnalysis::analyze`) is deterministic, which is exactly what the + /// production `DeriveWindow` path depends on (T-1137's `TerrainAnalysis` + /// re-derive / `TerrainAnalysisCache::get_or_derive` on a cache miss, and + /// `aliveness_probe --render`'s workaround before it). + /// + /// This test runs `run_layer1` TWICE, independently, from the SAME + /// `(seed, heightmap)` inputs — no shared `ta` — and asserts the two + /// COMPLETE `DistrictWindowLayer` outputs (derive AND pack) are + /// byte-identical. D-227's save-file guarantee ("same seed/body/position → + /// same derived output, always") is only as strong as the weakest link in + /// that chain; this closes the gap the packer-only test left open. + #[test] + fn full_path_two_independent_run_layer1_passes_produce_identical_window() { + let hm = window_test_hm(); + let params = window_test_params(); + let climate = crate::atlas::district_profile::ClimateConstants::default(); + let seed = SeedChain::root(11).derive(SeedDomain::Body, 5); + let n = 6u32; + let center = (4, -1); + + // Two INDEPENDENT calls to run_layer1 — each re-runs D8 drainage + + // TerrainAnalysis::analyze from scratch on the SAME heightmap, exactly + // mirroring what a cold TerrainAnalysisCache miss does on the real + // DeriveWindow path (or a second body eviction re-pay). + let (_, ta_pass1) = crate::atlas::layer1::run_layer1(&hm); + let (_, ta_pass2) = crate::atlas::layer1::run_layer1(&hm); + + // Confirm the two independent TerrainAnalysis derivations themselves + // agree field-by-field — a precise failure signal if drainage/analyze + // ever introduces nondeterminism (unordered iteration, uninitialized + // memory, etc.) BEFORE the packer even runs. + assert_eq!(ta_pass1.ocean_mask, ta_pass2.ocean_mask); + assert_eq!(ta_pass1.lake_mask, ta_pass2.lake_mask); + assert_eq!(ta_pass1.water_dist, ta_pass2.water_dist); + assert_eq!(ta_pass1.slope_deg, ta_pass2.slope_deg); + assert_eq!(ta_pass1.elev_pct, ta_pass2.elev_pct); + + // Now the FULL path: pack a DistrictWindowLayer from each independent + // TerrainAnalysis and confirm the complete served payload agrees. + let window_from_pass1 = + build_district_window_layer(seed, "test_body", ¶ms, &ta_pass1, center, n, &climate); + let window_from_pass2 = + build_district_window_layer(seed, "test_body", ¶ms, &ta_pass2, center, n, &climate); + assert_eq!( + window_from_pass1, window_from_pass2, + "two independent run_layer1 derivations from the same (seed, heightmap) \ + must pack to a byte-identical DistrictWindowLayer end to end (D-227)" + ); + } + /// [`DistrictWindowCache`] insert/get round-trips, and a capacity-1 cache /// evicts the oldest entry FIFO — mirroring `BodyWorldStateCache`'s own /// `evicts_lru_on_overflow` precedent, adapted to this cache's -- 2.54.0 From 4eed3bccb218f60daa30870875e21a7ffe3c6d42 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Jul 2026 13:57:28 +0200 Subject: [PATCH 4/5] fix(ui): latitude-inverse denominator + reticle city-hover gate (PR #187 findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoshe's blocking find: district_pos_at divided the latitude axis by tex_h where the server forward map uses ta.h.saturating_sub(1) (district_profile.rs:1436; ground-truth inverse aliveness_probe.rs:511) — every off-equator click descended into the wrong district, up to ~20km drift, live-repro'd at 10.2km. Fixed to /(tex_h - 1.0) with the reference implementation's degenerate-texture guard; verified against the repro (row 50: old formula recovered 41, fixed recovers 50 exact). Columns were already correct (longitude wraps — the asymmetry the docstring documented but the code didn't implement). Regression tests transcribe the server forward formula (source cited) and round-trip three off-equator rows — one per hemisphere plus near-pole — asserting EXACT recovery; a dedicated drift guard is framed as Tyre's C2 (a client-side twin of a server mapping owns its own round-trip proof). Root-caused the coverage hole: the old equator test sampled tex_h*0.5 believing it was the equator pixel — the true equator is (tex_h-1)*0.5, so the test never verified what it claimed; corrected with the why documented. Araminta's find: the descend reticle drew unconditionally on hover — over a city the marker flared white (city-click signal) while the reticle+extent label promised descent, though the click correctly resolved to city data. Extracted _should_draw_descend_reticle() with the missing _hovered_city.is_empty() clause; three state tests pin shows-over-map / hides-over-city / hides-when-idle. test_atlas_descend_entry 32/32; adjacent clusters no ripple; gdlint clean (atlas_viewer.gd at exactly the 1000-line cap). Co-Authored-By: Claude Fable 5 --- client/tests/test_atlas_descend_entry.gd | 156 +++++++++++++++++- .../apps/atlas/atlas_descend_geometry.gd | 44 +++-- client/ui/implant/apps/atlas/atlas_viewer.gd | 13 +- 3 files changed, 192 insertions(+), 21 deletions(-) diff --git a/client/tests/test_atlas_descend_entry.gd b/client/tests/test_atlas_descend_entry.gd index dde9e334f..56d85843c 100644 --- a/client/tests/test_atlas_descend_entry.gd +++ b/client/tests/test_atlas_descend_entry.gd @@ -83,16 +83,29 @@ func test_district_pos_at_zero_texture_size_is_safe() -> void: ## The equatorial center of the texture (px = tex_w/2) is district column ## district_cols/2 on a body with a radius (equator wraps at district (0,0) ## per the server's own doc: "district (0,0) sits at lon 0 / the equator"). -## The vertical center (py = tex_h/2) is the equator row (row 0). +## +## The TRUE equator pixel row is `(tex_h - 1) * 0.5`, NOT `tex_h * 0.5` (PR +## #187 review, Hoshe): the server's forward map (district_profile.rs:1436) +## divides by `ta.h.saturating_sub(1)`, so row 0 and row (h-1) are the real +## clamped pole endpoints and the midpoint between them is `(h-1)/2`. This +## test originally sampled `tex_h * 0.5`, which is NOT that midpoint for any +## finite tex_h — it happened to still round to district row 0 under BOTH the +## pre-fix buggy formula and the fix, at this specific radius/tex_h pair, +## which is exactly why this equator-only sample masked the ÷tex_h bug +## instead of catching it (see the off-equator round-trip tests below for the +## real regression coverage). Corrected here to the genuine equator pixel so +## this test asserts something true about the fixed formula, not a +## coincidence of the old one. func test_district_pos_at_center_of_texture_is_near_equator_row_zero() -> void: var radius_km := 6371.0 var tex_w := 1024.0 var tex_h := 512.0 + var true_equator_py: float = (tex_h - 1.0) * 0.5 var pos: Vector2i = AtlasDescendGeometry.district_pos_at( - Vector2(0.0, tex_h * 0.5), tex_w, tex_h, radius_km + Vector2(0.0, true_equator_py), tex_w, tex_h, radius_km ) assert_int(pos.y).override_failure_message( - "the vertical texture center must map to row 0 (the equator)" + "the true equator pixel ((tex_h-1)/2) must map to row 0" ).is_equal(0) @@ -116,6 +129,88 @@ func test_district_pos_at_sweeps_full_column_range_across_texture_width() -> voi assert_int(absi(right.x - left.x)).is_greater(1000) +# ============================================================================= +# Off-equator round-trip (PR #187 review, Hoshe — BLOCKING, live-repro'd bug): +# district_pos_at's row inverse divided by tex_h instead of (tex_h - 1), +# matching the WRONG symmetry with the column inverse (which correctly +# divides by the plain tex_w, since longitude wraps and has no edge case). +# The server's forward map (district_profile.rs:1436) uses +# ta.h.saturating_sub(1) for the SAME reason the ground-truth inverse +# (aliveness_probe.rs:511, `row / (ta_h - 1) - 0.5`) does: latitude CLAMPS at +# the poles, so row 0 / row (h-1) are real endpoints the division must land +# on exactly. This class of bug — a client-side twin of a server coordinate +# mapping silently drifting out of sync — is exactly what Tyre's C2 review +# criterion requires a round-trip drift guard for; that is what every test in +# this section is. The suite previously missed it because the only +# real-radius latitude sample was py = tex_h*0.5 (test above), the ONE point +## where the buggy (÷tex_h) and correct (÷(tex_h-1)) formulas round to the +# same integer district row — never exercising the asymmetry the fix targets. +# ============================================================================= + + +## Forward-maps a district row to its heightmap pixel row using the server's +## OWN formula, transcribed here (district_profile.rs:1436): +## let lat_frac = (dy * DISTRICT_M / meridian_m).clamp(-0.5, 0.5); +## let py = (0.5 + lat_frac) * ta.h.saturating_sub(1); +## `dy` is a DistrictPos.y component (not a pixel) — the caller supplies the +## same `district_rows_half`-scaled value district_pos_at()'s inverse would +## need to recover, so this function and district_pos_at() are meant to be +## exact inverses of one another for any row within the clamped range. +static func _server_forward_map_row(district_row: int, tex_h: float, radius_km: float) -> float: + var meridian_m: float = PI * radius_km * 1000.0 + var district_m: float = AtlasDescendGeometry.DISTRICT_M + var lat_frac: float = clampf((float(district_row) * district_m) / meridian_m, -0.5, 0.5) + return (0.5 + lat_frac) * (tex_h - 1.0) + + +## The actual regression: forward-map three off-equator district rows (one +## per hemisphere, plus a near-pole extreme) to pixel rows via the server's +## transcribed formula, then assert district_pos_at() recovers each EXACT +## row from that pixel. Fails outright under the pre-fix ÷tex_h formula for +## every row here except (coincidentally) very near the equator. +func test_district_pos_at_round_trips_off_equator_rows_against_server_forward_map() -> void: + var radius_km := 6238.4 # GJ380c (server/data/systems.db) — a real body, not a round number + var tex_w := 1024.0 + var tex_h := 512.0 + var col := 400 # arbitrary — this test is about the row axis only + + # One row per hemisphere (moderate latitude) + one extreme near-pole row. + # meridian_m / DISTRICT_M ≈ 9,569 for this radius, so district_rows_half + # ≈ 4,785 — these rows sit well inside that range without saturating the + # clamp (except the "near pole" case, deliberately close to the edge). + var test_rows: Array = [-1200, 900, -4700] + + for district_row: int in test_rows: + var forward_py: float = _server_forward_map_row(district_row, tex_h, radius_km) + var recovered: Vector2i = AtlasDescendGeometry.district_pos_at( + Vector2(float(col), forward_py), tex_w, tex_h, radius_km + ) + assert_int(recovered.y).override_failure_message( + ( + "row round-trip failed: district_row=%d -> forward pixel py=%.4f -> " + + "district_pos_at recovered row=%d (drift=%d)" + ) % [district_row, forward_py, recovered.y, recovered.y - district_row] + ).is_equal(district_row) + + +## Same round-trip, restated as an explicit non-equator drift guard: the +## pre-fix bug produced a WRONG but still-integer row (Hoshe's live repro: +## forward row 50, buggy inverse returned 45 — a 5-district, ~10.2 km drift) +## — a coarse "is it roughly right" tolerance would have passed that. This +## asserts EXACT equality specifically at a moderate off-equator row, not an +## approximate one, so a reintroduced ÷tex_h regression fails loudly again. +func test_district_pos_at_off_equator_row_is_not_off_by_a_few_districts() -> void: + var radius_km := 6238.4 + var tex_w := 1024.0 + var tex_h := 512.0 + var district_row := 2500 + var forward_py: float = _server_forward_map_row(district_row, tex_h, radius_km) + var recovered: Vector2i = AtlasDescendGeometry.district_pos_at( + Vector2(512.0, forward_py), tex_w, tex_h, radius_km + ) + assert_int(recovered.y).is_equal(district_row) + + # ============================================================================= # AtlasViewer — fixed view (no drag-pan/wheel-zoom) + click-through descent # ============================================================================= @@ -160,3 +255,58 @@ func test_regional_screen_forwards_district_descend_requested() -> void: screen._viewer.district_descend_requested.emit(Vector2i(5, 7)) assert_int(received.size()).is_equal(1) assert_that(received[0]).is_equal(Vector2i(5, 7)) + + +# ============================================================================= +# PR #187 review (Araminta — broken promise): the descend reticle must be +# hidden while hovering a city marker — _hovered_city already flares white as +# the "click here for city data" signal, and _try_click_city wins the click +# over descent, so drawing the reticle at the same time promised a descent +# the click would never perform. State-level tests (per the review's own +# "state-level is fine" allowance): assert _should_draw_descend_reticle()'s +# condition directly against _hover_active/_hovered_city/_heightmap_texture, +# rather than a pixel-diff on the actual draw call. +# ============================================================================= + + +static func _one_pixel_texture() -> ImageTexture: + var img := Image.create(1, 1, false, Image.FORMAT_RGB8) + return ImageTexture.create_from_image(img) + + +## Hovering EMPTY map (no city under the cursor) -> reticle SHOWS. +func test_descend_reticle_shows_when_hovering_empty_map() -> void: + var v: AtlasViewer = auto_free(AtlasViewer.new()) + add_child(v) + v._heightmap_texture = _one_pixel_texture() + v._hover_active = true + v._hovered_city = {} + assert_bool(v._should_draw_descend_reticle()).override_failure_message( + "reticle must show when hovering the open map (no city under cursor)" + ).is_true() + + +## Hovering a CITY marker -> reticle HIDES, even though _hover_active is true +## (this is the exact bug: pre-fix, _hovered_city was never consulted here). +func test_descend_reticle_hides_when_hovering_a_city() -> void: + var v: AtlasViewer = auto_free(AtlasViewer.new()) + add_child(v) + v._heightmap_texture = _one_pixel_texture() + v._hover_active = true + v._hovered_city = {"name": "Ridgeback", "pos": [10, 20]} + assert_bool(v._should_draw_descend_reticle()).override_failure_message( + "reticle must hide while hovering a city — the click would open city" + + " data (_try_click_city wins), not descend" + ).is_false() + + +## Not hovering at all (_hover_active false) -> reticle HIDES regardless of +## _hovered_city — the pre-existing base condition, guarded here so the city +## fix can't accidentally invert it. +func test_descend_reticle_hides_when_not_hovering() -> void: + var v: AtlasViewer = auto_free(AtlasViewer.new()) + add_child(v) + v._heightmap_texture = _one_pixel_texture() + v._hover_active = false + v._hovered_city = {} + assert_bool(v._should_draw_descend_reticle()).is_false() diff --git a/client/ui/implant/apps/atlas/atlas_descend_geometry.gd b/client/ui/implant/apps/atlas/atlas_descend_geometry.gd index 4202052f3..469aa5fb2 100644 --- a/client/ui/implant/apps/atlas/atlas_descend_geometry.gd +++ b/client/ui/implant/apps/atlas/atlas_descend_geometry.gd @@ -80,17 +80,30 @@ static func reticle_label(center: Vector2) -> Dictionary: ## function, per the amendment's §5 carry-over wording. The server's forward ## mapping (body has a radius) is: ## px = ((dx * DISTRICT_M) / circumference_m mod 1.0) * tex_w -## py = (0.5 + clamp(dy * DISTRICT_M / meridian_m, -0.5, 0.5)) * tex_h -## which is a linear scaling of the same world-metre fraction the equatorial/ -## meridian district COUNT already IS (district_cols = round(circumference_m -## / DISTRICT_M), the same value build_district_grid()'s `cols` converges to -## for a body tiled edge-to-edge) — so inverting is "pixel fraction * district -## count", not a re-derivation of the server's geodesy. Self-contained: does -## NOT depend on district_grid (the whole-body layer) having arrived yet, so -## descent works immediately on entry even before that async layer resolves. -## The "no radius" branch (tiny test bodies, body_radius_km absent/<=0) -## mirrors the server's own fallback: the district grid IS the heightmap -## grid 1:1. +## py = (0.5 + clamp(dy * DISTRICT_M / meridian_m, -0.5, 0.5)) * ta.h.saturating_sub(1) +## (district_profile.rs:1436 — note `.saturating_sub(1)`, NOT a bare `ta.h`; +## confirmed against the ground-truth inverse aliveness_probe.rs:511 too: +## `row / (ta_h - 1) - 0.5`). Columns and rows are DELIBERATELY asymmetric: +## longitude WRAPS (rem_euclid), so a column has no "last pixel" edge case and +## divides by the plain width; latitude CLAMPS at the poles, so row 0 and row +## (h-1) are real, distinct endpoints (the north/south pole pixels) and the +## division must land exactly on them — dividing by `tex_h` instead of +## `tex_h - 1` introduces a systematic drift that grows with |lat_frac| +## (worst at the poles, invisible only at the exact equator row, `tex_h*0.5`, +## where the two formulas round identically). This is why inverting is "pixel +## fraction * district count" for columns but NOT a symmetric operation for +## rows — the row inverse must undo the SAME -1 the forward map applied. +## +## The (wx/circumference_m, wy/meridian_m) fractions are linear scalings of +## the same world-metre quantity the equatorial/meridian district COUNT +## already IS (district_cols = round(circumference_m / DISTRICT_M), the same +## value build_district_grid()'s `cols` converges to for a body tiled +## edge-to-edge) — so inverting is "pixel fraction * district count", not a +## re-derivation of the server's geodesy. Self-contained: does NOT depend on +## district_grid (the whole-body layer) having arrived yet, so descent works +## immediately on entry even before that async layer resolves. The "no +## radius" branch (tiny test bodies, body_radius_km absent/<=0) mirrors the +## server's own fallback: the district grid IS the heightmap grid 1:1. static func district_pos_at( canvas_pt: Vector2, tex_w: float, tex_h: float, body_radius_km: float ) -> Vector2i: @@ -103,5 +116,12 @@ static func district_pos_at( var district_cols: float = roundf(circumference_m / DISTRICT_M) var district_rows_half: float = roundf((meridian_m / DISTRICT_M) * 0.5) var col: int = roundi((canvas_pt.x / tex_w) * district_cols) - var row: int = roundi(((canvas_pt.y / tex_h) - 0.5) * district_rows_half * 2.0) + # tex_h - 1.0, matching the forward map's ta.h.saturating_sub(1) — NOT a + # bare tex_h (see the docstring above; this was a live bug, T-1138 PR #187 + # review, Hoshe: every off-equator click descended into the wrong district). + # Guarded the same way the server's own inverse (aliveness_probe.rs:510, + # `if ta_h > 1 { ... } else { 0.0 }`) guards the same division — a + # degenerate 1px-tall texture would otherwise divide by zero. + var lat_frac: float = ((canvas_pt.y / (tex_h - 1.0)) - 0.5) if tex_h > 1.0 else 0.0 + var row: int = roundi(lat_frac * district_rows_half * 2.0) return Vector2i(col, row) diff --git a/client/ui/implant/apps/atlas/atlas_viewer.gd b/client/ui/implant/apps/atlas/atlas_viewer.gd index c08e299c3..44e9be4e3 100644 --- a/client/ui/implant/apps/atlas/atlas_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_viewer.gd @@ -16,8 +16,7 @@ extends Control ## - Empty markers case (server #832/#833 not yet shipped): bare heightmap renders ## fine, no sidebar opens, overlays draw nothing. ## - Overlays (#836) plug into _overlay_visibility dict and _draw_overlays(). -## - T-1138: view is FIXED (set_view() is capture-API-only); descent screen has the reticle math. -## Navigation: Hover reticle · Click descend · Click city data (wins) · R reset(no-op) · Esc back +## Navigation (view FIXED, T-1138): hover reticle (hidden/city) · click descend/city · esc back signal back_pressed signal economics_link_requested(system_id: String) @@ -645,13 +644,15 @@ func screen_to_canvas(screen_point: Vector2) -> Vector2: func _draw() -> void: draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) - if _hover_active and _heightmap_texture != null: + if _should_draw_descend_reticle(): _draw_descend_reticle() -## T-1138 NOT-TO-SCALE reticle (§5's open design point). Draw calls only — -## geometry/rationale in atlas_descend_geometry.gd (draw_line must run on `self`). -func _draw_descend_reticle() -> void: +func _should_draw_descend_reticle() -> bool: # hidden over a city — _try_click_city wins the click + return _hover_active and _heightmap_texture != null and _hovered_city.is_empty() + + +func _draw_descend_reticle() -> void: # draw calls only — geometry in atlas_descend_geometry.gd var center: Vector2 = _hover_screen_pos for seg: Array in AtlasDescendGeometry.reticle_segments(center): draw_line(seg[0], seg[1], AtlasDescendGeometry.COLOR_DESCEND_RETICLE, 1.5) -- 2.54.0 From 96f6baa93096a63acd7837b5be1b169a1fa4bbab Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Jul 2026 13:57:45 +0200 Subject: [PATCH 5/5] =?UTF-8?q?docs(meta):=20D-226=20T-1124=20prose=20corr?= =?UTF-8?q?ections=20=E2=80=94=20real=20TerrainAnalysis=20cost=20model=20+?= =?UTF-8?q?=20glaciation=20gate=20(PR=20#187=20C1/C4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SS4: the 7/29ms per-window figures now state their warm-analysis assumption — the first window on a body additionally pays one ~45ms run_layer1 into the lazy per-body LRU on the GenerationQueue (capacity 8, browsed-bodies-only, recency eviction, eviction re-pays); subsequent windows any (center, n) hit the LRU. SS5: the glaciation tint gate corrected from >= Light to >= Moderate, matching apply_ice_tint's reference gate (Light is erosion signatures, not visible ice) — code stood, prose was wrong. Co-Authored-By: Claude Fable 5 --- governance/decisions/architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 9925998cb..90019f019 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1655,14 +1655,14 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **`vegetation` palette/legend must be exhaustive over `Marine = 6`.** T-1126 appended `Marine` to `VegetationClass` (open water, morphology-derived, never a threshold of its own) specifically because the district tier is where the ocean-blind-vegetation bug (frozen bodies reading Forest over open sea) was caught. Any client palette/legend for this field that omits `Marine` reintroduces exactly that bug at window resolution — non-negotiable inclusion, not a nice-to-have. - **T-1127's ceiling ruling generalizes to this whole field list.** Its refinement text is explicit: *"the cap governs new top-level Option layers, not per-cell fields inside a shipped layer"* — the precedent it cites (`elev_q` already riding inside `DistrictGridLayer` with no separate governance gate) is the same shape as all six fields here riding inside one `DistrictWindowLayer`. Per-cell field additions to an already-shipped layer are engineering, not governance, unless a field would reopen a frozen vocabulary (D-239 §6's 17-zone freeze) — none of these six do; `morphology_zone` and `vegetation_class` both ship their existing frozen/T-1126-amended discriminant sets unchanged. - - **(4) Budget (binding numbers).** Per-cell wire cost is **7 bytes** (`morphology` 1 + `elev_q` 1 + `temp_dc` 2 + `moisture_q` 1 + `vegetation` 1 + `glaciation` 1), before MessagePack array-header overhead (negligible at these sizes — six flat byte/i16 arrays, no per-element framing). **`DISTRICT_WINDOW_MAX_N = 64`** (request-side hard clamp, §1): 64×64 = 4,096 cells → 28 KiB raw payload. This is deliberately the same `n` as `aliveness_probe --render`'s default window (T-1123) — a value already proven to render correctly server-side and matching the "regional inspection" altitude the ticket names (64 districts × 2.048 km ≈ 131 km per side — city-and-hinterland scale, not planetary). **`DISTRICT_WINDOW_DEFAULT_N = 32`** (client's interactive default, 1,024 cells → 7 KiB) — half the cap. The derive cost is **background-queue latency, not tick-thread cost** (per §1's serving model, the window derives on a Rayon worker and returns via a later tick's completion drain, never inline): at the ~7 µs/derive debug-build rate T-1123's window renderer measured, a full window is ≈ 7 ms (n=32) / ≈ 29 ms (n=64 cap) of **Rayon-worker time**, i.e. the enqueue-to-completion delay a client waits across (a tick or few, bridged by §5's border-fade), *not* time spent on the PreInput drain. **These are debug-build figures with no committed release-build number yet** — release is expected meaningfully faster (no debug assertions, inlining) but the design does not presume a specific multiplier. The cap's job in the background-queue model is to bound how long one window job occupies a worker (so it can't starve whole-body cascade jobs sharing the pool) and to keep the client-visible wait short; if profiling shows it should move, `DISTRICT_WINDOW_MAX_N` is the one constant to tune, not a redesign. Because the derive is off the tick thread, D-200's 5 ms on-demand tile-fill budget is irrelevant here (that governs the main-tick chunk-boundary path); the window is served over the D-226 item (1) paused-sim bridge path (Snapshot/PostSnapshot stay alive while Movement/Simulation/Economy/etc. freeze) exactly as whole-body layers are, and its responsiveness budget is the same background-queue-plus-poll latency those already accept, not a per-tick deadline. **Re-request-on-pan policy:** the client re-requests only when a **pan** carries the view past the held window's edge — **never on zoom** (§5): only pan changes `center`, and because window derivation is deterministic (D-227) a zoom-triggered re-fetch of the same `(center, n)` would spam byte-identical responses for zero new information. The client debounces pan motion (do not fire on every drag-frame delta); the exact debounce interval and whether windows snap to a fixed grid vs. float on the pan center are client-side screen decisions (Araminta's §5) using the request/response contract fixed here. **Client cache policy:** windows are cacheable client-side keyed on `(body_id, center, n)` — D-227's determinism guarantee (same seed/body/position → same derived output, always) means a previously-fetched window is valid forever for that body+seed and can be kept in an LRU without a freshness check, exactly the same guarantee that makes the whole invention pipeline (D-227's "invented deterministically" clause, the actual subject T-1124 surfaces to a screen for the first time) safe to memoize; eviction policy (size, LRU depth) is a client implementation detail, not fixed here. + - **(4) Budget (binding numbers).** Per-cell wire cost is **7 bytes** (`morphology` 1 + `elev_q` 1 + `temp_dc` 2 + `moisture_q` 1 + `vegetation` 1 + `glaciation` 1), before MessagePack array-header overhead (negligible at these sizes — six flat byte/i16 arrays, no per-element framing). **`DISTRICT_WINDOW_MAX_N = 64`** (request-side hard clamp, §1): 64×64 = 4,096 cells → 28 KiB raw payload. This is deliberately the same `n` as `aliveness_probe --render`'s default window (T-1123) — a value already proven to render correctly server-side and matching the "regional inspection" altitude the ticket names (64 districts × 2.048 km ≈ 131 km per side — city-and-hinterland scale, not planetary). **`DISTRICT_WINDOW_DEFAULT_N = 32`** (client's interactive default, 1,024 cells → 7 KiB) — half the cap. The derive cost is **background-queue latency, not tick-thread cost** (per §1's serving model, the window derives on a Rayon worker and returns via a later tick's completion drain, never inline): at the ~7 µs/derive debug-build rate T-1123's window renderer measured, a full window is ≈ 7 ms (n=32) / ≈ 29 ms (n=64 cap) of **Rayon-worker time**, i.e. the enqueue-to-completion delay a client waits across (a tick or few, bridged by §5's border-fade), *not* time spent on the PreInput drain. **Corrected per PR #187 C1:** those per-window figures assume the body's `TerrainAnalysis` is warm — the **first** window on a body additionally pays one ~45 ms `run_layer1` derive (the analysis is deliberately transient post-cascade, D-203), memoized in a **lazy per-body LRU on the `GenerationQueue`** (capacity 8, ~2 MB/entry, browsed-bodies-only, true recency eviction; an eviction re-pays the ~45 ms on that body's next window). Every subsequent window on the same body — any `(center, n)`, not just exact repeats — hits the LRU and costs only the per-window pack. **These are debug-build figures with no committed release-build number yet** — release is expected meaningfully faster (no debug assertions, inlining) but the design does not presume a specific multiplier. The cap's job in the background-queue model is to bound how long one window job occupies a worker (so it can't starve whole-body cascade jobs sharing the pool) and to keep the client-visible wait short; if profiling shows it should move, `DISTRICT_WINDOW_MAX_N` is the one constant to tune, not a redesign. Because the derive is off the tick thread, D-200's 5 ms on-demand tile-fill budget is irrelevant here (that governs the main-tick chunk-boundary path); the window is served over the D-226 item (1) paused-sim bridge path (Snapshot/PostSnapshot stay alive while Movement/Simulation/Economy/etc. freeze) exactly as whole-body layers are, and its responsiveness budget is the same background-queue-plus-poll latency those already accept, not a per-tick deadline. **Re-request-on-pan policy:** the client re-requests only when a **pan** carries the view past the held window's edge — **never on zoom** (§5): only pan changes `center`, and because window derivation is deterministic (D-227) a zoom-triggered re-fetch of the same `(center, n)` would spam byte-identical responses for zero new information. The client debounces pan motion (do not fire on every drag-frame delta); the exact debounce interval and whether windows snap to a fixed grid vs. float on the pan center are client-side screen decisions (Araminta's §5) using the request/response contract fixed here. **Client cache policy:** windows are cacheable client-side keyed on `(body_id, center, n)` — D-227's determinism guarantee (same seed/body/position → same derived output, always) means a previously-fetched window is valid forever for that body+seed and can be kept in an LRU without a freshness check, exactly the same guarantee that makes the whole invention pipeline (D-227's "invented deterministically" clause, the actual subject T-1124 surfaces to a screen for the first time) safe to memoize; eviction policy (size, LRU depth) is a client implementation detail, not fixed here. - **(5) Screen (Araminta).** The regional view is a **zoom-threshold LOD swap on the existing `AtlasViewer`, not a new screen**: crossing a second, higher zoom threshold (`DISTRICT_WINDOW_MIN_ZOOM`, proposed `6.0`, past the existing `SETTLEMENT_LABEL_MIN_ZOOM = 2.0`) swaps the draw target in place — same `AtlasViewer` node, same `RegionalScreen` nav-stack "regional" state `show_body()` already establishes, no nav-stack push, no second `Control` scene, no new crumb. **The swap trigger is the zoom threshold alone** — there is no "must be over a settlement" precondition; the district window derives for *any* `DistrictPos` (`derive_district` is defined everywhere, ocean included), so the player can descend anywhere they can pan to. **The first window centers on the pan-center's derived `DistrictPos`** — the `DistrictPos` nearest the current screen-center point at the moment the threshold is crossed (`true_district_of_pixel`-style inverse mapping), *consistent with §4's float-on-center pan model*, **not** snapped to the nearest settlement. (Settlements matter only as the *practical* reason a player zooms in on a spot — the map's markers draw the eye — but nothing in the mechanism keys on them; a player zooming into open coastline gets that coastline's window, correctly.) Both thresholds live on the existing `_view_zoom` float. Crossing back out (zoom below the threshold, or Esc — the two exits are one code path, a zoom-value transition watcher) swaps back. This reuses the viewer's own established LOD vocabulary (zoom past a threshold reveals more detail — labels at 2.0, the district window here) and its "reveal more without leaving" instinct (city-click opens a sidebar, not a nav push). **Rejected:** a nav-stack push (the planetary→regional push earns its crumb because the rendering genuinely changes — galaxy scatter → heightmap texture; body→window is the same viewer at a smaller camera window, so a crumb would imply "you left somewhere" for a metric reached by scrolling in); click-to-open on the settlement dot (overloads the existing city-click sidebar gesture — two intents on one gesture, and D-013 argues the zoom gesture should own spatial descent); a dedicated "view district" button (duplicates what pan/zoom already promises at every other level of this map). - **Pan re-fetches; zoom does not.** A **pan** past the held window's edge re-centers `window_center` and fires a new request (§1's optional fields, same request machinery); **zoom never re-fetches** — with `DISTRICT_WINDOW_MAX_N = 64` the composite is a texture the client zooms client-side (the existing `_view_zoom` mechanic, now on the smaller composite) to get from a coarse read (~14 px/cell at n=64, 1.0×) to a detail read (~28 px/cell at 2×, comparable to the T-1123 `w256` probe renders' native fine texture) **from already-held data**. In-window zoom doing real legibility work is what separates "fetch = ground coverage" from "render zoom = detail resolution"; a zoom-triggered re-fetch would spam byte-identical (D-227) responses for the exact `(center, n)` already on screen. This is the §4 re-request policy's client-side rationale. - **Debounce + window origin (the two client-side calls §4 left open).** Re-fetch fires **150 ms after the last drag-release** (not per-drag-frame) — long enough to collapse a flick-and-resettle into one request, short enough that a deliberate single pan-and-stop never feels delayed (no competing tick-driven redraw under the D-226 pause). Windows **float on the pan center** (nearest `DistrictPos` to the new screen-center), **not grid-snapped** — snapping would jump the composite by up to half a window-width across a snap boundary (a visually discontinuous "invisible re-fetch"), and floating keeps the spot the player is looking at exactly under the screen-center on entry and after every pan (consistent with the first-window centering above — the descent point stays put, whether it's a settlement or open coast); the §4 client cache still gets real hit value because Esc-then-re-enter and pan-back reproduce the same `(body_id, center, n)` (D-227 makes exact-repeat the common case for the two navigation patterns that matter), without a grid forcing arbitrary alignment. - **What renders during the wait** (the "invisible re-fetch"): the previous composite, panned to its new screen position, with a **border-fade to the underlying whole-body heightmap** (already resident, coarser `district_grid`/`region_grid` data — real data seen through, not a placeholder) at the newly-exposed edge; **no black, no spinner** unless the wait exceeds a ~0.5 s grace window (reusing the existing `_gen_pending_indicator`, not a new mechanism). Because §4's cache is D-227-valid indefinitely, a pan back toward a recently-cached window composites from cache with zero wait — the genuine-miss `Pending`/re-poll path (D-225's existing loop) becomes the minority case, not the default. - - **Overlay/legend reuse against §2–§4.** The base layer is **morphology, lightness-modulated by `elev_q`** (one `0.7 + 0.3*(elev_q/100)` multiply per cell — relief read without a second draw call, the T-1112 "shape=identity, cheap second channel=magnitude" instinct as hue=type / lightness=elevation), reusing the T-1123 probe's 17-entry `MORPHOLOGY_RGB` hues verbatim; it is always-on once the LOD threshold is crossed (it *is* this screen's `terrain` layer), so it takes no toggle id. Three switchable overlays get new `gen_dw_temp` / `gen_dw_moisture` / `gen_dw_veg` `OVERLAY_DEFS` ids (`group: "toggle"`, the `gen_l1_*` multi-toggle-over-one-base precedent): **temperature** reuses T-1118's region-grid ramp *exactly* (same `i16` deci-°C domain + `REGION_TEMP_NONE_DC` sentinel disposition — one colorizer across both zoom levels, §2's consistency ruling); **moisture** reuses the existing `SUB_BIOME_COLORS` dry-sand→wet-teal endpoints; **vegetation** is a green-family ramp with **`Marine = 6` rendered transparent** (lets the morphology water-blue show through — `Marine` is `derive_vegetation`'s bookkeeping answer for already-`OpenOcean`/`Lake` districts, not new player information; a second blue would fight or duplicate the morphology read — this is the exhaustive disposition §3 mandates). **Glaciation is a modifier, not a toggle**: an ice-tint wash gated on `glaciation_grade >= Light` (alpha scaling with grade) composited over whichever layer shows — the `aliveness_probe::apply_ice_tint` approach ported to the player composite; it keeps sea-ice (tint over `OpenOcean` navy → whitened blue) visually distinct from open ocean and from ice-capped land (tint over alpine grey → near-white) by alpha-compositing over different bases rather than three drifting hard-coded colors. Legend: one `GENERATION_LEGEND` entry per new id (existing data-driven `atlas_legend_panel.gd` table, no new panel class); the morphology base folds its 17 zones into ~5 family rows (water / coastal-transition / plains-river / upland / volcanic) with the full mapping in the city-click sidebar, mirroring T-1112's "not everything earns permanent screen space" discipline. Implant chrome discipline (D-169/D-170): `ImplantHeader` carries a location label (the nearest settlement's name when the window is over/near one, else a coordinate/region label — the window is not settlement-anchored, per the entry clause above) + extent-in-real-units subtitle (e.g. "4.1 × 4.1 km · 2.0 km/cell") + one optional flavor line; the map-data palettes stay **out of** the theme's semantic accent roles (especially `ACCENT_ACTIVE` gold, which the settlement marker owns and must not compete with); no scanline/glitch dressing (the implant is confident working tech — a signal-quality state, if ever needed, rides `_gen_pending_indicator`, not cosmetic noise). Full color/ramp/compositing/legibility rationale and the n=32↔n=64 on-screen-scale math live in Araminta's companion T-1124 sections (visual encoding / implant aesthetic / legibility constraints), not re-derived here. + - **Overlay/legend reuse against §2–§4.** The base layer is **morphology, lightness-modulated by `elev_q`** (one `0.7 + 0.3*(elev_q/100)` multiply per cell — relief read without a second draw call, the T-1112 "shape=identity, cheap second channel=magnitude" instinct as hue=type / lightness=elevation), reusing the T-1123 probe's 17-entry `MORPHOLOGY_RGB` hues verbatim; it is always-on once the LOD threshold is crossed (it *is* this screen's `terrain` layer), so it takes no toggle id. Three switchable overlays get new `gen_dw_temp` / `gen_dw_moisture` / `gen_dw_veg` `OVERLAY_DEFS` ids (`group: "toggle"`, the `gen_l1_*` multi-toggle-over-one-base precedent): **temperature** reuses T-1118's region-grid ramp *exactly* (same `i16` deci-°C domain + `REGION_TEMP_NONE_DC` sentinel disposition — one colorizer across both zoom levels, §2's consistency ruling); **moisture** reuses the existing `SUB_BIOME_COLORS` dry-sand→wet-teal endpoints; **vegetation** is a green-family ramp with **`Marine = 6` rendered transparent** (lets the morphology water-blue show through — `Marine` is `derive_vegetation`'s bookkeeping answer for already-`OpenOcean`/`Lake` districts, not new player information; a second blue would fight or duplicate the morphology read — this is the exhaustive disposition §3 mandates). **Glaciation is a modifier, not a toggle**: an ice-tint wash gated on `glaciation_grade >= Moderate` (alpha scaling with grade; `None`/`Light` draw no tint — `Light` is erosion signatures, not visible ice, per `apply_ice_tint`'s own gate, which this corrected prose now matches — PR #187 C4) composited over whichever layer shows — the `aliveness_probe::apply_ice_tint` approach ported to the player composite; it keeps sea-ice (tint over `OpenOcean` navy → whitened blue) visually distinct from open ocean and from ice-capped land (tint over alpine grey → near-white) by alpha-compositing over different bases rather than three drifting hard-coded colors. Legend: one `GENERATION_LEGEND` entry per new id (existing data-driven `atlas_legend_panel.gd` table, no new panel class); the morphology base folds its 17 zones into ~5 family rows (water / coastal-transition / plains-river / upland / volcanic) with the full mapping in the city-click sidebar, mirroring T-1112's "not everything earns permanent screen space" discipline. Implant chrome discipline (D-169/D-170): `ImplantHeader` carries a location label (the nearest settlement's name when the window is over/near one, else a coordinate/region label — the window is not settlement-anchored, per the entry clause above) + extent-in-real-units subtitle (e.g. "4.1 × 4.1 km · 2.0 km/cell") + one optional flavor line; the map-data palettes stay **out of** the theme's semantic accent roles (especially `ACCENT_ACTIVE` gold, which the settlement marker owns and must not compete with); no scanline/glitch dressing (the implant is confident working tech — a signal-quality state, if ever needed, rides `_gen_pending_indicator`, not cosmetic noise). Full color/ramp/compositing/legibility rationale and the n=32↔n=64 on-screen-scale math live in Araminta's companion T-1124 sections (visual encoding / implant aesthetic / legibility constraints), not re-derived here. **Amended 2026-07-21 (T-1124 §5 entry revision — Jeroen, first companion hands-on):** the regional-map **entry mechanic changes from zoom-threshold LOD swap to explicit click-through**. Jeroen's ruling after using `make atlas`: the planetary pixel-scaling pan/zoom "is only messing with the pixels of the map and the interaction is weird" — (a) the **planetary heightmap view becomes FIXED** (no drag-pan / wheel-zoom of the planetary canvas; the current pan/zoom ships until T-1138 replaces it, then is removed *in the same change* as the replacement so close inspection is never stranded); (b) **entry is a click-through**: hovering the planetary heightmap shows a **rectangle cursor** representing the regional-mode bounds, and clicking descends into the regional map centered on the click point's derived `DistrictPos` — §5's float-on-center/first-window rules carry over with "pan center" read as "click point". `DISTRICT_WINDOW_MIN_ZOOM` is retired before ever being built (the T-1138 zoom-headroom note is moot); the §5 cross-reference reading of D-013 ("the zoom gesture owns spatial descent") is superseded **for this seam only** — click owns descent. Everything *inside* the regional mode stands unchanged (§4 pan-only refetch, debounce, float-on-center, D-227 cache, border-fade). A **morphing transition** between map modes is explicitly deferred (Jeroen: nice, too ambitious for now) — the descent may cut. **Open at T-1138:** the rectangle cursor is an affordance, not to scale — an n=64 window (~131 km) is a few pixels on a planetary canvas; the screen design must resolve the honest representation (rectangle at true extent with a zoom-in cut on click, or a not-to-scale reticle with the real extent labeled beside it) without implying the regional view covers more planet than it does. - **Rationale:** Reusing the real UI — rather than a parallel offline renderer or dumped files — means the debug/review surface never diverges from what ships, and a dropped artifact can't go stale. Agent-navigability converts qualitative "does the synthesis look natural?" review from a manual eyeball pass into an automatable sweep that flags the few outliers for a human. The harness rides seams that already exist (`TickRate::Paused`, the paused-allowlist, `gameplay_occluded`, the bridge framing, the `run-visual` capture primitive) — a naming-and-contract exercise, not a new subsystem. -- 2.54.0