Files
settled-reach/governance/decisions/architecture.md
T
jpmschweitzerandClaude Fable 5 de8bcf4ebb fix(simulation): PR #215 review fixes — hop-unit surcharge, D-210 amendment, citation + gap
The surcharge is now COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING=1 added
directly to length_cells (a pure hop count) — the old cost-unit
constant div_ceil'd through MIN_CELL_COST silently produced 4 hops per
ring, worst-case +24 (double the waypoint threshold) for physically
short edges; worst case is now 6. A formula-pinning test asserts both
the arithmetic and the constant. GJ251c's repro tightened to the
documented 2 edges. The always-land citation now points at the real
guarantee (features.rs::extract_attractors, D-209) — and checking the
D-211 Phase-4 synthetic-overflow path exposed a real gap: it has no
ocean-mask guard at all (T-1206 filed); documented, not papered over.
D-210 gains a dated amendment recording the surrogate-anchor-at-cost
carve-out and the relaxation-over-nudge adjudication. The bare 100
dependency dissolved with the unit fix. Edge counts on both repro
bodies verified unchanged (reachability was never affected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 13:34:43 +02:00

524 KiB
Raw Blame History

Architecture Decisions

Technical foundation decisions that constrain implementation: engine, client-server, ECS, simulation, testability, performance budgets, audio architecture.


D-008: Action pillar design principles

  • Date: 2026-02-08
  • Decision: The action/combat system follows these principles:
    • Rimworld/XCOM hybrid - simple mechanics, stats-driven. Simple gets complicated fast on its own.
    • Z-levels - floors, verticality. Not 3D rendering, but 3D space. Fights across floors of a building.
    • Perception-bounded - you see/hear what your character can. Rest is abstracted/occluded. Same principle as information asymmetry applied to physical space.
    • LOD/occlusion - simulation reduces outside player view. Both a design principle and performance optimization.
    • Large, varied maps - locations should feel big. Achieved through procedural generation (templates + procedural flesh), trickery, or both. Procedural layouts also feed replayability.
    • Multiple maps - different locations are separate maps. Step through a wormhole, load a new place.
    • Wildly asymmetric encounters - balanced fights are the exception. Power mismatches in both directions are the norm and the source of tension.
    • Hubris wall as design principle - let the player feel powerful, then recontextualize their power level. Not cheap difficulty spikes - genuine "you were playing a smaller game than you thought" moments. The books are not always balanced; the fun comes when a character hits their hubris wall.
    • Death = information loss, not game over - memory cell backup system means death costs you everything since your last backup. Lost knowledge, lost relationships, lost deals. The storyteller knows what you've lost and can exploit it.
    • Scales with ascension - baseline human fights with weapons, Higher fights with biononics, ANA-connected fights with something else entirely. The pillar transforms as the character evolves.
  • Design references: Rimworld (real-time, simple systems, personal stakes), XCOM (tactical asymmetry, pod activation moments), Dwarf Fortress (z-levels, simulation depth)
  • Raised by: Team Leader (Jeroen), with input from full team.

D-009: Multiplayer - design for it, build single-player first

  • Date: 2026-02-08
  • Decision: Single-player is the build target. Multiplayer is designed into the architecture from day one so it can be added without rewriting the game.
  • Rationale: Two players experiencing the same conspiracy from different keyholes (Senate insider + Guardian operative) is a killer feature. But building multiplayer too early kills projects. The compromise: architectural decisions now that make multiplayer a "add networking" problem later, not a "rewrite everything" problem. Team Leader flagged that bolting multiplayer on after the fact is one of the hardest things to do - so the architecture must be honest about this from the start.
  • Cost: ~15-20% slower single-player development due to required abstractions. Accepted as cheap insurance.
  • Raised by: Full team discussion. Tyre led technical framing, Team Leader insisted on architectural honesty.

D-010: Multiplayer-ready architectural baseline

  • Date: 2026-02-08
  • Decision: Four non-negotiable architectural principles that must be present from the first line of code:
    1. Client-server separation - even in single-player. The game simulation runs as a "server," the player view is a "client." Single-player = local client + local server. This is the single decision that makes or breaks retrofitting multiplayer.
    2. Information boundaries as a first-class system - every piece of game state is tagged with who knows it. Not fog-of-war bolted on - the engine fundamentally thinks in terms of "what does this observer have access to." Required for single-player asymmetric information anyway. Multiplayer just means multiple observers.
    3. No baking player identity into the game loop - the simulation doesn't know there's "the player." It knows there are characters, some of which are player-controlled. Adding a second player-controlled character should be a configuration change, not a rewrite.
    4. Deterministic simulation with input events - game state advances based on timestamped actions, not "whatever the local machine calculated." Enables synchronization later without rewriting the simulation.
  • Side benefits (Nigel's observation): Every one of these makes single-player better too. Information boundaries make NPC AI smarter about what they know. Client-server makes save/load cleaner. Deterministic simulation makes debugging easier. No sacrifice.
  • Engine implication: Client-server friendliness is now a hard requirement on the engine shortlist (see Q-001).
  • Raised by: Tyre (Technical Architect), endorsed by Team Leader as "sound architectural baseline."

D-012: Chunk-based map architecture for future borderless generation

  • Date: 2026-02-09
  • Decision: Maps use chunk-based generation and loading from day one. Chunks load/unload around the player. A bounded map is "only generate chunks within this boundary." Removing the boundary later to enable Minecraft-style borderless generation is a configuration change, not a rewrite.
  • v0.1: Bounded ~150x150 per world, 2-3 z-levels, chunk-based internally.
  • Future: Borderless generation. The world generates as you explore. The map can never be "solved" by walking to every corner. New areas develop, existing areas change.
  • Rationale: Same principle as D-010 (multiplayer architecture) - design for the future, build the simpler version now.
  • Raised by: Tyre (Technical Architect), endorsed by Team Leader.
  • Amendment (2026-04-05): The ~150×150 visual tile estimate is superseded by D-094 (256×256 visual tiles per district, 4×4 blocks). D-094 explicitly amends this decision.
  • Amendment (2026-07-24): "The world generates as you explore" is now concretely the seed-to-tile cascade (D-227, D-239): Phase-5 in-world streaming derives the chunk neighborhood around the player (minimum the 3×3 chunks needed to draw the scene; prefetch radius a tuning constant) via the same derivation the Atlas ladder samples (D-255). Coarser-layer context is self-provided by function composition (D-255 (f), the cache-accelerated pure function) — Atlas interaction is never a precondition; a player arriving anywhere without ever opening the atlas gets the byte-identical world, and the step-canvas serving work's cache-hit==cache-miss determinism gate protects exactly this property. The Phase-4 epic (T-750) carries the matching deliverable note; the insert minimap (Q-093) is noted as a same-derivation consumer, design deferred to Phase 5.

D-020: Engine and architecture selection — Godot client + Rust simulation via subprocess/IPC

  • Date: 2026-02-09
  • Decision: The game uses a split architecture: Godot 4 (GDScript) as the rendering client, Rust with bevy_ecs standalone as the simulation server. The two communicate via subprocess/IPC (local socket for single-player, TCP for multiplayer). NOT via GDExtension.
  • Architecture:
    • The Rust simulation is a standalone binary with zero Godot dependencies. It runs the ECS world, perception queries, AI, storyteller, combat — all game logic.
    • The Godot client is a pure renderer: receives ObserverSnapshot data, draws tiles/sprites/fog, plays audio, shows UI, captures input. No game logic in GDScript.
    • Single-player: Godot launches the Rust binary as a child process. Local Unix socket or localhost TCP.
    • Multiplayer: Godot connects to a remote Rust server. Same protocol. The simulation binary doesn't know the difference.
    • This IS the D-010 client-server architecture — literally, not simulated.
  • Serialization:
    • MessagePack for all client-facing communication (Rust↔Godot). Dynamic structure supports variable HUD composition driven by perception modes (D-017). Cross-language, debuggable.
    • bincode reserved for future Rust↔Rust server-to-server sync (same binary, hot path, zero overhead).
    • protobuf rejected — solves deployment/versioning problems we don't have, poor GDScript support.
  • Why subprocess over GDExtension:
    • Eliminates entire risk categories: gdext pre-1.0 API churn, Godot version ABI breakage, FFI thread safety (Gd<T> is !Send), cross-boundary memory management.
    • Decouples learning: build and test Rust simulation standalone, build Godot renderer standalone, connect when both work.
    • Maps directly to D-010 client-server with no simulation — it IS client-server from day one.
    • Either side can be upgraded, replaced, or scaled independently.
    • Cost: ~1-5ms serialization latency per tick. Acceptable for a detective/strategy game, not a twitch shooter.
  • Key patterns:
    • ObserverSnapshot: the only data structure crossing the boundary. Contains visible entities, fog state, sound events, monologue triggers, HUD widget data. Variable shape per character build.
    • PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode), not raw key events. Timestamped for deterministic processing.
    • SimBridge trait: abstracts transport. LocalBridge (subprocess, channels) and NetworkBridge (TCP, MessagePack) implement the same interface.
  • Protocol versioning policy: PROTOCOL_VERSION gates wire format compatibility — field names, types, message structure. Bump when deserialization would fail (added/removed/renamed fields, new enum variants, changed types). Do NOT bump for gameplay parameter changes that affect what data flows through the same format (vision cone angles, NPC behavior, map layout, balance tuning). Per-character variation (D-015 cone config, D-017 perception modes) means these parameters differ between entities on the same server simultaneously — they are game state, not protocol. The client renders whatever ObserverSnapshot the server sends; it has no awareness of cone angles or perception mode configuration.
  • Kill switch: If no working prototype (character + fog + one NPC) exists by week 8 of development, pivot to pure Godot. If bridge/sync code exceeds game logic for 3 consecutive sprints, the architecture tax is too high.
  • Development sequence:
    1. Build Rust simulation as standalone binary (testable via terminal/logs)
    2. Build Godot renderer as standalone project (hardcoded test data)
    3. Connect via MessagePack protocol
  • Evaluation reports: docs/architecture/eval-godot-rust-bridge.md (Tyre), docs/architecture/risk-godot-rust-bridge.md (Troblum)
  • Raised by: Team Leader (Jeroen) proposed Godot client + Rust backend. Tyre designed architecture. Troblum's risk assessment shifted integration from GDExtension to subprocess/IPC. Full team endorsed.
  • Dissent: None. Troblum's CRITICAL risk flags on GDExtension were accepted; subprocess approach addresses them.

D-026: Simulation tiers with timestamp-based eviction

  • Date: 2026-02-10
  • Decision: Four simulation tiers: Active (30-80 NPCs, full sim at 10-20 ticks/sec), Background (500-2,000 NPCs, state machine ticks 1/game-minute with 4 machines: schedule, mood, relationships, job), State-saved (10,000+, frozen serialized structs ~1-2KB each), Ungenerated (doesn't exist yet). Eviction uses interaction-timestamp LRU against available sim-space. NPCs with active scope tags (neighborhood, active-quest, colleague, known-contact) stay fully simulated. State-saved NPCs reactivate on player return (~2-5ms). Density follows the player — content generated ahead of arrival, home system fully instantiated at game start.
  • Rationale: Timestamp-based eviction replaces categorical persistence rules with one priority queue. State-save makes disposal reversible. bevy_ecs dynamic component add/remove makes tier transitions seamless. Tyre confirmed all proposals fit within performance budgets.
  • Cross-reference: Content density implications in D-029.
  • Raised by: Team Leader (timestamp model), Tyre (technical validation), Gestalt (scope tags)
  • Dissent: None

D-030: Testability architecture — 8 decisions for ticket T-214

  • Date: 2026-02-11
  • Decision: The v0.1 testability architecture is confirmed with 8 sub-decisions from the Gap Analysis Workshop (Round 18):
    1. Rust test organization = Hybrid. #[cfg(test)] for unit tests inside modules + tests/ directory for integration tests. Both via cargo nextest run.
    2. Godot test framework = gdUnit4 (changed from GUT). Native JSON output, stable headless via GdUnitCmdTool, GdUnitSceneRunner for scene lifecycle tests, organizational maintenance.
    3. IPC testing = Three-layer architecture. Layer 1: fixture-based serialization roundtrip (fast, every edit). Layer 2: mock subprocess protocol state machine (medium, every PR). Layer 3: real subprocess integration (slow, daily/pre-merge).
    4. Production code constraints + CauseChain. No #[cfg(test)] in production. Public API is the test surface. ECS World setup replaces mock injection. CauseChain is a production component (monologue provenance, journal, debugging) that tests also leverage.
    5. Test runner tooling. cargo-nextest (Rust) + gdUnit4 (Godot) + bash wrapper scripts in test/ directory, whitelistable for agent use.
    6. Test output format = JSON summary. Consistent schema across all runners (suite, total, passed, failed, failures array). JUnit XML as secondary CI format.
    7. T-201 (Deterministic replay) promoted to CRITICAL. Simulation must consume time, randomness, and input exclusively through injectable resources (SimulationTime, SimRng, InputQueue). Required by D-010 principle 4.
    8. Test priority aligned with hard blockers. Phase 1 (sprint 1-2): test infra + collision/pathfinding/time. Phase 2 (sprint 3-4): monologue pipeline integration test + information boundary negative tests. Phase 3 (sprint 5+): CauseChain verification + divergent snapshots.
  • Rationale: Two rounds of analysis by Tyre (Technical Architect) and Hoshe (QA Engineer) with cross-validation from all design agents. Key change: gdUnit4 over GUT driven by agent-driven development requirements (JSON output, headless stability, bus factor). CauseChain endorsed unanimously after all design agents independently identified the need for information provenance tracking.
  • Raised by: Tyre (architecture), Hoshe (testability analysis). Full workshop endorsed.
  • Dissent: GUT vs gdUnit4 resolved in Hoshe's favor — Tyre explicitly changed position. No remaining dissent.

D-031: Time system — game clock and day phases

  • Date: 2026-02-11
  • Decision: The v0.1 time system uses the following model:
    • Tick-to-time mapping: 10 simulation ticks = 1 game-minute (at 10 tps, 1 real second = 1 game-minute). A 30-minute real-time play session covers ~12-18 game-hours — enough for a full NPC daily cycle.
    • Day phases: Four phases drive routine transitions: Morning, Afternoon, Evening, Night. NPCs transition between routine activities at phase boundaries (e.g., go to work in Morning, to the bar in Evening).
    • Time display: Diegetic — shown on the player's neural insert HUD. The character checks their insert to see the time, consistent with D-013.
    • Pause: Available in single-player. Simulation freezes, UI stays responsive. Compatible with future multiplayer (D-009) where pause would be disabled or vote-based.
    • Time-skip: Deferred for v0.1. The "wait/stake out" mechanic (if implemented) would advance time while the player observes from a fixed position.
  • Not in scope for v0.1: Deep time (years/decades), day/night lighting, seasonal cycles, time zones between locations.
  • Resolves: Q-009
  • Raised by: Tyre (technical proposal), Gestalt (day-phase design). Confirmed in Round 18 Gap Analysis Workshop with full team consensus.
  • Dissent: None.

D-041: Knowledge Graph Data Model

  • Date: 2026-02-11
  • Decision: The knowledge graph is a per-entity bevy_ecs Component with BTreeMap storage for deterministic iteration. Each entity that has knowledge (player character, Active-tier NPCs, Background-tier NPCs) gets a KnowledgeGraph component containing: (1) entity knowledge map: BTreeMap<StableId, EntityKnowledge>, (2) fact knowledge map: BTreeMap<FactId, FactKnowledge>. Knowledge confidence uses a 4-level hierarchy: Suspects < KnowsOf < KnowsDetails < Direct. Knowledge state tracks temporal/logical status: Active (believed true), Contradicted (conflicting information exists), Stale (aged beyond threshold). Knowledge source provides provenance per entry: DirectObservation, Heard, ToldBy, Inferred, Background. Stable entity IDs (StableId(u64)) replace bevy_ecs Entity handles in knowledge references, mapped via EntityRegistry resource for bidirectional StableId <-> Entity lookup. Knowledge updates flow through event-driven architecture: perception systems emit KnowledgeEvent to KnowledgeEventQueue resource, knowledge update system drains queue and writes to KnowledgeGraph components. Basic decay runs once per game-minute (every 10 ticks per D-031), downgrading confidence levels based on last_observed_tick age against configurable DecayThresholds.
  • Sprint 2 scope: Full data structures + direct observation flow + basic decay + observer snapshot integration (T-112). Deferred to Sprint 3+: NPC-to-NPC gossip, ToldBy/Inferred source generation, Contradicted state detection, Stale state logic, knowledge-driven dialogue filtering, monologue triggering, misinformation.
  • Canonical reference: Full Rust struct definitions at docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md Part 3 (lines 320-752). All implementation must conform to those types.
  • Key design choices:
    • BTreeMap over HashMap: D-010 principle 4 (deterministic simulation) requires stable iteration order. BTreeMap provides O(log N) lookups (~6-8 comparisons at N=50-200), deterministic serialization, and predictable replay behavior. HashMap iteration is non-deterministic and incompatible with deterministic replay (D-030 sub-decision T-7).
    • Per-entity Component, not centralized Resource: Enables Changed<KnowledgeGraph> dirty tracking, per-entity serialization for D-026 tier transitions, no shared mutable state, natural ECS query patterns.
    • 4-level confidence hierarchy: Resolves Q-016. Suspects = "something's off", gates initial investigation. KnowsOf = "X is involved in Y", gates topic-specific dialogue and peer-tier access (D-028). KnowsDetails = actionable detail, gates confrontation and secret-tier dialogue. Direct = currently in LOS, provides live position data and maximum rendering fidelity. Maps to D-028 access tiers: surface available at any level, real at KnowsOf+, secret at KnowsDetails+.
    • KnowledgeState for contradiction detection: THE FRIEND arc (D-034, D-039 wow moment T-3) requires detecting when a ToldBy entry conflicts with a DirectObservation entry. Both entries receive Contradicted state, triggering monologue event and relationship state shift (PersonOfInterest). Sprint 2 only uses Active state; contradiction detection ships Sprint 3.
    • StableId for knowledge references: Partially resolves Q-019 for server-side and knowledge graph purposes. Knowledge graphs reference StableId(u64) that persists across save/load cycles, not bevy_ecs Entity (generational index). EntityRegistry maintains bidirectional mapping. Assigned once at entity creation, never changes. Client-side mapping (Godot StableId -> scene node) remains open.
    • Event-driven updates: Phase 2 (perception) emits events. Phase 3 (knowledge) consumes events and writes graphs. Phase 4 (snapshot) reads graphs. Prevents mutable borrow conflicts in bevy_ecs.
  • Performance budget: ~14 KB per NPC knowledge graph (50 entities + 20 facts). Active tier (80 NPCs) = ~1.1 MB. Background tier (2,000 NPCs, 10 entries each) = ~5 MB. Total live memory: ~6 MB. Knowledge lookups are O(log N) at N=50 (~100ns per query). Not on critical path (shadowcasting/spatial queries consume 10-20ms per tick, knowledge operations <3ms).
  • Resolves: Q-016 (knowledge hierarchy). Partially resolves Q-019 (entity ID stability, server-side).
  • Blocks: T-352 (Observer Snapshot Pipeline Workshop)
  • Raised by: Tyre (architecture synthesis), Dudley (implementation analysis), Gestalt (mechanics validation), Paula (narrative requirements). Workshop participants: Tyre, Dudley, Gestalt, Paula, SI. Source: Knowledge Graph & Information Boundaries Workshop (Epic T-351), 2026-02-11.
  • Dissent: None. Gestalt initially proposed dual-axis model (confidence + understanding) but validated that single-axis confidence with future content-driven understanding progression is architecturally sufficient. Dudley proposed HashMap; synthesis chose BTreeMap per D-010 principle 4 with Dudley's explicit acknowledgment ("iteration order is a determinism time bomb").

D-042: UI microcopy format — YAML via GDScript autoload

  • Date: 2026-02-13
  • Decision: UI strings (interaction prompt labels, knowledge panel labels, relationship state descriptors, HUD labels, tutorial text) are stored in YAML format at client/data/ui-strings.yaml and loaded via a dedicated GDScript autoload singleton (UIStrings). UI strings are NOT hardcoded as GDScript constants in client/scripts/constants/ui_strings.gd.
  • Rationale: YAML format enables editing UI text without rebuilding the client and supports future localization infrastructure (all player-facing text in one format). UI microcopy is client-side rendering data per D-020 (Godot is the renderer) — distinct from server-side game content (dialogue/monologue lines). UI labels are presentation metadata that never cross the protocol boundary, so they live in the client repository and load via a client-side autoload rather than the content loader system. Hardcoded constants would require client recompilation for copy edits.
  • Related ticket: T-409 (UI microcopy)
  • Raised by: Team decision in Sprint 5 planning
  • Dissent: None

D-054: Tile-based movement with same-tile occupancy

  • Date: 2026-02-13
  • Decision: All movement is tile-based (server-authoritative, discrete positions). Client-side Tween interpolation (100-150ms) hides the grid visually. Same-tile occupancy via TilePresence component (Standing/Prone/Seated/Fixture layers) allows multiple entities on one tile in different postures. Mouse facing is a client-side float; the server receives the facing octant only. Tile occupancy provides trivial collision detection.
  • Rationale: Determinism (D-010 principle 4). Tile-based enables shadowcasting (D-238), pathfinding, chunk-based maps (D-012), and trivial collision. Occupancy system adds positioning depth (doorway blocking, eavesdrop positioning, sitting at furniture) within tile-based constraints. ~150 lines server-side.
  • Implementation: TilePresence enum: Standing, Prone, Seated, Fixture. Multiple entities can share a tile if they occupy different posture layers.
  • Cross-reference: Stance system (D-053), shadowcasting (D-238)
  • Source: Control & Interaction Workshop (2026-02-13)
  • Raised by: Tyre (tile-based, non-negotiable), Dudley (tiles-per-tick model), Nigel (converted in Round 2: "tiles are BETTER for replayability — discrete positions = finite meaningful choices")
  • Dissent: Nigel initially proposed free movement with tile-based collision (Round 1). Converted in Round 2 after demonstrating that tile-based spatial puzzles (doorway decisions, corner peeks, eavesdrop corridors) create replayability.
  • Amendment (2026-07-06, T-1088): the "client-side Tween interpolation (100150 ms)" sentence is scoped to the 2D renderer henceforth. A fixed-duration tween conflicts with the D-053 stance cadences (200800 ms/step) — dash-then-stand stutter at every stance below Sprint. The 3D presentation layer interpolates per D-248 (per-leg constant velocity keyed to the stance throttle). Everything else in this record (server-authoritative discrete tiles, octant-only facing on the wire, TilePresence occupancy) is unchanged and remains binding for both renderers.

D-055: Sprint explicitly suppresses interaction buffer

  • Date: 2026-02-13
  • Decision: When in Sprint stance (D-053), the server explicitly clears the interaction buffer. No interaction verbs are computed or sent to the client during sprint. Anomaly monologue survives sprint — the "sprint double-take" (if the character passes something anomalous while sprinting, a delayed monologue fires retroactively: "Wait — was that Kael? At this hour?").
  • Rationale: Mouse gymnastics to click during sprint = bad UX. Explicit suppression is cleaner and deterministic. The sprint double-take preserves the feel that the character is still aware even when the player can't interact — sprint suppresses interpretation (monologue at 40%), not sensory data (overlays still render).
  • Cross-reference: Stance system (D-053), monologue (D-016)
  • Source: Control & Interaction Workshop (2026-02-13)
  • Raised by: Dudley (explicit suppression), Ozzie (anomaly survival / double-take), Gestalt (interpretation vs data framing)
  • Dissent: Gestalt argued physics handles it naturally (player passes through interaction radius too fast to click). Lead ruled explicit suppression for clarity and determinism.

D-066: Dual-scale grid — 0.5m simulation, 1m visual (2x retina factor)

  • Date: 2026-02-14
  • Decision: The game uses two coordinate scales with a fixed 2x retina factor:
    • Simulation grid: 0.5m tiles. All movement, LOS/shadowcasting (D-238), pathfinding, occupancy (D-054), and interaction range operate at 0.5m per sim tile. The server knows only sim tiles.
    • Visual grid: 1m tiles. The Godot client renders floor art, wall art, and structural tiles as 2x2 blocks of sim tiles. Art is authored at 1m conceptual scale.
    • World geometry: 2x2 sim tile minimum. All walls, furniture, crates, doors, and environmental objects occupy a minimum of 2x2 sim tiles (= 1 visual tile). This ensures visual truth and sim truth agree on where solid things are — cover, LOS occlusion, and collision map 1:1 with what the player sees.
    • Entities: 1x1 sim tiles. Characters and small items occupy individual 0.5m sim tiles, giving sub-visual-tile positioning precision. Entities naturally take corners/edges within a visual tile's 1m space.
    • Sprites: 2x2 sim tile footprint. Entity sprites render across 2x2 sim tiles so they feel proportional to the 1m visual grid. Tween interpolation (D-054) hides half-visual-tile movement increments.
  • Mental model: "Objects are where they look. I can position myself precisely within open space." The player reads cover and walls at visual scale (always correct). Fine movement granularity is felt, not counted.
  • What the simulation does NOT know: Visual tiles. The retina factor is purely a client rendering convention. The server operates exclusively on 0.5m sim tiles.
  • Amendment (2026-05-22, D-222): canonical vocabulary. This 0.5m "sim tile" is now the Subtile — the granularity the server simulation, entity positioning, and render detail run on. The Tile = 1m (2×2 subtiles) is the unit sizes are quoted in and the one the world-generation cascade, pathfinding, and grids operate on. The dual-scale model and the 0.5m simulation granularity are unchanged; only the naming primacy flips. The Chunk/Block/Quarter/District ladder built on the Tile is canonical in D-222.
  • Fog shader (D-059): Unaffected — fog is screen-space, driven by PointLight2D vision cone and LOS mask from sim-resolution shadowcasting. Gradient edge "3-4 tiles" is retuned to 6-8 sim tiles (= 3-4 visual tiles) to preserve the intended softness.
  • Cursor/interaction: No change — cursor already resolves to sim tile from pixel position. Interaction range of ~2 sim tiles = 1m (arm's length).
  • Map authoring: Author at 1m visual scale. Subdivision tool expands each visual tile to 4 sim tiles (2x2). Validation enforces 2x2 minimum on all world geometry layers.
  • Amends: OQ-01 resolution (ticket T-444). Sim tile size remains 0.5m; visual presentation changes from 1:1 to 2:1 retina factor.
  • Amendment (2026-06-17): The dual-scale coordinate model (0.5 m Subtile / 1 m Tile) and the 2×2-sim-tile geometry minimum are unchanged and load-bearing for the 3D architecture — cover/LOS/collision still map 1:1 to what the player sees. The "Sprites: 2×2 sim tile footprint" bullet and the "sprites look right" rationale are pre-Sprint-28 sprite-era: in-world entities/objects are now rendered as 3D (D-149 / D-244), so there is no pixel sprite footprint — the 2×2 figure persists only as the visual-tile proportionality the 3D entity occupies. The spatial math survives; only the rendering technology changed.
  • Cross-reference: Tile-based movement (D-054), shadowcasting (D-238), fog (D-059), art direction (D-043), z-stack (D-049), stance system (D-053)
  • Rationale: 0.5m sim tiles give stealth-grade granularity for movement stances, cover peeking, and interaction range. 1m visual tiles make spaces feel proportional, sprites look right, and world geometry readable. The 2x2 minimum on geometry eliminates visual/sim mismatch for cover and LOS — the only sub-visual-tile positioning is entity movement, which is communicated through fog feedback, not tile counting. Analogous to macOS Retina: logical resolution (visual) differs from physical resolution (sim), but the system is coherent because both agree on where solid objects are.
  • Raised by: Team Leader (Jeroen) — proposed retina scaling analogy and 2x2 geometry constraint. Tyre (feasibility: trivial, half-day integration). Gestalt (approved with 2x2 constraint resolving LOS readability concern). Ozzie (approved: solves sprite scale without uncanny mismatch).
  • Dissent: Gestalt initially objected to dual-scale (mental model mismatch for cover/LOS). Resolved by the 2x2 geometry minimum constraint — all cover maps 1:1 at visual scale.

D-068: 5-bus audio architecture

  • Date: 2026-02-16
  • Decision: Audio uses a 5-bus architecture with player-facing volume sliders:
    1. Music — future/empty in v0.1. Reserved for diegetic Meridian music in social spaces.
    2. Ambient — station hum (D-038 asset 1) + zone overlays (assets 2-4). Continuous soundscape.
    3. World SFX — NPC footsteps, doors, environmental events. Diegetic world sounds not caused by player.
    4. Player Actions — player footsteps (assets 5-6), future: combat sounds, item interactions. Sounds player directly causes.
    5. UI Sounds — cursor hover, implant open, monologue chimes (assets 7-8), fog recognition. Interface feedback.
  • Client implementation:
    • AudioManager GDScript autoload singleton on client branch (lives with rendering, per D-020).
    • Audio assets committed to audio branch (content, not code).
    • Directory-scan registry pattern: AudioManager scans res://audio/ on startup, maps filenames to AudioStream resources. No hardcoded asset list.
    • If directory empty or asset missing, all play methods no-op with visual fallback (per D-038 architecture).
    • 5 player-facing volume sliders, one per bus. Accessible via settings.
  • Bus routing dropped:
    • Dialogue bus removed — no voice acting in v0.1. NPC conversation murmur goes on World SFX (event-driven, per D-072).
  • Rationale: 5 buses provide player control granularity (disable UI sounds, boost World SFX for eavesdropping, mute Ambient for focus) without over-segmentation. Directory-scan registry eliminates hardcoded asset paths — audio branch can add files without touching client code. No-op fallback means client works identically with or without audio.
  • Cross-reference: Audio assets (D-038), audio dip (D-069), client-server architecture (D-020)
  • Raised by: Team Leader (channel split directive), Tyre (architecture), Inigo and Gestalt (bus refinement)
  • Dissent: None

D-073: Zone crossfade approach — hard boundary, soft audio transition

  • Date: 2026-02-16
  • Decision: Zone audio transitions use hard tile boundary triggers with 1.5-2s audio crossfade tweens. Server sends zone_id per tile in ObserverSnapshot (server-authoritative zone assignment). AudioManager receives zone changes and tweens between ambient layers. No blended overlap zones — the transition smoothness comes from audio fade duration, not spatial blending.
  • Implementation: AudioManager stub in Sprint 7 (5-bus setup, directory registry). Full zone crossfade implementation deferred to Sprint 8+.
  • Rationale: Hard boundaries with soft audio = predictable for simulation, pleasant for player. Avoids complex overlap zone geometry. Crossfade duration (1.5-2s) is long enough to feel smooth, short enough that walking back-and-forth across boundary doesn't create audio chaos.
  • Cross-reference: Audio architecture (D-068), client-server (D-020), ambient assets (D-038)
  • Raised by: Tyre
  • Dissent: None

D-085: Per-game save directory structure

  • Date: 2026-02-25
  • Decision: Every new game creates a dedicated directory under the user save path. All saves for that game (manual, quicksave, autosave) live inside the game's directory. Directory name includes a human-readable game identifier and creation timestamp.
  • Rationale: Natively groups saves by game without requiring a database or index file. Players can browse, back up, or delete game saves at the filesystem level. Avoids a flat save folder where 50+ files from different games are interleaved.
  • Structure: user://saves/<game-id>/ where <game-id> is <timestamp>-<seed> (e.g., 20260225-143022-a7b3f1/). Inside: quicksave.sav, autosave.sav, manual_001.sav, etc.
  • Constraints:
    • Game directory created on "New Game" — even before the first save, so the path exists for quicksave/autosave.
    • F5 = quicksave (overwrites quicksave.sav in the active game dir).
    • F6 = quickload (loads quicksave.sav from the active game dir).
    • Loading screen lists game directories sorted by last-modified, shows most recent save per game.
  • Raised by: Team Leader (Jeroen)
  • Dissent: None

D-088: 3-state pause system — Normal/Overlay/Paused, server-authoritative

  • Date: 2026-02-12
  • Decision: Simulation runs at three speed states: Normal (100% tick rate), Overlay (50% — active during knowledge panel, dialogue, map view), Paused (0% — full pause via Esc). Server is authoritative: client sends pause requests, server sets sim_speed field in ObserverSnapshot. Client reads sim_speed and adjusts presentation. No client-side tick manipulation.
  • Rationale: Server-authoritative speed states preserve D-010 principle 4 (deterministic simulation). Client cannot modify simulation state directly. Overlay mode at 50% ensures UI interactions do not require a hard pause while still giving the player time to read and decide.
  • Raised by: Tyre, Dudley
  • Dissent: None
  • Source: v0.1 Content Scoping Workshop, closing round resolution
  • Cross-reference: D-031 (time system), D-020 (client-server architecture)

D-094: District Spatial Hierarchy — Chunk, Block, District Naming and Sizes

  • Date: 2026-02-25
  • Decision: The spatial hierarchy for map generation and streaming is defined as follows. Chunk = 64×64 sim tiles (32×32 visual tiles, 32m) — the streaming and serialization unit. Block = 128×128 sim tiles (64×64 visual tiles, 64m) — the generator planning unit, composed of 4 chunks arranged in a 2×2 grid. Each block contains 4 chunks; chunks within a block can merge into one large edifice, remain separate (small buildings, gardens, cafes), or form L-shaped buildings across chunk boundaries. District = 4×4 blocks = 512×512 sim tiles (256×256 visual tiles, 256m) per z-level, containing 16 blocks and 64 chunks. Large civic structures (gate terminals, horizon station installations, stadiums, parks) span multiple blocks. Three z-levels for the Transit District = ~1.35MB (trivial). This decision amends D-012 and overrides the ~150×150 visual estimate in D-014.
  • Amendment (2026-05-22, D-222): naming + scale superseded. The unit this record calls a District (4×4 blocks) is renamed the Quarter (now 512m at Tile = 1m); District is promoted to a new 2048m tier (4×4 quarters). Tier counts and nesting structure here are unchanged — only the names and the metre values (per the Tile = 1m / Subtile = 0.5m scale of D-220). See D-222 for the canonical ladder and the lore-vs-code rule.
  • Rationale: Chunk size of 32×32 visual (64×64 sim) gives a 32m streaming cell — large enough to hold a meaningful space, small enough for efficient streaming. The 2×2-chunk block provides a generator planning unit with enough granularity for per-chunk variation. The 4×4 block district (256×256 visual) gives a full district footprint generalisable as a template for the Q-036 generator. The chunk-based fill system within blocks allows the generator to place buildings of varying scale without hard-coding building dimensions.
  • Raised by: Tyre (chunk/block spec and memory confirmation), confirmed by team. Lead ratified district = 4×4 blocks.
  • Dissent: Araminta preferred 32×32 visual chunk size (effectively halving the chunk to a 16m cell). Overruled by lead and team majority — 32m chunk is the minimum viable streaming cell for the simulation architecture.
  • Source: Station District Layout Workshop, Ticket T-153, Sprint 20. Round document: docs/discussions/round-20-station-district-layout.md
  • Cross-reference: D-012 (tile spec — amended), D-014 (v0.1 map spec — district bounding box superseded), D-066 (dual-scale grid), D-093 (Sova Transit District layout using this hierarchy), Q-036 (district generator)

D-096: DistrictLayoutMode — Grid and Organic Support

  • Date: 2026-02-27
  • Decision: Two layout modes coexist for district generation. Grid: Commission-planned districts with rectilinear block placement. Organic: pioneer/growth districts with block offsets (±16 sim tiles per axis), rotation (03 steps, 15° increments), variable street width (0.752.0×). Hard technical ceiling: maximum rotation ±45°. Beyond 45°, tile-based pathfinding produces unacceptable movement artifacts. Organic districts produce curved-street impressions through angular jogs and irregular setbacks. Grid vs. Organic proportions must vary per seed to prevent predictable meta-level patterns.
  • Rationale: Grid = power imposed (Commission-planned). Organic = power negotiated (pioneer settlements, organic growth). Both modes encode political and settlement history in spatial form.
  • Source: Generator Architecture Workshop (T-562), 2026-02-27. Full spec: docs/workshops/generator-architecture/workshop-outcomes.md §D-READY-1.
  • Raised by: Tyre (technical architecture), Miri (cultural grammar). Full team sign-off.
  • Dissent: None.
  • Cross-reference: D-094 (spatial hierarchy), Q-036 (district generator)

D-097: Guarantee Tier System — Universal / Full-Only / Conditional

  • Date: 2026-02-27
  • Decision: The district generator runs a GuaranteeAuditResult with three tiers of spatial guarantees. Tier 1 — Universal (all inhabited): Social Hub, Informal Zone, Encounter Corridor. Tier 2 — Full-complexity: Traffic Chokepoint, Institutional Space, Insider Space, Economic Node, Horizon View Corridor (coastal), BreachOnly Zone (≥1), Rooftop Discovery Zone (tall structures). Tier 3 — Conditional: A-1 Elevated Vantage, A-2 Egress Multiplicity, A-3 Temporal Opacity Window, A-4 Non-Institutional Route, Economic Asymmetry Signal, Power Gradient Visibility. A Full-complexity coastal urban hub gets up to 13 checks. Archetype placement must vary in angular position (not just distance) across seeds — audit fails if archetypes cluster predictably across a test batch of N seeds.
  • Rationale: The generator makes contracts it keeps. Guaranteed affordances ensure every playstyle has spatial affordances in any district, without hand-crafting each location.
  • Source: Generator Architecture Workshop (T-562), 2026-02-27. Full spec: docs/workshops/generator-architecture/workshop-outcomes.md §D-READY-2.
  • Raised by: Gestalt (tier structure + assassin lens integration), Tyre (GuaranteeAuditResult struct). Full team sign-off.
  • Dissent: None.
  • Cross-reference: D-103 (assassin lens guarantees A-1 through A-4), D-102 (horizon view corridor — Tier 2 coastal)
  • Amended 2026-05-25 (D-229/D-231 — guarantee-audit engine hook): The audit runs AFTER step-3 fill completes for a district, before the skeleton is marked "complete"; on a failed mandatory check, fill regenerates with adjusted parameters. The mandatory-check set is keyed on ComplexityTier, not WorldTier (T1 = all inhabited; T2 and T3/A-1..A-4 = Full only). Signature: run_guarantee_audit(skeleton: &DistrictSkeleton, building_tags: &[BuildingPropertyTag], street_graph: &StreetGraph, seed: SeedChain) -> GuaranteeAuditResult. Tier-1 checks (all inhabited): social_hub_present (≥1 building with entry_class ∈ {Public, Commercial} AND zone ∈ entertainment/market/transit); informal_zone_present (≥1 Organic + Public, or open ground in an Organic district); encounter_corridor (street graph has a continuous Public traversal path crossing the district). Tier-2 checks (full-complexity): traffic_chokepoint (a bottleneck node whose removal disconnects the graph); institutional_space (admin/judicial/checkpoint, Restricted); insider_space (Restricted + Hidden or non-primary-street door); economic_node (≥1 Commercial); breach_only_zone (≥1 BreachOnly). Tier-3 / A-1..A-4 (pure graph/geometry over step-3 tags + street graph — NO interior generation): A-1 elevated vantage (max above_ground ≥3 with unobstructed LOS to a chokepoint); A-2 egress multiplicity (≥2 distinct unique-street connections across the perimeter); A-3 temporal opacity (≥1 TemporalWindow door → reduced traffic in closed hours); A-4 non-institutional route (a Public→insider-space path exists that avoids all institutional buildings). GuaranteeAuditResult gains a bool per check above, retaining its existing rooftop_discovery_zone (D-106) and horizon_view_corridor (D-102) fields. Raised by: Gestalt, economic-built-world workshop round 2.

D-099: WallBackside / TileBehindState — Dual Classification

  • Date: 2026-02-27
  • Decision: Two complementary enums classify tiles behind wall surfaces. WallBackside (structural): what is physically there — AdjacentSpace | StructuralFill | ServiceVoid | ChunkBoundary | Exterior. TileBehindState (gameplay): what kind of space this represents — StructuralFill | HiddenRoom | Interstitial. Mapping: ServiceVoid → Interstitial; AdjacentSpace → HiddenRoom or StructuralFill depending on access tier. Era-tagged infrastructure cavity contents with standardized color codes: Era 1 power conduit only (#c8b840), Era 2 power + water/coolant (#4888c8) + comm lines (#b8b8b8), Era 3 full bundle. Backside assignments within a template must have seed-driven variation — not fixed template values.
  • Rationale: "Every wall is a secret keeper." No tile is ever void. Dual classification separates structural truth (what's there physically) from gameplay meaning (what does this imply for the player's investigation).
  • Source: Generator Architecture Workshop (T-562), 2026-02-27. docs/workshops/generator-architecture/workshop-outcomes.md §D-READY-4.
  • Raised by: Tyre (WallBackside), Gestalt (TileBehindState). Full team sign-off.
  • Dissent: None.

D-100: Dynamic Modification via Overlay — DamageOverlay and RegenerationStrategy

  • Date: 2026-02-27
  • Decision: Generator output is immutable after Phase 1. All post-generation modifications are applied via overlay, not re-generation. DamageOverlay struct: overlay_type (GasExplosion | Fire | Structural { collapse_direction } | Flooding), epicenter: ChunkLocalPos, radius: f32, intensity: f32, scatter_seed: u64 (variation within zone only). RegenerationStrategy enum: LocalOverlay(DamageParameters) for in-playthrough events (MANDATORY), SoftReseed { seed_modifier: u64 } at scenario boundaries only, FullReseed at era-level discontinuities only. Trauma event → visual stage mapping: PhysicalDestruction/ViolenceEvent → Stage 2 (Fresh Aftermath), decays to Stage 3; EconomicDisruption/PoliticalShock/MigrationShock → quarter fill modifier. Full stage sequence: Stage 1 Active → Stage 2 Fresh Aftermath → Stage 3 Stabilized → Stage 4 Reconstruction → Stage 5 Healed Scar. Destruction palette is corruption-only: no new colors introduced by destruction. Single exception: #c8d8f0 open-sky tile appears when a roofed structure has its roof removed. See D-109 for the XOR prohibition as architectural mandate.
  • Rationale: Modification history diverges per playthrough on the same seed. Same world, different event histories, different delta layers — this is the replayability engine. Causal legibility requires the player to be able to read what happened from the world state.
  • Source: Generator Architecture Workshop (T-562), 2026-02-27. docs/workshops/generator-architecture/workshop-outcomes.md §D-READY-5.
  • Raised by: Tyre (structs), Gestalt (LocalOverlay mandate). Destruction stages and palette constraint: Araminta (Round 5).
  • Dissent: None.
  • Cross-reference: D-109 (XOR prohibition as architectural mandate), D-107 (trauma events — cultural track)

D-101: ZonePalette Modifier System

  • Date: 2026-02-27
  • Decision: Zone palettes use ZonePalette { base: BasePalette, modifiers: Vec<PaletteModifier> }. Eight canonical base terrain types: T1 temperate farmland (warm organic, natural lighting) / T2 industrial farmland (cool grey-green, artificial lighting) / T3 wilderness / T4 grassland / T5 coastal water (deep near-black blue, animated specular; referenced by D-102 horizon corridor guarantee) / T6 beach/coastal margin (warm dark tan) / T7 mountain/high terrain (dark blue-grey stone, snow at elevation) / T8 desert/arid. T1 and T2 are explicitly distinct farmland types. Additional terrain types must be specified with new numbers — not silent replacements for existing types. Modifier axes: A (heritage root → material character), B (economic tier → condition/density), C (era → material generation), plus faction overlay, climate, condition, season. Palette modifiers influence NPC appearance as well as environment (people dress like they're from here).
  • Rationale: A zone's visual identity must be legible at a glance. Palette modifiers create cultural visual identity without rewriting base terrain.
  • Source: Generator Architecture Workshop (T-562), 2026-02-27. docs/workshops/generator-architecture/workshop-outcomes.md §D-READY-6.
  • Raised by: Araminta (terrain types and color specs, canonical T5/T7 numbering corrected Round 5), Tyre (palette struct). Full team sign-off.
  • Dissent: None.
  • Cross-reference: D-102 (horizon view corridor — T5 coastal water is the referenced terrain type), D-104 (heritage grammar overlay — modifier axis A)
  • Superseded (partial): Modifier axis A (HeritageRoot) superseded by D-167 (2026-03-24). Cultural palette modifiers are now authored per-system via the corridor framework; abstract heritage root IDs no longer drive axis A.
  • Modifier axis A (HeritageRoot) superseded 2026-05-25 by D-232 → replaced by FlavorTagFilter; axes B/C and faction/climate/condition/season unchanged.

D-102: Horizon View Corridor as Coastal Guarantee

  • Date: 2026-02-27
  • Decision: A negative-space reservation for coastal districts: ≥8 visual tiles unobstructed view corridor from nearest public street to water's edge. No building, tree, or z=4 element may occupy this corridor. A low z=2 element (railing, bench, bollard) marks the waterfront point as a designed viewing location. Tier 2 Conditional guarantee — applies to Full-complexity coastal districts. Position within the district must vary per seed; the Wow Moment of seeing the horizon must be discovered, not expected.
  • Rationale: "Negative-space reservation" framing — the generator reserves space by prohibiting placement, not by placing something. The view of the horizon is a spatially guaranteed player experience.
  • Source: Generator Architecture Workshop (T-562), 2026-02-27. docs/workshops/generator-architecture/workshop-outcomes.md §D-READY-7.
  • Raised by: Araminta (visual grammar and negative-space framing), Tyre (implementation constraint). Full team sign-off.
  • Dissent: None.
  • Cross-reference: D-097 (guarantee tier system — Tier 2), D-101 (ZonePalette — T5 coastal water is the terrain type this guarantee references)

D-103: Assassin Lens Spatial Guarantees — A-1 through A-4

  • Date: 2026-02-27
  • Decision: Four derived spatial properties validated by the guarantee audit for Full-complexity districts. These are derived properties of existing spatial configuration, not assassin-tagged features — they add no generation cost; the audit validates existing output. A-1 Elevated Vantage (Tier 3): ≥1 position with clear LOS cone to Traffic Chokepoint. A-2 Egress Multiplicity (Tier 3): ≥2 exit routes to adjacent districts. A-3 Temporal Opacity Window (Tier 3): ≥1 time window where Social Hub has reduced ambient NPC coverage. A-4 Non-Institutional Route (mandatory Full-complexity): ≥1 route to any Insider zone not passing through high-security institutional spaces. A-1/A-2/A-3 are Tier 3 Conditional (trigger on complexity_tier == Full). A-4 is mandatory for all Full-complexity districts regardless of playstyle.
  • Rationale: The investigator/assassin playstyle needs guaranteed affordances without the generator explicitly building for assassination. Derived properties keep generation cost zero while ensuring spatial conditions exist.
  • Source: Generator Architecture Workshop (T-562), 2026-02-27. docs/workshops/generator-architecture/workshop-outcomes.md §D-READY-8.
  • Raised by: Gestalt (assassin lens framing and derived-properties insight). Full team sign-off.
  • Dissent: None.
  • Cross-reference: D-097 (guarantee tier system — Tier 3)

D-106: Vertical Scale Architecture and Rooftop Bar Clause

  • Date: 2026-02-27
  • Decision: Four height tiers: S1 (12 z-levels, surface + roof/mezzanine), S2 (310), S3 (1130), S4 (30+). Shadow length is the primary height signal in top-down view (240 visual tiles). Lazy z-level loading: ZLevelLoadState: Loaded | Skeleton | Ungenerated — only current + adjacent z-levels filled by Phase 2. Rooftop Bar Clause: Every tall structure (z_band_count ≥ 3) must assign RooftopConfig: Restricted | PublicWithHiddenLayer. Discovery layer mandatory in both configurations. Heritage root weights the probability between the two configs — it does not determine the outcome. Final config is seeded per-building; a minority of buildings of any heritage root may be the non-dominant type (a Frost building with a rooftop bar must be possible). Z-band floor boundaries must have seed-variation within cultural ordering constraints. Vertical access routes are playthrough-history dependent.
  • Rationale: Height has meaning — floor 30 has information floor 1 cannot have because it is harder to reach. Full determination of rooftop config by heritage root kills the discovery moment.
  • Source: Generator Architecture Workshop (T-562), 2026-02-27. docs/workshops/generator-architecture/workshop-outcomes.md §D-READY-11.
  • Raised by: Tyre (z-level architecture), Ozzie (Rooftop Bar Clause — discovery guarantee). Ozzie + Araminta corrected "determines" → "weights probability" in Round 5.
  • Dissent: None.
  • Cross-reference: D-094 (spatial hierarchy), D-097 (guarantee tier system — Rooftop Discovery Zone is Tier 2)

D-108: MobileChunk Specification

  • Date: 2026-02-27
  • Decision: Entity-carried interior space attached to a mobile world entity. Not a district — uses the same chunk fill primitives in a simpler flat structure (no Phase 1/Phase 2 split, no block grid, no zone negotiation). Key structs: MobileChunk, MobileInterior, VesselClass, MobileMovementState (Docked / InTransit / InterSystem / Idle), TransitSocialModifier, MobileNpcSlot, NpcPersistence (Crew | Passenger). Idle = vessel parked at a location but not docked to infrastructure (anchored ship, grounded shuttle). Vessels are persistent world entities — interior cache keyed by entity_id persists across voyages for crew state. Docked state requires dock_position, connected_chunk: Option<ChunkCoord>, docked_since: SimTick, scheduled_departure: Option<SimTick>. scheduled_departure must be populated by the generator; vessels without departure schedules are an error state. Cultural grammar: TransitSocialModifier with TransitVariant (BoundedLinear | BoundedMobile | InterSystem). Vessel visual grammar (5 rules): (1) hull uses vessel-identity material, not zone palette; (2) windows reveal exterior context (docked vs. transit); (3) compression modifier tightens proportions; (4) section transitions use vessel-identity threshold elements; (5) class stratification via proportion, not palette. Replayability requirements R-V-1 through R-V-6 in docs/workshops/generator-architecture/round-4-notes.md §5. Memory: ~0.54KB metadata + up to 64KB ChunkData per vessel; paged by streaming model.
  • Rationale: "The journey is content — mobile environments are social pressure cookers, not loading screens with chairs." Vessel persistence and crew state continuity make the world feel real.
  • Source: Generator Architecture Workshop (T-562), 2026-02-27. docs/workshops/generator-architecture/workshop-outcomes.md §D-READY-13.
  • Raised by: Tyre (struct design), Miri (cultural grammar — miri-round4.md), Nigel (replayability requirements), Ozzie (player experience). Visual grammar: Araminta (araminta-round4.md §2).
  • Dissent: Nigel initially proposed instanced districts for vessels; lead ruled entity-carried MobileChunk for persistence.
  • Note: The Idle movement state is the canonical primitive for player-owned stationary installations (space stations, orbital platforms, parked vessels as permanent bases). A MobileChunk in Idle with no scheduled_departure is architecturally equivalent to a static chunk from the simulation's perspective — it participates in the same tile/zone system. This design prevents future over-engineering of a separate 'player installation' system.
  • Cross-reference: D-100 (DamageOverlay applies to vessel damage), D-109 (LocalOverlay mandate), D-111 (MobileChunk Idle state as stationary installation primitive), Q-046 (departure schedule — resolved by this D-record)

D-109: DamageOverlay / RegenerationStrategy Prohibition — Architectural Mandate

  • Date: 2026-02-27
  • Decision: XOR reseeding for in-playthrough events is architecturally prohibited. LocalOverlay is the mandatory modification strategy for all events that occur while the player is present. SoftReseed and FullReseed are permitted only at scenario-boundary and era-level discontinuities respectively — events the player was not present for, where causal legibility is not required. This prohibition is filed as a separate D-record from D-100 because it establishes the modification principle for the entire game, not just the overlay mechanics.
  • Rationale: Causal legibility: the player must be able to look at a damaged district and understand what happened. XOR reseeding destroys the causal thread. Unanimous consensus across all workshop participants — the strongest architectural agreement of the entire workshop.
  • Source: Generator Architecture Workshop (T-562), 2026-02-27. docs/workshops/generator-architecture/workshop-outcomes.md §D-READY-14.
  • Raised by: Gestalt (XOR prohibition framing), Tyre (RegenerationStrategy struct). Unanimous.
  • Dissent: None.
  • Cross-reference: D-100 (DamageOverlay + RegenerationStrategy full specification)

D-110: Signed Z-Level Addressing — base_z u8 → i8

  • Date: 2026-02-27
  • Decision: All z-level base fields use signed integers (i8) instead of unsigned (u8). Specifically: MultiBlockReservation.base_z: i8, FloorZone.z_level: i8, and any struct that references a z-level position (not a count). Z-level counts (z_levels: u8 on DistrictSkeleton) remain unsigned — they represent "how many floors", which is always positive. The distinction: base_z is "where does the bottom floor start" (can be negative for basements/subterranean spaces), z_levels is "how many floors total" (always ≥1).
  • Rationale: The Round 4 workshop designs (D-106, D-108) and the gestalt gas explosion example explicitly assume negative z-levels (basements at z=-1 to z=-3, utility sub-levels). The existing u8 type contradicts the design intent. A deep mine is structurally an inverted skyscraper with base_z: -30, z_levels: 30. Lazy z-level loading (ZLevelLoadState) works identically for negative z — the loading system cares about relative offsets between adjacent levels, not the sign.
  • Source: Tyre architectural analysis, 2026-02-27. Confirmed existing Round 4 design intent (D-106 §height tiers, Gestalt Round 4 §gas explosion, Araminta Round 2 §infrastructure routing).
  • Raised by: Tyre (type-level fix), lead (edge case prompt: basements, deep mines).
  • Dissent: None.
  • Cross-reference: D-106 (vertical scale architecture), D-094 (spatial hierarchy), D-108 (MobileChunk)

D-111: MobileChunk Idle State Covers Stationary Player Installations

  • Date: 2026-02-27
  • Decision: The MobileChunk Idle movement state (D-108) is the canonical primitive for player-owned stationary installations — space stations, orbital platforms, parked vessels used as bases, or any persistent interior space the player controls that is not part of the district chunk grid. A stationary installation is a MobileChunk that does not move: it uses the same streaming, save/load, NPC simulation, and LocalOverlay modification system as vessels. Player construction within a MobileChunk (building rooms, placing equipment) requires the DLC construction system to emit valid LocalOverlay modifications — the architectural pattern is ready, the construction system is DLC scope.
  • Rationale: A separate "location instance" system for player bases would require a new coordinate system, new streaming/loading path, new save/load path, portal/transition logic, and duplicate pathfinding/perception/simulation — Tier 4 difficulty for zero benefit over existing primitives. The Idle state already exists in D-108; documenting its design intent for stationary installations costs nothing and prevents future over-engineering.
  • Source: Tyre architectural analysis, 2026-02-27.
  • Raised by: Tyre (architectural mapping), lead (edge case prompt: player bases, fortresses).
  • Dissent: None.
  • Cross-reference: D-108 (MobileChunk specification — Idle state), D-100/D-109 (LocalOverlay for modifications)

D-112: No Separate Location Instancing System

  • Date: 2026-02-27
  • Decision: The game does not use instanced locations (separate spatial domains outside the world chunk grid). All subterranean spaces, player bases, and special locations are represented using existing primitives:
    • Basements / sub-levels: MultiBlockReservation with negative base_z (D-110)
    • Deep mines (shaft-style): Downward MultiBlockReservation, lazy-loaded via ZLevelLoadState
    • Deep mines (cave network): Organic-mode district (D-096) with mine-specific template
    • Player base in existing building: LocalOverlay modifications (D-100/D-109)
    • Player base as hidden bunker: MultiBlockReservation with negative z, generated at world-gen
    • Player-built space: LocalOverlay + construction system (DLC scope)
    • Stationary installation: MobileChunk in Idle state (D-111)
    • Vessel interior: MobileChunk (D-108)
  • Rationale: Instanced locations would require: (1) new coordinate system for instanced space, (2) new streaming/loading path, (3) new save/load path, (4) portal/transition logic between world space and instanced space, (5) duplicate pathfinding, perception, and simulation tier logic. Every edge case maps cleanly to an existing primitive. Nigel's vessel instancing proposal was already ruled out in favor of entity-carried MobileChunk (D-108 dissent note); the same reasoning extends to all "separate space" cases.
  • DLC/Mod implication: LocalOverlay is the universal post-generation modification layer. DLC quest locations, mod-injected dungeons, and scenario-specific spaces can be delivered as overlay packages applied to existing districts under strict conditions — without touching the generator or reseeding. The generator output remains sacred (D-109); overlays are how the world changes after generation. This makes the overlay system the canonical content injection point for all post-generation content, whether player-driven, storyteller-driven, or DLC/mod-driven.
  • Source: Tyre architectural analysis, 2026-02-27.
  • Raised by: Tyre (comprehensive edge case mapping), lead (edge case prompt: deep mines, player bases, fortresses).
  • Dissent: None.
  • Cross-reference: D-110 (signed z-levels), D-111 (MobileChunk Idle for installations), D-108 (MobileChunk), D-096 (organic layout), D-100/D-109 (LocalOverlay)

D-113: Tile data model — extensible per-tile properties

  • Date: 2026-03-05
  • Decision: Replace the current single-character tile encoding (F/W/V/R strings in location YAML) with a tile palette/registry system (option A from the design space). Tiles are typed by a palette ID; per-type properties are defined once in the palette and inherited by all tiles of that type. Per-tile overrides are supported via a sparse overlay map.
  • Current state: Tiles are single characters in string arrays. Each character maps to a TileKind enum (Floor, Wall, Door, Object) and a walkability bool. TileCell in WalkabilityMap stores { walkable: bool, kind: TileKind }. No per-tile properties (material, visual variant, sound, access lists, container contents, damage state, trigger zones) can be expressed.
  • Design survey — what systems need tile-level data:
    1. Doors — access lists (who can open), open/closed state, locked/unlocked. Currently no tile-level door data; TileKind::Door exists but carries no properties.
    2. Containers — contents, capacity, searched state. Currently handled by entity ObjectType::Container on separate entities, not tiles. Containers should remain entities, not tile properties.
    3. Damage stateDamageOverlay (D-100) modifies tiles post-generation. Damage needs to degrade tile properties (walkability, visual, material) without replacing the base tile type.
    4. Visual variants — same logical tile type (e.g., "industrial floor") with per-tile visual variation for visual richness. Currently impossible — all Floor tiles look identical to the client.
    5. Trigger zones — tile-level triggers for entry/exit events (zone transitions, alarms, dialogue triggers). Currently handled by ZoneMap at zone granularity, not per-tile.
    6. Material properties — footstep sound, movement speed modifier, surface type for particle effects. Currently all tiles produce the same footstep sound.
    7. WallBackside (D-099) — structural classification behind wall surfaces. Already defined as an enum but not yet integrated into tile data.
  • Chosen approach — Tile Palette + Sparse Override:
    • Tile palette (YAML, per-district or global): defines tile types by string ID. Each type specifies: walkable: bool, kind: TileKind, material: String (footstep/SFX), visual_base: String (client sprite), visual_variants: u8 (random variant count), los_blocking: bool, movement_cost: f32 (default 1.0), optional wall_backside: WallBackside (D-099). The palette is the type-level contract — most tiles need no per-instance data beyond their palette ID.
    • Tile map (YAML): retains the string-array format for human readability, but each character is a palette key (single char or short code). Backward-compatible: F, W, V, R are reserved palette keys that map to current behavior. New tile types use additional characters or a separate palette layer.
    • Sparse override map (YAML): overrides key on Location — a list of { x, y, properties } entries for tiles that differ from their palette type. Supports: door access lists, initial locked state, visual variant pinning, damage overlay data. Only tiles with non-default properties need entries. Keeps the string map clean for 90%+ of tiles.
    • Runtime representation:
      • TilePalette resource: BTreeMap<char, TileType> loaded at startup. Immutable after load.
      • TileCell extended: { palette_id: char, walkable: bool, kind: TileKind, material_id: u16 }. Material ID is a compact index into the palette's material table.
      • TileOverrideMap resource: BTreeMap<(i32, i32, i32), TileOverride> for per-tile overrides. Sparse — only tiles with overrides consume memory.
      • ECS queries: WalkabilityMap remains the primary interface for movement/pathfinding (unchanged API). TilePalette provides material/visual data when needed (snapshot construction, sound system). TileOverrideMap provides door state, access lists, damage overlays.
  • YAML authoring format:
    # Palette definition (loaded once, reusable across locations)
    palette:
      F: { walkable: true, kind: Floor, material: metal-grate, visual_base: floor_industrial }
      W: { walkable: false, kind: Wall, material: bulkhead, visual_base: wall_heavy, los_blocking: true }
      D: { walkable: true, kind: Door, material: metal-door, visual_base: door_standard }
      G: { walkable: true, kind: Floor, material: glass-panel, visual_base: floor_glass }
      R: { walkable: false, kind: Floor, material: metal-grate, visual_base: floor_restricted }
    
    # Location tile map (unchanged human-readable format)
    tiles:
      - "WWWWWWWWWWWWWW"
      - "WFFFFDFFFFFFFW"
      - "WFFFFFFFFFFGFW"
      - "WWWWWWWWWWWWWW"
    
    # Per-tile overrides (sparse, only for non-default properties)
    overrides:
      - { x: 5, y: 1, door_access: [faction.commission], locked: true }
      - { x: 12, y: 2, visual_variant: 3 }
    
  • Loader contract: ContentPlugin loads palette YAML first, then location tiles. The apply_location_tiles() function resolves each character via palette lookup instead of the current hardcoded match. Unknown characters fall back to Floor with a warning (same as current behavior). Overrides are loaded after tiles and applied to TileOverrideMap.
  • Migration effort for existing locations (5 files):
    • Zero-migration path: The default palette defines F/W/V/R with identical behavior to current hardcoded mapping. Existing location YAMLs work unchanged. No migration required for v0.1.
    • Incremental enrichment: Locations can opt into the new palette by adding a palette: key. Locations without palette: use the global default. Migration is per-location, at author pace.
    • Estimated effort: Palette definition = 0.5 day. Loader refactor = 1-2 days. Override system = 1 day. Total: 2-4 developer-days. No changes to location YAML files required for v0.1.
  • Alternatives considered:
    • (b) Per-tile property bags (arbitrary key-value per tile): Maximum flexibility but violates D-010 principle 4 (deterministic — dynamic typing makes serialization non-deterministic). Memory cost: ~100 bytes/tile vs ~6 bytes/tile with palette. Rejected.
    • (c) ECS-style tile components (tiles as entities): Each tile becomes a bevy_ecs entity with optional components. Elegant in theory but 150x150x3 = 67,500 entities per location, potentially 4M+ entities for a district. ECS entity overhead (~128 bytes each) makes this prohibitively expensive. Queries scale poorly at this count. Rejected for spatial data; tiles remain grid-based. Entities are reserved for interactive objects placed ON tiles.
    • (d) Hybrid (palette + entity overlay): Palette for base tiles, entities for interactive tile features (doors, containers, triggers). This is almost what we chose — the distinction is that our sparse override map is grid-indexed (O(1) lookup by position) rather than entity-query based. Interactive objects that have their own behavior (NPCs, containers, items) remain entities; tile properties that are spatial/static (material, visual variant, access) are grid data.
  • Key design principles:
    • Palette is the type; override is the instance. 90%+ of tiles need only a palette ID.
    • String-array tile maps remain human-readable and merge-friendly. No JSON, no complex nested structures.
    • WalkabilityMap API is unchanged — callers don't know about palettes.
    • BTreeMap for deterministic iteration per D-010 principle 4.
    • Palette keys are char (single Unicode codepoint) for direct mapping from tile string arrays.
  • Raised by: Tyre (architecture), requested by T-586 (Epic: extensible tile data model).
  • Dissent: None anticipated — this is a design-only D-record for post-v0.1 implementation.
  • Cross-reference: D-054 (tile-based movement), D-066 (dual-scale grid), D-094 (spatial hierarchy), D-099 (WallBackside classification), D-100 (DamageOverlay), D-012 (chunk architecture)

D-133: Skills affect outcome — same verbs available, skill determines quality

  • Date: 2026-03-05
  • Decision: The skills-to-verb coupling model is: everyone sees the same verbs (mostly). Skills determine how well you execute — bad at social means you can still talk, just badly. Some advanced verbs may still be gated by skill level, but the default is outcome-based, not access-based. This is the simplest learnable model: try anything, skill determines result.
  • Rationale: Verb access gating (skill gates whether you can even attempt an action) creates invisible walls and punishes players for trying. Outcome-based (skill determines quality of result) lets players learn by doing and creates organic differentiation. A tycoon with low social can still negotiate — they just negotiate poorly, which produces interesting consequences.
  • Source: Where's the Fun? Workshop, Round 4 Interview, Decision 5
  • Raised by: Team Leader (Jeroen) — outcome model (option C)
  • Dissent: None
  • Cross-reference: D-120 (no skill ceiling in v0.2)

D-134: Full character customization — hair, clothing, colors at tile scale

  • Date: 2026-03-05
  • Decision: Full character appearance customization is in scope: hair, clothing, colors. Readability at top-down tile scale is solved through outline and highlight mechanics, not by limiting customization options. The character creation screen is an emotional investment moment — the player should feel this is their character.
  • Rationale: Customization at this scale was assumed to be a readability risk. The workshop decision: solve the readability problem rather than limit the player. Readability via outline/highlight is a solved problem in the tile rendering pipeline. Limiting customization would undermine the identity investment that makes life-sim attachment possible.
  • Source: Where's the Fun? Workshop, Round 4 Interview, Decision 11
  • Raised by: Team Leader (Jeroen)
  • Dissent: None

D-135: Setting delivery via both layers — visual world + insert in parallel

  • Date: 2026-03-05
  • Decision: Setting is delivered through two parallel layers: (1) the physical world — visuals and NPC behavior show context, atmosphere, place; (2) the neural insert — names, contextualizes, provides information the character would know from their background. Araminta (visual layer) and Mellanie (insert copy layer) work in parallel. Both layers are required from day one of the tycoon bookmark experience.
  • Rationale: Either layer alone is insufficient. Visuals without naming leave the player in a beautiful void with no cultural foothold. Naming without visuals produces an exposition dump. Both together produce the "this is a place" sensation the workshop identified as the missing ingredient of v0.1.
  • Source: Where's the Fun? Workshop, Round 4 Interview, Decision 12
  • Raised by: Team Leader (Jeroen) — both layered (option C)
  • Dissent: None
  • Cross-reference: D-128 (culture as context for insert copy)

D-136: First Settled Reach moment — auto-generated apartment + insert activation

  • Date: 2026-03-05
  • Decision: The first moment of The Settled Reach is two layered beats: (1) Waking up in YOUR auto-generated apartment (reflects your economic position from the tycoon bookmark; wealthy, modest, or constrained start matters). (2) Insert activation — the neural implant powering on is intimate, personal, tech-specific. The alarm clock is the Groundhog Day homage (D-126). The apartment reflects the character's economic position — auto-generated, not hand-built.
  • Rationale: The apartment establishes place, economic status, and self without exposition. Insert activation establishes the neural lattice as intimate and personal — this is your character's relationship with their technology. Both beats together create the "this is MY character in MY world" moment that v0.1 lacked.
  • Source: Where's the Fun? Workshop, Round 4 Interview, Decision 13
  • Raised by: Team Leader (Jeroen)
  • Dissent: None
  • Cross-reference: D-126 (alarm clock tone), D-135 (both layers active from first moment)

D-137: Generator produces both structural and cosmetic variety at different scales

  • Date: 2026-03-05
  • Decision: The generator must produce two types of variety simultaneously at different scales: (1) Structural variety — operates at seed level: different playthroughs have genuinely different world structures (economic landscape, faction power balance, crisis composition, NPC role distribution). (2) Cosmetic variety — operates within a structure: NPC names, faces, apartment layouts vary per instance. Structural variety is the higher-priority proof for the Sprint 25 spike (D-119).
  • Rationale: Cosmetic variety without structural variety produces "same game with different wallpaper." Structural variety without cosmetic variety produces identical-looking characters with different internal states. Both are load-bearing for the life-sim experience — structural variety drives replay value, cosmetic variety drives in-session believability.
  • Source: Where's the Fun? Workshop, Round 5 Interview, Decision 23
  • Raised by: Team Leader (Jeroen)
  • Dissent: None
  • Cross-reference: D-114 (generator proof-of-life), D-119 (Sprint 25 generator spike)

D-141: PlatformInfo — client-side OS abstraction autoload

  • Date: 2026-03-13
  • Decision: All OS-dependent queries on the client are centralized in a single PlatformInfo autoload (client/scripts/autoloads/platform_info.gd), registered first in the autoload order. Individual systems (HardwareDetector, voice pipeline, settings UI) consume PlatformInfo properties and signals — they never call OS.* directly. PlatformInfo owns: power state (with PowerProfile enum: FULL, BATTERY, POWER_SAVER), memory queries, platform identity, and platform-dependent file paths. Power state is polled on a 30-second timer with a power_profile_changed signal; memory is refreshed on demand. The PowerProfile enum is the abstraction seam for future power-saver detection (GDExtension) without consumer code changes. PlatformInfo is client-side only — the client never relies on the server for hardware info, because the server may not be on the same hardware in multiplayer/remote hosting scenarios. Each side detects independently.
  • Rationale: OS calls were scattered across HardwareDetector, AiDialogueDetector (duplicate), and SimBridge. A central abstraction prevents duplication, provides a single seam for platform-specific behavior, and keeps the client self-sufficient per D-010 (information boundaries) and future multiplayer readiness.
  • Raised by: Team Leader (Jeroen)
  • Dissent: Centralized server detection was considered and rejected — server may not share hardware with client in future multiplayer scenarios.
  • Cross-reference: D-138 (hardware detection for voice pipeline), D-010 (information boundaries), Q-059 (full interface scope — open)

D-148: 30° low-angle camera with 45° map rotation — supersedes D-019

  • Date: 2026-03-17
  • Decision: The default gameplay camera is an orthographic Camera3D at 30° tilt (60° from horizontal) with the tile map rotated 45° into a diamond grid. Not faked in art — an actual Camera3D tilt. Art direction reference: Hades, Divinity: Original Sin. Three camera angle presets: frontal (-5°) (default for character editor/mugshot, see D-158), dramatic/low-angle (-30°) (gameplay default), overhead (-80°) (near-top-down view; 0° in code convention is horizontal, not overhead). Camera tilt cycle (T key in prototype): top-down ↔ dramatic. 30° is the confirmed gameplay default. The 45° map rotation gives natural depth cues and the classic diamond-grid isometric layout. 45° isometric preset superseded — not part of the confirmed set.
  • Rationale: The 30° angle (versus 45° or top-down) gives significantly more character front visibility and wall depth. Players can see faces, clothing, and character detail rather than primarily hat and shoulder. The diamond grid provides natural spatial depth cues without requiring Z-ordering hacks. The T-key prototype confirmed 30° as the most readable angle at gameplay scale.
  • Architecture note: Unlike D-019's amendment (which faked tilt in sprite art), this is a real Camera3D setting. The 45° map rotation is a Transform3D applied to the tile grid root — it does not affect simulation coordinates, which remain axis-aligned. Vision cone math and all server-side systems remain in unrotated space; the client applies the visual rotation.
  • Raised by: Team Leader (Jeroen) — confirmed during Sprint 28 character visuals spike review
  • Dissent: None
  • Supersedes: D-019
  • Cross-reference: D-149 (3D rendering), D-151 (direction count)
  • Editorial note (2026-07-06, T-1088): the opening "30° tilt (60° from horizontal)" contradicts this record's own preset list — the preset list is authoritative: pitch values are measured from horizontal (0° = horizontal), gameplay default 30° from horizontal, frontal 5°, overhead 80°. Implemented as such in the T-1088 sandbox camera. Also note "45° rotation" here means the single static map rotation (one Transform3D on the tile-grid root); stepped player camera rotation was never decided (parked under Q-084) and requires a new record before implementation.

D-149: 3D characters rendered live in scene — not pre-rendered sprites

  • Date: 2026-03-17
  • Decision: Characters are rendered as live 3D models in the Godot scene using a CharacterCompositor (Node3D). The camera is a real Camera3D at 30° tilt (D-148). Characters are not pre-rendered 2D sprite sheets. The 3D model is rotated to match the server-tracked 8-direction facing; the camera and lighting remain fixed. Clothing, hair, and accessories are separate mesh layers composited at runtime.
  • Rationale: The Sprint 28 spike used CSG placeholder characters (cylinders, spheres) and confirmed that even crude 3D shapes read as recognizable people at isometric scale — silhouette, proportion, and facing direction are all legible. Pre-rendered sprites would require 8× (or 4×) separate renders per outfit combination; live 3D compositing gives unlimited clothing/color combinations at negligible extra render cost. Direction changes are a model rotation, not a sprite swap. Future animation is natural.
  • Architecture note: The existing EntityRenderer (client/scripts/rendering/entity_renderer.gd) currently uses a single Sprite2D per entity. Under this decision, EntityRenderer is extended to instantiate a CharacterCompositor scene (Node3D subtree) instead. The compositor API is specified in docs/design/compositor-api-spec.md.
  • Raised by: Team Leader (Jeroen) — spike prototype confirmed; Sprint 28 workshop decision
  • Dissent: None
  • Cross-reference: D-148, D-150, D-151, D-152, ticket T-693 (compositor implementation)

D-150: Character outline — inverted hull method

  • Date: 2026-03-17
  • Decision: Character outlines are rendered via the inverted hull method (GPU vertex extrusion on a back-face-only render pass). Color: #1e1e24 (very dark blue-grey) for all characters, always — not pure black. Specified by Araminta (art direction, Sprint 28 Round 2). No screen-space outline system. At LOD tier 2 (billboard impostor), the outline is baked into the impostor sprite — no separate draw call needed at that tier.
  • Rationale: Inverted hull is GPU-cheap, works correctly in 3D space, and produces clean consistent outlines. Screen-space methods (e.g., Sobel filter) are more expensive and produce artifacts at isometric angles. The billboard LOD tier naturally subsumes the outline into the baked sprite, so the system degrades gracefully under performance pressure without special outline handling.
  • Raised by: Sprint 28 workshop consensus (Tyre technical, Jeroen confirmed)
  • Dissent: None
  • Cross-reference: D-149, D-152, D-154

D-151: Direction count — 8 server-side facings, 4 visual groups client Sprint 28

  • Date: 2026-03-17
  • Decision: The server tracks 8 facing directions for all characters (N, NE, E, SE, S, SW, W, NW) — full resolution, future-proof. The client renders 4 visual groups for Sprint 28: North (covers N, NW), East (covers NE, E), South (covers SE, S), West (covers SW, W). The 3D model is rotated to the true 8-direction angle; only the visual mesh/asset groups are 4-way. E and W groups share mirrored assets. Post-Sprint 28: additional direction-specific mesh variants can be authored for diagonal facings without protocol changes.
  • Critical distinction — perception vs. rendering: The 8 server facings are perception system input (fog-of-war, vision cone direction, all simulation logic). The 4 visual groups are rendering output (what the player sees). These are not the same thing. Diagonal facings (NE, NW, SE, SW) do not exist as visible character states — a character facing NE renders as East. EntityRenderer maps the server's 8-direction value to a 4-group CharacterFacing before calling the compositor. ModelRoot body rotation uses the true 8-direction angle for subtle lean.
  • Rationale: Hybrid approach (Tyre): 8 server facings ensures the data model never needs migration. 4 client visual groups keeps Sprint 28 asset authoring cost manageable — each new clothing item needs 2 unique meshes (N, E) plus mirroring, not 4 or 8. The 3D rotation to true angle (before the visual group snap) gives subtle body lean and positioning cues even within a visual group. Characters snap to nearest cardinal with smooth rotation interpolation.
  • Raised by: Tyre (hybrid proposal), confirmed by Team Leader (Jeroen) — Sprint 28 Round 2
  • Dissent: None
  • Cross-reference: D-148, D-149

D-152: Character LOD — performance-driven budget, not distance threshold

  • Date: 2026-03-17
  • Decision: Character LOD degrades based on GPU frame budget, not distance or fixed character count. Three tiers:
    • Tier 0 (full): All characters at full 3D detail with all compositor layers active.
    • Tier 1 (simplified mesh): Reduced-poly model; clothing layers merged into combined mesh; inverted hull still active.
    • Tier 2 (billboard impostor): Flat sprite impostor. Outline baked in. Must visually preserve: (1) body size tier (slim/average/stocky silhouette), (2) dominant clothing color (cloth_primary). Applied to characters furthest from player first. LOD trigger is proactive on projected character count, not reactive on frame drop. Reactive triggering produces visible hitches; proactive demotion is invisible. LOD demotes characters outward from player: nearest characters always stay at Tier 0 longest. When paused, render budget is fully freed and all characters restore to Tier 0 — player can inspect the scene at leisure. In a chaotic moment (400+ characters on screen), peripheral detail naturally degrades, which matches the cognitive experience of chaos.
  • Rationale: A fixed distance or count threshold would produce visually jarring sudden LOD pops when character density changes (a crowd gathering). Performance-driven budget is adaptive and invisible to the player. The "paused = full detail" design is a deliberate player affordance — it makes the pause button feel powerful.
  • Raised by: Sprint 28 workshop (Jeroen — Q2 resolution)
  • Dissent: None
  • Cross-reference: D-149, D-150

D-160: Body meshes must be segmented into 18 bone-group regions

  • Date: 2026-03-19 (updated 2026-03-19: 17→18, added torso_upper)
  • Decision: Character body meshes are segmented into 18 bone-group regions (head, neck, torso, torso_upper, arm_upper_l/r, arm_lower_l/r, hand_l/r, leg_upper_l/r, leg_lower_l/r, foot_l/r, eyes, eyebrows). Each segment is a separate skinned GLB on the shared 65-bone skeleton. Segments have a 1-ring vertex overlap at boundaries to eliminate visible seams during animation. Segments can be individually hidden when clothing covers them. The torso_upper variant covers chest-only (for jackets/vests that leave the waist exposed).
  • Rationale: Validated in the Quaternius aesthetic spike. Monolithic body meshes block the compositor: hiding the body to show clothing also removes the head and limbs. Segmentation enables per-slot visibility, head separation for Trellis-generated faces, and limb loss mechanics. The 1-ring vertex overlap was validated as seamless at gameplay zoom.
  • Raised by: Jeroen + Tyre, during spike validation.
  • Cross-reference: D-159 (body type enum), D-149

D-161: Head is always a separate mesh on the Head bone

  • Date: 2026-03-19
  • Decision: Character heads are never baked into body or clothing meshes. The head is always a separate mesh attached to the Head bone via BoneAttachment3D. Trellis generates unique head shapes per character. Hair attaches to the same bone as a separate swappable mesh (enables hairdresser mechanic). The neck stays with the body mesh as a separate segment.
  • Rationale: Validated in the Quaternius spike. The Quaternius pack bakes the head into the body mesh — this blocks clothing display (hiding body = losing head) and prevents per-character face variation. Separating the head unlocks: Trellis-generated faces, idle look-around animation, helmet/hood equipment, hair as a swappable accessory.
  • Raised by: Jeroen, during spike validation.
  • Cross-reference: D-160, D-163

D-162: Clothing is pre-baked per body type via Blender Surface Deform

  • Date: 2026-03-19
  • Decision: Clothing meshes are authored on a reference body type, then batch-fitted to each of the 9 body types via Blender's Surface Deform modifier in an offline pipeline. The fitted variants are exported as separate GLBs. At runtime, the compositor loads the variant matching the character's body type. Runtime clothing scaling does not exist — it was tested (bone pose scaling in Godot) and failed catastrophically.
  • Rationale: Three approaches were tested during the spike: (1) runtime bone scaling — failed with mesh collapse at joints, (2) Blender bone scaling — same failure, (3) Blender Surface Deform — produced output (visual quality at extremes is Q-060). The Surface Deform pipeline is the only viable path. Pre-baking is required; runtime fitting is impossible.
  • Raised by: Tyre + Araminta, during spike research and validation.
  • Cross-reference: D-159, Q-060

D-163: Trellis generates unique heads per character via BoneAttachment3D

  • Date: 2026-03-19
  • Decision: Each character in the game receives a unique Trellis-generated head mesh. The head is a static (unskinned) mesh attached to the Head bone via BoneAttachment3D. Hair is a separate mesh on the same bone. Head generation happens offline during character creation/world generation. The concept image prompt controls face shape, ethnicity, age, and features.
  • Rationale: Validated in the spike: Trellis head + BoneAttachment3D + toon shader produces a coherent character at gameplay zoom. This is the only Trellis output that works on the Quaternius rig — clothing and body meshes both failed. Head generation gives per-character visual identity without hand-modeling.
  • Raised by: Jeroen (insight that Trellis output works as heads), validated by Tyre.
  • Cross-reference: D-161

D-164: Fork Quaternius skeleton, replace all body meshes

  • Date: 2026-03-19
  • Decision: The Quaternius 65-bone skeleton and Universal Animation Library are adopted as the character rig foundation. All body meshes are replaced with hand-authored meshes on the same skeleton. The CC0 license permits unrestricted forking. Quaternius Source tier ($5/mo Patreon, one month) is recommended for .blend source files to support the Blender batch scripting pipeline.
  • Rationale: The spike validated the skeleton and animation library as clean, compatible, and well-structured. The Quaternius Superhero body mesh is retained as the muscular body type (D-159); the Regular mesh becomes average; the Teen mesh becomes teen. The remaining body types (thin, heavy, child) are new meshes authored on the same skeleton. Procedural body type generation via bone scaling failed — separate meshes are required. The skeleton is the expensive part to create from scratch; keeping it and replacing/extending the meshes is the correct split.
  • Raised by: Tyre + Araminta, confirmed by Jeroen.
  • Cross-reference: D-159, D-160

D-166: Development Cascade — 6-Phase First-Things-First Build Order

  • Date: 2026-03-24
  • Decision: Development follows a strict 6-phase cascade. Each phase completes before the next begins. No v0.2 target. No scoping negotiations.
Phase Focus Deliverable
1 Wiki content complete — all planets, moons, stations, heightmaps, artwork Implant-ready Godot map with click-throughs + wiki/GTTR popups
2 Economics layer — supply/demand, transport, corporations, supply chains Economics spreadsheets/graphs with runtime-tweakable simulation
3 Planetary/moon maps & station layouts — cities, rivers, mountains, roads, biomes Atlas of the Reach (implant app)
4 World generation (tile/chunk/block) — deterministic multilayer seed→tile cascade; each layer viewed as a map in the implant Atlas (no in-world rendering) Deterministic walkable-world data + per-layer Atlas maps + asset catalog
5 Player control & in-world rendering — character, walls/stairs/doors, lighting, drawn on generated tiles (no test map) Player viewport with final-version assets on the generated world
6 Detail coloring — room-level content, cultural architecture Only when the world is walkable
  • Amendment (2026-05-22): Phases 4 and 5 swapped — world generation now precedes player control. The rule: no player-control or in-world rendering work begins until the generator can deterministically seed-generate every tile of every world via the full multilayer cascade. The original Phase 4 "2-floor test map" is dropped — test layers are produced by the generator itself once layer-drawing begins; we start drawing the world only when generation knows what to draw. Generation progress is viewed as per-layer maps in the implant Atlas (the Phase 3 deliverable, already built), not via an in-world renderer. The existing in-world rendering code is left as-is until Phase 5 — neither built upon nor removed before then. Rationale: building player systems against a throwaway test substrate means rebuilding them against real generated tiles later; gating player work on deterministic generation avoids that waste. Epics T-749 (now Phase 5) and T-750 (now Phase 4) and the CLAUDE.md cascade table are updated to match.
  • Amendment (2026-06-12): Build order within and after the cascade clarified (Jeroen). The world is built outside-in: (1) geo layer; (2) the economic layer drawn onto the world — cities, buildings, streets, blocks, and the inter-settlement road/rail network; (3) building templates; (4) door boundary contracts defining the seam between exteriors and building interiors. Only then (5) the background NPC layer and building interiors (Phase 6), and only after that does gameplay/scenario design resume ("making it a game again" — tracked in the post-cascade gameplay parking epic, outside any phase). Player control and visual rendering (Phase 5) ramp in parallel once outside generation produces walkable exteriors — the Phase-5 trigger is "outsides generate deterministically and are walkable", not "all Phase-4 polish complete"; T-962 holds the gate and is updated to this trigger. Guiding statement: "Before this is a game I want it to be a Reach a character can travel through." NPCs, scenarios, and gameplay systems are conceived, designed, and built only after the traversable world exists. This amendment absorbs the v0.2 scope cluster — D-114, D-115, D-117, D-118, D-120 are marked superseded by this record, with each one's surviving design substance noted on the record itself.
  • Amendment (2026-07-21): Seamless zoom ladder is a hard Phase-4 deliverable condition (Jeroen, reviewing the Groombridge/Lendel Atlas zoom-ladder captures). The guiding statement (BHAG) is deliberately unchanged"Before this is a game I want it to be a Reach a character can travel through." What changes is the Phase 4 exit bar: the Atlas must provide a continuous zoom ladder from the planetary map down to tile scale, where every zoom level shows deterministically calculated information at that level's native granularity ("LoD on the information in view" — the T-1143 ruling), never magnified interpolation of a coarser composite. Evidence that forced the condition: at Lendel's scale one heightmap pixel spans ~19 districts, so the 16-district window is sub-pixel relative to its source — beyond fit-zoom the current viewer can only linearly magnify the same 16×16 composite (captures 2026-07-21, .cache/screenshots/groombridge/), and the real waterline sat ~36 districts from the pixel-edge estimate, i.e. all information at window scale and below is invented detail (D-227) that must be derived, not smoothed. T-1143 is the design pass for the ladder (granularity rungs, wire budget, the D-226(d) floor question); the Phase-4 epic (T-750) does not close until the ladder stands. CLAUDE.md's cascade table is updated to carry the condition. Corollary (Jeroen, same day): the authored heightmap PNGs are thereby demoted to invisible input to the seed/derivation calculation — no zoom level displays them directly, including the orbital/planetary level, which must itself be fully derived. Display at every rung samples the derivation at canvas resolution (the ladder is a continuous field, not a stack of fixed display rasters), which removes both zoom oversampling (magnified interpolation) and undersampling (the ~4078 km/px source-raster floor) by construction, and lets the map adapt to any viewport size. The import_heightmaps bake into systems.db survives as derivation input storage only; the AtlasViewer's direct heightmap-texture display path retires when the derived planetary rung lands (in T-1143's design-pass scope).
  • Amendment (2026-07-23, body-map-viewer workshop — stepped ladder supersedes continuous; see D-255): the 2026-07-21 corollary sentence — "Display at every rung samples the derivation at canvas resolution (the ladder is a continuous field, not a stack of fixed display rasters)" — is repointed, not deleted. The zoom mechanism is now stepped: the server generates one data canvas per discrete zoom step, sampled at that step's native D-243 gridunit spacing. The corollary's guarantee survives, re-expressed per-step: a gridunit is never derived coarser than its step's own rung floor, and never displayed finer than the display-ratio tunable (1×1 ideal, ≥5×5 px/gridunit acceptable). The "continuous field" framing is retired as literally false (it never was continuous once server-side per-step canvases replaced the client _canvas.scale model). The corollary now reads: "each zoom step's data canvas is a derivation sampled at that step's native gridunit spacing; display within a step holds that canvas at a fixed, texel-exact ratio; between-step magnification of the held canvas is bounded to one step interval and is the ladder's only sanctioned display-time scaling." This amendment owns the one honest new gap it creates: in the interval just before a step-cross, the held (coarser or adjacent) canvas is magnified to fill the new step's viewport for the fetch duration — the exact operation the original corollary was written against, now a named, bounded exception rather than a silent violation. The bound is joint in two knobs — step count (more steps → smaller per-step magnification factor) and the display ratio — and the fetch interval is short (measured: step-canvas texture upload 0.034.6 ms at every size, uncached 330K-gridunit step derive+encode ~80 ms — a double-digit-millisecond transient, not a resting display state). Ladder extent: the 2026-07-21 amendment's "down to tile scale" phrasing is narrowed by D-255 — the Atlas ladder bottoms out at chunk (64 m), not tile (1 m); tile/voxel is Phase-5 in-world content, not an Atlas rung.
  • Rationale: The pattern of negotiating pragmatic v0.2 cuts while discussing room-level detail repeatedly produced superseded decisions, confused agents, and distracted from building the actual product. The cascade enforces a first-things-first discipline: each layer of the game is grounded in the layer below it before detail is added.
  • Raised by: Jeroen, established 2026-03-24 during world generation workshop.
  • Dissent: None.
  • Cross-reference: Initiative T-745, Epics T-746751, CLAUDE.md cascade table, docs/workshops/world-generation/workshop-outcomes.md, D-255 (body-map-viewer stepped render architecture — the corollary's stepped successor).

D-169: Implant UI component library — Control node tree with custom Theme

  • Date: 2026-04-05
  • Decision: The implant UI (all diegetic neural overlay panels — star map info, travel planner, station profiles, GTTR reader, cargo manifest, etc.) uses Godot Control node components composed in scenes, styled via a shared Theme resource. Not _draw() draw-objects, not hand-rolled rendering.
  • Rationale: The implant system is tagged as dynamic — different hardware, upgrades, damage states, and faction overlays will change the UI's look and behavior at runtime. This requires layout automation (reflow, resize, expand/collapse), accessibility support, and theme swapping — all things Godot's Control tree provides and a _draw() approach would need to reimplement. The team (Tyre, Stig, Araminta) initially favored _draw() for pixel control, but the dynamic implant requirement makes Control nodes the better long-term investment.
  • Architecture:
    • ImplantPanelPanelContainer with theme override, serves as root for any implant overlay
    • ImplantHeaderHBoxContainer (title label + subtitle label)
    • ImplantSeparatorHSeparator with theme override
    • ImplantDataRowHBoxContainer (key label + value label, two-column)
    • ImplantTextBlockRichTextLabel for wrapping narrative text
    • ImplantStatusBadge — colored dot + label
    • ImplantProgressBar — two-tone, no gradients
    • ImplantTabRow — uppercase labels, underline-active pattern
    • ImplantExpandable — chevron + collapsible section
    • Theme resource (.tres) defines: colors (primary, dim, accent active/positive/negative/warning), spacing (line height, separator padding, panel padding), font sizes (header, body, small, caption)
    • Different implant hardware swaps the entire Theme resource at runtime
  • Rejected alternative: R-NNN _draw() RefCounted components — pixel-precise, zero scene tree overhead, but would require reimplementing layout automation, scroll, and interaction handling as the implant system grows. Correct for a static HUD; wrong for a dynamic system.
  • Raised by: Jeroen + Tyre/Stig/Araminta design discussion, 2026-04-05
  • Dissent: Tyre and Stig initially favored _draw() for aesthetic control. Reversed when the dynamic implant requirement was surfaced.
  • Semantic color roles: PRIMARY_TEXT (#c8d0e0), DIM_TEXT (#667788), ACCENT_ACTIVE (#f0d060), ACCENT_POSITIVE (#44aa66), ACCENT_NEGATIVE (#aa4444), ACCENT_WARNING (#aa8844), SEPARATOR (#c8d0e0 at 12%)
  • Interaction pattern: Invisible until touched. Hover shows 0.3 alpha rect. Active shifts text to ACCENT_ACTIVE. No button chrome — the implant doesn't label its affordances.

D-170: HUD visibility groups — gameplay vs implant layers

  • Date: 2026-04-05
  • Decision: HUD elements register into named visibility groups via a HudGroups autoload. Groups control show/hide of related elements as a unit. Exclusive groups (gameplay, implant) are mutually exclusive — showing one hides the other. Non-exclusive groups (modal, debug) overlay independently.
  • Rationale: Full-screen implant panels (star map, travel planner, GTTR reader) need to hide gameplay HUD elements (stance, minimap, health, interaction prompts). Without groups, each panel manually hides/shows individual nodes — error-prone and unsustainable as the panel count grows.
  • Groups (hierarchical):
    • gameplay — stance indicator, minimap, HUD status, interaction prompts, cursor, inventory, news ticker, examine display. Visible during normal gameplay.
    • implant/map — star map navigator. Mutually exclusive with gameplay and other implant apps.
    • implant/wiki — GTTR reader. Mutually exclusive with gameplay and other implant apps.
    • implant/journal — knowledge journal. Mutually exclusive with gameplay and other implant apps.
    • implant/travel — travel planner. Mutually exclusive with gameplay and other implant apps.
    • implant/station — station profile. Mutually exclusive with gameplay and other implant apps.
    • implant/cargo — cargo manifest. Mutually exclusive with gameplay and other implant apps.
    • modal — settings, bug report, loading screen, debug console. Independent — overlays on top of anything.
    • debug — debug overlay, gauntlet HUD, checklist. Independent.
  • Exclusivity rules: Opening any implant/* app hides gameplay and any other implant app. Closing an app returns to gameplay. Modal and debug are independent layers.
  • API: HudGroups.register(node, "implant/map"), HudGroups.open_app("implant/map"), HudGroups.close_app(), HudGroups.toggle_app("implant/map"), HudGroups.is_app_active("implant/map"), HudGroups.is_implant_active()
  • Implementation: client/scripts/autoloads/hud_groups.gd — lightweight autoload. Uses z-index layer management (not visibility toggling) so all nodes stay active in the tree. Emits gameplay_occluded signal when a fullscreen app covers gameplay — renderers extend GameplayRenderer base class to pause automatically.
  • Raised by: Jeroen, 2026-04-05 — "Walk" badge visible over fullscreen star map.
  • Dissent: None.

D-188: Rename biome_summary to planet_class across codebase

  • Date: 2026-04-06
  • Decision: The biome_summary field in systems.db and all references throughout the codebase are renamed to planet_class. "Biome" describes per-zone vegetation classification (Whittaker table — tropical rainforest, temperate deciduous, etc.). "Planet class" describes the overall planetary character (temperate, arid, frozen, etc.). The two concepts were conflated under one name, causing confusion in the planet generator pipeline where both exist.
  • Scope: DB column (bodies.biome_summarybodies.planet_class), schema SQL, all Rust atlas code, wiki table headers (BiomeClass), atlas proposal JSONs, design docs, decision refs, planet generator scripts.
  • Raised by: Team Leader (Jeroen), during planet generator spike.
  • Dissent: None.

D-191: Atlas of the Reach — Phase 3 Scope and Pipeline

  • Date: 2026-04-10

  • Decision: Phase 3 delivers the Atlas of the Reach as an extension of the implant map (implant/map), adding planetary and regional zoom levels to the existing star map. The Atlas is a read-only spatial intelligence tool — the player looks at it to plan, not to execute actions. It serves dual purpose: gameplay information layer and world-texture content system.

    1. Zoom Hierarchy

    • 4 levels: Reach map (Phase 1, exists) → System view (orbital diagram, new) → Planetary view (hemisphere, new) → Regional view (hundreds-of-km, new — core Phase 3 deliverable)
    • Atlas is the star map extended downward, not a separate app. implant/map at different zoom levels.
    • Station maps and underground/cave city maps are DEFERRED to a later sprint (similar bounded generation pattern).
    • Amended 2026-07-13: generation-layer maps (D-226/T-960) bottom out at settlement/quarter-skeleton granularity — chunk/tile/voxel fill is never a planetary map layer; it is harness-verified and inspected in-world in Phase 5 (see the D-226 amendment of the same date).

    2. Existing Foundation

    • 2,394 heightmap PNGs (1024×512 equirectangular, production quality)
    • 2,394 markers.json files with procedural geometry (rivers, oceans, mountains) — all names null, all cities/roads/rail/POIs empty
    • Planet-gen pipeline (tooling/planet-gen/) explicitly designed for Phase 3: render_heightmap.py line 26-27 defers cultural overlay to the atlas app
    • 301 systems in systems.db, 273 inhabited bodies, 466 stations, 668 gate links

    3. Content Pipeline — Sequential Settlement Growth Simulation

    • City placement is terrain-aware and sequential (not scatter)
    • Capital first: favor river mouths (~50% of capitals), scored by habitability (temperature, moisture, slope), coastal access, flat hinterland
    • Subsequent cities: grow along rail corridors from capital (multi-source Dijkstra on cost grid)
    • At cities 34: first foothold on a new continent if one exists (port city)
    • Roads and rail: A* pathfinding on terrain cost grid (water=impassable, mountains=expensive, rivers=cheap corridors), minimum spanning tree — NO straight lines
    • Quadrant distribution: after 2 cities in the same map quadrant, subsequent cities must prefer unoccupied quadrants unless those quadrants have no habitable land
    • Variation: ±25% noise on scoring per seed — same terrain, different seed → different city network
    • Settlement pattern modifies spacing (urban_concentrated=tight, dispersed=wide)
    • All systems same depth, scaled by population. No manual tier classification.

    4. Naming Pipeline — Gemma 2 Voice Pipeline

    • Geographic names generated by the existing Gemma 2 voice pipeline (server/src/voice/, sr-voice binary)
    • Input: body wiki page (planet_class, cultural_corridor, settlement_pattern, population, economic_role, atmospheric_tone) + corridor naming palette
    • Output: culturally-appropriate names for rivers, oceans, mountains, regions, cities
    • Corridor palettes: north_reach (Anglo-Saxon), south_reach (Iberian/Portuguese), east_reach (East Asian), west_reach (Germanic/Nordic), inner_orbit (institutional Latin/Anglo)
    • Dual purpose: Phase 3 content generation AND quality/consistency test of the in-game LLM pipeline
    • Batch throughput: ~80 min for all 2,394 bodies (placement) + ~40 min for 273 inhabited (naming). Parallelizable.
    • Earth-name blocklist as post-processing safety net. Dedup check against full name corpus.

    5. Core Systems as Templates

    • Core systems (Gateway/Sirius, Groombridge/Lendel, etc.) hand-authored as templates
    • Templates establish rulesets and quality bar for the generator
    • Generator produces all remaining bodies → hand-author refinements over the batch
    • Lendel (GJ 380c) is the first proving ground: arid, urban_concentrated, financial hub, 900M pop

    6. Atlas Panel Architecture

    • FULLSCREEN implant app (z=20), same component library as economics panel
    • 3 navigation levels: system picker → orbital diagram → body atlas (heightmap + overlays)
    • Heightmap displayed as Texture2D with pan/zoom
    • Marker overlay renders cities, roads, rail, POIs, named features on top of heightmap
    • Click city → City Data Panel (name, population, currency zone, Commission presence, gate distance, economics panel link) — shadow economy zone dropped per §7 amendment (2026-05-22)
    • Economics panel link: click-through to Phase 2 economics panel pre-filtered to that node — primary Phase 2/3 integration point

    7. Overlay System — 8 MVP Overlays

    • Amendment (2026-05-22): dropped the shadow economy toggleable overlay (9 → 8 overlays). Shadow economy is an underwater simulation modifiershadow_economy_intensity (D-174) feeds derived signals such as collection_efficiency and signal 7 official_coverage_ratio (D-181), but it is not a user-navigable data point, so it does not warrant a player-facing atlas overlay or City Data Panel field. The simulation layer (D-174) is unaffected. Also removed from §6 (city data panel) and §10 (completion criteria) for consistency.
    • 5 always-on: terrain, infrastructure, named features, gate/spaceport POIs, political zones
    • 3 toggleable: population density, production zones, corporate presence (Tier 1 only)
    • Deferred overlays visible in toggle bar but locked with unlock requirements shown on hover (creates pull toward Phase 4+ systems)
    • Maps to D-181 signal visibility ladder

    8. Settlement Data Model

    • Amendment (2026-05-22, D-223): superseded — markers.json is reduced to names only (a flavored name pool); it no longer carries position arrays or topographic-feature geometry. River/mountain positions derive from the heightmap + drainage (D-208); settlement positions from the economic sim + placement (D-211). The 6 hand-authored templates and their reserved pinning are removed (bodies stay as ordinary named bodies). The pixel-space schema below is retained as historical reference only.
    • Amendment (2026-04-15): The original §8 (below) described marker positions as lat/lon objects and city records keyed by population_tier/primary_function/gate_terminal/continent_id. That shape was aspirational — neither the generator nor the hand-authored templates ever emitted it. Both ended up writing pixel-space row/col arrays against a 512 × 256 storage grid, and PR #129 canonizes that shape so the code and the decision stop drifting. The original prose is preserved immediately below; the current shape follows.
    • Original (2026-04-10, superseded): markers.json schema per body: cities (name, lat/lon, population_tier, primary_function, gate_terminal, continent_id), roads (path polylines, connects), railroads (path polylines, connects), pois (name, kind, position), plus existing rivers/oceans/mountains with names filled. Population tier → city count: floor(log10(pop/1M)), modified by settlement_pattern. Gate terminal POI at largest population center, sometimes scattered to a smaller one. Moons: same depth as planets, scale with population.
    • Current canonical format: markers.json is stored in heightmap pixel space. Every marker file declares a grid: { w, h } header — the generator, the 6 hand-authored templates (Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade), and all 2394 procedural seed files ship {"w": 512, "h": 256}. Every position is a two-element array [row, col] of integer pixels into that grid, where row ∈ [0, h) and col ∈ [0, w) (row is the first axis to match NumPy convention and the flood-fill / A* / cost-grid code that tooling/planet-gen/ already runs in). Polyline geometry (roads[*].path, railroads[*].path, rivers[*].path) is [[row, col], [row, col], ...].
    • markers.json top-level schema:
      • grid: {"w": 512, "h": 256}
      • cities[]: {id, name, kind, center: [row, col], population}kind is capital or city; name is empty when awaiting gemma_naming.py (T-833).
      • roads[]: {id, name, kind, path: [[row, col], …]}kind is commercial by default for generated roads; hand-authored roads use highway, rural, etc.
      • railroads[]: same shape as roads[]; generated default kind is passenger_freight.
      • pois[]: {id, name, kind, center: [row, col]} — generated POIs are kind: "transit"; hand-authored POIs use institutional, cultural, corporate, etc.
      • rivers[]: {id, name, path: [[row, col], …]}
      • oceans[]: {id, name, kind, center: [row, col], area_fraction} where kind is lake | sea | ocean.
      • mountain_ranges[]: {id, name, center: [row, col], peak: [row, col], area_cells}
    • Pixel space is what the heightmap analysis (flood-fill, A* cost grid, city placement) natively operates on; it is deterministic and avoids double-conversion through a projection.
    • Lat/lon strings are a display-time derivation, not a storage format. The atlas UI converts [row, col] + grid: {w, h} into an equirectangular lat°N/S, lon°E/W string for the city data panel and hover tooltips, using the body's radius for any great-circle distances it needs. This keeps the immersive surface without paying conversion cost in the generator, the DB, or the diff churn on hand-authored files.
    • Atlas DB index (atlas_cities, atlas_roads, atlas_railroads, atlas_pois, atlas_rivers, atlas_oceans, atlas_mountain_ranges, atlas_body_grids) mirrors these scalar fields row-by-row for implant-app and development queries; polyline geometry stays in the JSON files next to the heightmaps.
    • Population tier → city count: floor(log10(pop/1M)), modified by settlement_pattern
    • Gate terminal POI: at largest population center, sometimes scattered to a smaller one
    • Moons: same depth as planets, scale with population

    9. Implementation Pipeline

    • Python: tooling/planet-gen/generate_atlas.py — reuses planet_simulation.simulate(), adds city_placement.py, infrastructure_gen.py, gemma_naming.py, markers_writer.py
    • make atlas-generate runs the full batch. Deterministic per seed. Incremental (skips up-to-date bodies).
    • Pipeline order: simulate terrain → analyze (continents, habitability, river mouths, cost grid) → place cities sequentially → generate infrastructure (A* MST) → name via Gemma 2 → write markers.json

    10. MVP Completion Criteria

    1. Navigation chain works end-to-end (Reach → system → planet → regional)
    2. Regional map content complete for all inhabited bodies
    3. Population-scaled depth (core systems rich, frontier sparse)
    4. City data panel works (click any city, see data + economics link)
    5. Economics panel integration works (link opens Phase 2 panel filtered to node)
    6. All 8 MVP overlays present and functional
    7. Atlas is read-only (no verbs execute from map)
    8. Stations show at system view with mini data panel (no drill-down)
  • Rationale: The heightmap pipeline was designed with Phase 3 in mind — 2,394 base maps exist. The critical path is content (filling empty markers.json arrays), not technology (the atlas panel follows the established implant component pattern). Sequential settlement growth simulation produces more realistic city networks than scatter placement — each city's location is informed by previous placements, terrain, and economic logic. Using the Gemma 2 voice pipeline for naming tests the in-game LLM quality while generating content. Population-scaled depth without manual tier classification keeps the pipeline simple and the authoring burden manageable.

  • Raised by: Full planning team workshop, Sprint 34 (T-748). Participants: Gestalt (systems design), Tyre (technical architecture), Miri (worldbuilding), with Jeroen as workshop participant.

  • Dissent: None.

  • Cross-reference: D-166 (development cascade — Phase 3), D-036 (Sova as canonical setting), D-093 (Sova spatial layout), D-094 (district hierarchy), D-095 (Horizon stations), D-170 (HUD visibility/implant apps), D-169 (implant component library), D-181 (signal vocabulary/visibility ladder), D-174 (shadow economy intensity), D-175 (corporation taxonomy), D-138 (Gemma 2 voice pipeline)

D-192: Drop PROTOCOL_VERSION lockstep handshake

  • Decision: Deprecate the snapshot envelope version field, the PROTOCOL_VERSION constants on both server (server/src/bridge/types.rs) and client (client/scripts/protocol/protocol.gd), and the version-mismatch guard in Protocol.decode_snapshot(). Removal is tracked in ticket T-868 (server + client coordinated, sprint 37 or later). Once removed, genuine schema mismatches will surface as MessagePack decode errors or missing-field errors at the consumer; that signal is sufficient for our deployment model. Until T-868 lands, the field and guard stay in place — they are no longer load-bearing, but removing them requires coordinated edits on both sides and fresh fixture regeneration.
  • Rationale: The version constants were designed for a network deployment where client and server can ship out of sync. Our actual deployment is a subprocess: the Godot client launches the Rust server it was built with. They are always in sync at runtime — the version check has never caught a real mismatch in the field, only dev-time forgetfulness. The cost has been measurable: every protocol-shaping sprint requires bumping two constants in lockstep, and we accumulated tautological tests asserting PROTOCOL_VERSION == N (deleted in sprint 36 — see ticket from this D-record). Removing the handshake makes the per-sprint cost zero. Reversibility: When/if networked multiplayer arrives (no firm date — see D-005), the natural fit is a one-time handshake at connection time (a single client-version vs. server-version exchange in the connection protocol), not a per-snapshot version stamp. So even the multiplayer path doesn't argue for keeping the per-snapshot field — that field would be doubly redundant once a connection-time check exists. The design space hasn't been narrowed.
  • What we lose: A single eager, human-readable error at connect time ("client v22 ↔ server v23"). A genuine dev-time schema drift will now surface as a downstream decode/missing-field error, possibly seconds into a session rather than at handshake.
  • What we keep: All field-presence and roundtrip tests in test_protocol_bridge.gd, test_signal_sprint24.gd, etc. — these cover the behavior the version constant was meant to gate. Decode failure in Messagepack.decode() still rejects malformed payloads.
  • Amendment (2026-07-23, body-map-viewer workshop — persistent-cache scope note; see D-255): the co-ship "always in sync at runtime" guarantee holds for the live wire but does not extend to a disk-backed persistent cache. D-255's client-side Atlas cache (user://atlas_cache/) survives a game update by construction — a cache file written by version N read back by version N+1 crosses exactly the version boundary this record assumes away elsewhere. That boundary is handled in D-227's cache schema/version-tag amendment (a mismatch = cache miss, re-fetch, never decode), not here — this note only records that D-192's reasoning is scoped to the live wire and the persistent cache is the one place it does not reach. D-192's live-wire reasoning is unchanged.
  • Raised by: Jeroen, sprint-36 client triage. Triggered by stale test_protocol_version_is_19 assertions failing across two suites after the v23 bump, requiring mechanical edits in both places to "fix."
  • Dissent: None.
  • Cross-reference: D-005 (subprocess model — always co-shipped), D-227 (persistent-cache version-tag — where the disk-format version boundary is handled), D-255 (body-map-viewer render architecture — the persistent Atlas cache).

D-194: Three-Component District Mix Algorithm for City District Type Distribution

  • Date: 2026-05-01
  • Decision: District type distribution for a generated city is computed from three components combined at generation time:
    1. Population tier guarantees — minimum district counts enforced by city size. Population tier is floor(log10(pop / 1_000_000)), capped at 5. Larger populations guarantee minimum counts of Transit, Commercial, and Residential districts.
    2. 10×9 economic multiplier table — rows are 10 economic_role values (manufacturing, financial, agricultural, extraction, service_mixed, institutional, transit_hub, research, military, residential); columns are 9 DistrictType variants. Each cell is a weight multiplier (0.03.0) applied to that district type's base probability for cities of that economic role.
    3. Political archetype modifiersPoliticalArchetype shifts weights for Institutional, Restricted-access, and Civic district types. Corporate archetype boosts Commercial + Restricted. Commission archetype boosts Institutional + Administrative. Pioneer archetype boosts Mixed-use + Organic residential.
    • Founding age character is applied as a post-mix adjustment to BlockIrregularity (see D-216), not to the district type distribution itself.
    • The mix is self-contained per city: two cities with the same economic role, population tier, and political archetype produce the same district type distribution (modulo seed-driven noise). No city-to-city state dependency.
    • Integer weights throughout — no f32 for D-010 determinism.
  • Rationale: Economic role should visibly shape a city's physical form. A financial hub looks different from a mining hub. Population tier prevents cities from being too small to sustain their economic function. Political archetype encodes power structure in spatial form — Corporate settlements are commercially dense, Commission settlements are institutionally heavy. The three-component model is the minimum set to produce legible variety; adding more inputs risks over-constraining the generator.
  • Ticket: T-920
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-191 (atlas pipeline), D-213 (DistrictType enum), D-214 (PoliticalArchetype), D-216 (BlockIrregularity)

D-195: Attractor-Matching Compatibility Matrix for Generative City Placement

  • Date: 2026-05-01
  • Decision: City placement on a planetary surface uses an attractor-matching model. A GeographicAttractor is a terrain feature that increases city placement score at nearby positions. Seven AttractorType variants: RiverMouth, CoastalAccess, RiverCrossing, ValleyFloor, PassEntrance, LakeShore, PlainCenter. A CompatibilityMatrix is a 10×7 scoring table (10 economic_role values × 7 attractor types) whose cells are float weights (0.03.0) representing how strongly that economic role favors that terrain feature. Examples: manufacturing → RiverMouth 2.8, ValleyFloor 2.1; financial → CoastalAccess 2.5, PlainCenter 1.8; agricultural → ValleyFloor 3.0, PlainCenter 2.5. The matrix is authored data (not computed at runtime). Attractor extraction from heightmaps is defined in D-209. Matching algorithm is defined in D-211.
  • Rationale: Terrain-naive city placement produces spatially incoherent worlds. The compatibility matrix gives different city types different terrain affinities, so financial hubs appear on coasts and agricultural cities appear in fertile valleys — without hard-coding placement rules per city type. The weight matrix gives graduated preference, not binary requirement.
  • Amended 2026-06-03 (T-955): the matrix weights and the whole placement-scoring path are integer basis-points, not f32 (D-010 determinism / D-227 save-critical). When T-955 wired match_cities into the deterministic generation cascade, the original f32 scoring became a live cross-platform divergence risk (a near-tie score comparison or the Hungarian's f32 reductions can round differently per platform → a different world from the same seed). Now: CompatibilityMatrix.weights are i32 bps (10000 = 1.0×; the examples above are 28000 / 30000 / 25000 …), GeographicAttractor.strength and terrain_modification_cost are bps, and cell_score / the Hungarian / CityPlacement.score use integer arithmetic. The 0.03.0 affinity semantics are unchanged; only the representation is now integer.
  • Ticket: T-919, T-925, T-955
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-191 (atlas pipeline), D-208 (D8 drainage — attractor extraction), D-209 (feature tag extraction), D-211 (attractor-matching pipeline)

D-196: SettlementClass Enum and Latent Settlement Active/Ghost Logic

  • Date: 2026-05-01
  • Decision: Every settlement (city marker in atlas_city_names) has a SettlementClass that determines how it enters and exits active simulation:
    enum SettlementClass {
        NameLocked,        // Named in wiki; always active regardless of population
        PopulationBudget,  // Active if pop > threshold; ghost if below
        EconomicTriggered, // Active only while economic role condition is met
        OrganicGrowth,     // Emergent; generated by simulation, no prior wiki record
    }
    
    • Active threshold (applies to PopulationBudget): population ≥ 50,000 for a city to receive full Phase 1 district skeleton generation. Below threshold: 1-district stub with Minimal ComplexityTier.
    • Ghost threshold (applies to PopulationBudget): population < 5,000. Settlement is present in atlas data but receives no NPC population; structures are generated as abandoned (Worn/Derelict condition baseline).
    • NameLocked settlements bypass both thresholds — they are always simulated regardless of population (handles narrative-significant small towns).
    • EconomicTriggered settlements collapse to ghost state when their triggering economic condition lapses (e.g., a mining outpost depopulates when the mine is exhausted).
    • OrganicGrowth settlements are not in atlas_city_names at generation time; they are written to the table during simulation when a settlement emerges organically.
  • Rationale: Not every named location needs full generation, and not every simulated location is named. The classification separates authorial intent (NameLocked) from economic reality (PopulationBudget, EconomicTriggered) and simulation emergence (OrganicGrowth). Ghost settlements are important for world texture — abandoned mining towns and depopulated frontier outposts are as legible as thriving hubs.
  • Ticket: T-913
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-191 (atlas pipeline), D-200 (CityGenerationContext), D-203 (BodyWorldState), D-207 (atlas_city_names)

D-197: prosperity_baseline Derivation Formula with Topographic Gradient

  • Date: 2026-05-01
  • Decision: Each city's prosperity_baseline (f32, 0.01.0, used as economic pressure state seed) is derived at generation time from four components:
    1. Economic role base — lookup per economic_role value: manufacturing=0.55, financial=0.70, agricultural=0.50, extraction=0.45, service_mixed=0.60, institutional=0.65, transit_hub=0.60, research=0.65, military=0.55, residential=0.50.
    2. Population log-scale bonus0.04 × floor(log10(pop / 1_000_000 + 1)), capped at +0.12. Larger cities are generally more prosperous.
    3. Topographic gradient bonus — terrain features that historically correlate with prosperity add to the baseline: river mouth +0.08, coastal access +0.06, valley floor +0.04, pass entrance +0.03. At most one terrain bonus applies (the highest-scoring attractor at the city's position).
    4. Seed noise — ±0.05 uniform noise applied last (integer-seeded per city, D-010 determinism).
    • Formula: base + pop_bonus + terrain_bonus + noise, clamped to [0.1, 0.95].
    • prosperity_baseline is not the current prosperity level — it is the simulation's starting point and decay/growth target. The live pressure simulation (D-026) drifts from this value based on trade flows, events, and faction pressure.
  • Rationale: A flat random baseline produces economically incoherent worlds. Terrain-informed prosperity encodes real-world patterns: port cities are wealthy, river-mouth cities are strategic. The log-scale population bonus prevents megacities from dominating without eliminating small-city character. Clamping to [0.1, 0.95] prevents degenerate all-thriving or all-collapsing starting states.
  • Ticket: T-920 (consumer of prosperity_baseline)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-194 (district mix — consumes prosperity_baseline), D-195 (attractor types that produce terrain bonus)

D-198: Economic Simulation Independence from Layer 12 Spatial Data

  • Date: 2026-05-01
  • Decision: The economics simulation (Phase 2, D-026 background tier) runs independently of Layer 1 (galaxy graph) and Layer 2 (location profiles / planetary topography). Economic state is seeded at game start from systems.db data (economic roles, trade flows, corporate presence) and then drifts via the pressure simulation. The generator (Layer 7 district skeleton) reads economic pressure state as an input but does not feed back into the simulation model. The two layers communicate one-way: simulation → generator (pressure state used to set district condition and density), never generator → simulation.
    • Prohibited: Generator code must not modify PressureState. Generator code must not query live simulation state during async background generation tasks (race condition risk). Generator reads a snapshot of pressure state taken at generation dispatch time.
    • Allowed: The generator reads economic_health, prosperity_baseline, industries, and faction_influence from the snapshot. These are read-only inputs to Phase 1 skeleton classification and Phase 2 condition application.
  • Rationale: Bidirectional coupling between generator and simulation creates initialization order dependencies and potential circular references. The one-way data flow (simulation → generator snapshot → generator) keeps both systems independently testable and avoids race conditions in the Rayon thread pool (D-206). The generator is a consumer of economic state, not a participant in economic evolution.
  • Ticket: T-915 (CityGenerationContext reads economic snapshot)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-026 (simulation tiers), D-200 (CityGenerationContext), D-206 (background generation queue)
  • Amended 2026-05-25 (D-233 — structural-fill / condition-overlay two-pass split): The "Phase 2 condition application" language is replaced by a formal two-pass model. Pass 1 — Structural fill (frozen; reads t=0 initial-economics only; re-derivable from seed + initial_economics_snapshot): zone-type per block, building footprint shape+position, building-type vocabulary tags (BuildingTag), founded_era, operations-surface extent (bulk industries), and the labor-demand signal feeding adjacent residential blocks. Pass 2 — Condition overlay (the sanctioned rolling-economy consumer; a paint layer OVER the frozen fill; refreshable on a cadence): BuildingConditionState = New|Maintained|Worn|Derelict|Abandoned; OccupancyState = Full|Partial|Vacant; VegetationEncroachment (Abandoned in appropriate sub-biomes, D-210); feeds the D-100 tile DamageOverlay. Hard wall: the condition overlay CANNOT change a BuildingTag (a mine plant becomes an abandoned mine plant, never an office). The fill generator's signature is fill_chunk(ctx: CityGenerationContext, seed: SeedChain) — NO access to PressureState, price signals, or tâtonnement output. The condition overlay is a SEPARATE Bevy system, triggered by EconEvent (D-180), with its own read set. Cross-ref D-233, D-100, D-180, D-210, D-217. Raised by: Burnelli/Tyre/Gestalt, economic-built-world workshop round 2.

D-199: 6-Field Minimum Economic Read Set for City Generation Context

  • Date: 2026-05-01
  • Decision: When building a CityGenerationContext (D-200), the generator reads exactly 6 fields from the economic pressure snapshot per city. Reading more fields is permitted but these 6 are the minimum required for correct Phase 1 skeleton classification:
    1. economic_role — primary function of the city (determines DistrictType distribution via D-194)
    2. prosperity_baseline — starting economic health (0.01.0, see D-197)
    3. population — city population (determines ComplexityTier ceiling, BlockSkeleton density)
    4. dominant_faction — faction with highest faction_influence at this location (affects Institutional and Restricted district bias)
    5. founding_age_years — years since settlement founding (drives BlockIrregularity via D-216, era distribution)
    6. settlement_classSettlementClass enum value (D-196, determines whether to generate at all)
    • Fields 15 are read from systems.db (bodies table + economics tables). Field 6 is derived at generator dispatch time.
    • All 6 fields must be present before a generation task is dispatched. Missing fields abort the task with a logged error; generation does not proceed with partial context.
  • Rationale: A fixed minimum read set prevents generators from accumulating unbounded dependencies on simulation state. The 6 fields cover the minimum information needed to produce a correctly-classified skeleton. The abort-on-missing-fields rule ensures generator output is always deterministic from a complete context, never silently degraded from a partial one.
  • Ticket: T-915 (CityGenerationContext implementation)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-196 (SettlementClass), D-197 (prosperity_baseline), D-198 (economic simulation independence), D-200 (CityGenerationContext struct), D-237 (authored specialization layer — read-set extended)
  • Amended 2026-05-25 (D-229/D-232/D-233): CityGenerationContext gains morphology_zone, flavor_profile/architecture_flavors, dominant_bulk_class, dominant_production_ubiquity (the record already permits >6 fields).
  • Amended 2026-05-31 (D-237 — authored specialization layer): the read set gains economic_specialization and cultural_specialization (both new columns on system_economy). dominant_faction (field 4) is now sourced from authored values on system_factions where present, heuristic fallback otherwise. economic_specialization is the upstream source of dominant_bulk_class/dominant_production_ubiquity (resolved via specialization_vocabulary, D-233 re-amendment); cultural_specialization feeds the D-232 template pool. Still bounded — three authored fields, all NULL-safe with deterministic fallbacks.

D-200: Three-Tier Execution Model (Build-Time / Runtime-Background / Runtime-On-Demand)

  • Date: 2026-05-01
  • Decision: The generation pipeline operates at three distinct execution tiers with no cross-tier mutation:
    1. Build-time (Python pipeline): Runs make regen-db. Produces systems.db tables including atlas_city_names, atlas_province_boundaries, body_radius_km. Output is a static artifact committed to the repo. Never runs during gameplay. (Amended T-963, D-202: atlas_body_heightmaps is no longer produced — elevation is now a per-body 16-bit heightmap.png file, not a DB table.)
    2. Runtime-background (Rayon thread pool, D-206): Triggered by content-spidering events (player approaches a system, NPC names a location, news ticker references a place). Runs D8 drainage analysis (D-208), attractor extraction (D-209), settlement placement, and Phase 1 district skeleton generation. Output goes into BodyWorldState cache (D-203). Transparent to main tick thread.
    3. Runtime-on-demand (main tick thread): Triggered when the player crosses a chunk boundary. Runs Phase 2 chunk fill for the approaching chunk. Must complete within 5ms. Reads from BodyWorldState cache (always populated before this tier runs).
    • Tier boundary rules:
      • Build-time outputs are read-only at runtime.
      • Runtime-background tasks read from systems.db and write to BodyWorldState only.
      • Runtime-on-demand reads from BodyWorldState and writes to the active ECS world (chunk tile data, NPC spawns).
      • No tier may write to a higher tier's outputs. No circular dependencies.
    • CityGenerationContext struct (see below) is the data contract between tiers 1→2.
    struct CityGenerationContext {
        city_id: u64,
        political_archetype: PoliticalArchetype,
        prosperity_baseline: f32,
        surrounding_biome: SettingType,
        road_entry_directions: Vec<u8>,     // compass octants (07)
        footprint_radius_km: f32,
        founding_orientation: FoundingOrientation,
        world_tier: WorldTier,
    }
    
  • Rationale: Three tiers with explicit boundaries eliminates the "where does this code run?" question. Build-time is deterministic and committable. Runtime-background is parallelizable. Runtime-on-demand has strict latency budgets. Cross-tier mutation would create race conditions between the Rayon thread pool and the main tick thread.
  • Ticket: T-915
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-026 (simulation tiers), D-194 (district mix), D-203 (BodyWorldState), D-206 (background generation queue), tyre-sw1r3.md Layer 5/6 architecture

D-201: Spatial Hierarchy — Eight Tiers with Locked Dimensions

  • Date: 2026-05-01

  • Decision: The generation pipeline has eight spatial tiers from galaxy to tile. Dimensions are locked and cannot be changed without amending this decision:

    Tier Name Dimensions Purpose
    1 Galaxy 300 systems Galaxy graph, gate topology, cultural corridors
    2 System Orbital mechanics, body catalog
    3 Body ~512×256 pixels (equirectangular heightmap) Planetary topography, climate zones
    4 Province ~50500km Province boundaries (watershed-derived, D-205), biome zones
    5 Settlement ~130km radius City footprint, district layout
    6 District 2048×2048 tiles (2048m, 4.19 km²) 4×4 quarters — large urban division (D-222)
    7 Quarter 512×512 tiles (512m, 0.262 km²) 4×4 blocks — settlement footprint cell, Phase 1 skeleton grid (D-222)
    8 Block 128×128 tiles (128m) Generator planning unit, 2×2 chunks (D-222)
    9 Chunk 64×64 tiles (64m) Streaming/serialization unit (D-222)
    • Tiers 69 (District → Chunk) are the sub-settlement spatial hierarchy, canonical in D-222 — renamed/resized from the original D-094 ladder (the old 512m "District" is now the Quarter; District is now 2048m), at the Tile = 1m / Subtile = 0.5m scale of D-220. This decision formalizes Tiers 15 with equivalent lock status.
    • Tier 3 heightmap resolution (512×256 equirectangular working grid; 1024×512 PNG) is the canonical format. Deviation requires amending D-191. Amended (T-963, D-202): the canonical stored heightmap is now a per-body 16-bit grayscale heightmap.png at 1024×512 carrying native elevation (the prior PNG was a 1024×512 RGB relief, now renamed reliefmap.png). PNG dimensions are unchanged (1024×512); Layer 1 downsamples to the 512×256 working grid. This amendment is the explicit deviation gate being satisfied — format/content changed, resolution preserved.
    • Tier 4 province boundaries are pre-computed at build-time and stored in atlas_province_boundaries (D-205). They are not re-computed at runtime.
    • Amended 2026-06-14 (D-243): Tier 4 renamed Region → Province — it always was the watershed/political tier (D-205), and the name "Region" is now reserved for D-243's ~205 km metric containment cell (the top hard block of the absolute scale ladder + the climate/weather lockdown scale). Province is an overlay painted across regions, not a containment rung; the two sit at overlapping scales but are different kinds (irregular lore-bearing boundary vs fixed metric grid cell). D-243 owns the metric containment ladder (voxel→chunk→block→quarter→district→region); Tiers 15 here (Galaxy/System/Body/Province/Settlement) are organizational scopes/overlays, not metric rungs, and the ~1 km RegionProfile scale this record's tier-4 collided with is dropped (its carrier role moves onto the district, D-243 §5).
    • The SettingType enum on DistrictSkeleton is the interface between Tier 5 (settlement planning) and the skeleton cell (the 512m Quarter, Tier 7 — DistrictSkeleton is pending rename to match D-222).
  • Rationale: Locking spatial dimensions prevents the generative layers from drifting in incompatible directions. The heightmap pipeline, atlas pipeline, and district generator all assume these dimensions and would need coordinated migration if they changed. Formalization prevents silent per-system variation.

  • Ticket: T-912 (WorldTier enum), T-913 (SettlementClass)

  • Raised by: Generation cascade workshop (T-897)

  • Cross-reference: D-094 (district hierarchy — Tiers 68), D-191 (atlas pipeline — Tier 3), D-205 (province boundaries — Tier 4), D-208 (D8 drainage — Tier 3 analysis)

D-202: Heightmap BLOB Storage Schema (atlas_body_heightmaps)

  • Date: 2026-05-01
  • Decision: Heightmap elevation data is stored in systems.db as a BLOB in the atlas_body_heightmaps table. Schema:
    CREATE TABLE atlas_body_heightmaps (
        body_id     INTEGER PRIMARY KEY REFERENCES bodies(id),
        width       INTEGER NOT NULL,        -- pixel columns (canonical: 512)
        height      INTEGER NOT NULL,        -- pixel rows (canonical: 256)
        data        BLOB NOT NULL,           -- float32 little-endian, row-major, width×height floats
        sea_level   REAL NOT NULL DEFAULT 0.0,
        imported_at TEXT NOT NULL DEFAULT (datetime('now'))
    );
    
    • data is a float32 little-endian BLOB. Size: width × height × 4 bytes. Canonical: 512×256×4 = ~512KB per body.
    • Values are normalized elevation in [0.0, 1.0]. sea_level is the fraction below which terrain is underwater (default 0.0 = no ocean, overridden per body).
    • The Rust loader reads the BLOB via bytemuck::cast_slice::<u8, f32>() after fetching from SQLite. No endian conversion needed on LE-native systems; the pipeline stores LE explicitly.
    • Only inhabited bodies receive heightmap rows at build-time. Uninhabited bodies are generated on-demand (runtime-background tier, D-200).
    • This table is populated by the import_heightmaps build-time step in the asset pipeline (D-191 §9 pipeline order). It is read-only at runtime.
  • Amendment (2026-05-23, T-963): the DB-BLOB store is superseded by a per-body file-based 16-bit grayscale heightmap.png stored next to the body's other assets (path from bodies.terrain_reference). Rationale for the change: raw float grids committed inside a binary systems.db are the exact binary-merge-conflict trap the asset-pipeline rule warns against, and ~512KB×N bloats the DB; a per-body file matches D-203's on-demand model and keeps systems.db lean. Concretely:
    • Naming fix: the existing color hypsometric render (today's heightmap.png, 1024×512 RGB) is renamed reliefmap.png — it is a relief visualization, not elevation. Display-only.
    • Canonical elevation: a new heightmap.png = 16-bit grayscale (luminance = normalized elevation), 1024×512 (2× per axis / 4× the cells of the old 512×256 sim grid — the PNG dimensions are unchanged from the prior canonical 1024×512 in D-201; only the content changed from an RGB relief to native 16-bit elevation). 1024×512 bounds install size (~190 MB across 267 inhabited bodies) while sub-pixel detail is synthesized by the lower cascade layers. Single source of truth — the reliefmap and all computed geography derive from it, so it is bit-identical/deterministic by construction.
    • Multi-resolution: the stored heightmap is high-res for the lower layers (region/block/tile sample local detail); Layer 1 (continental drainage/basins/mountain-ranges) calls BodyHeightmap::downsample to the GRID_W×GRID_H = 512×256 working resolution first, decoupling continental compute cost (~45ms) from stored resolution.
    • Rust loader: heightmap.rs::load_heightmap_png reads the 16-bit grayscale PNG (via the png crate), normalizes to f32 [0,1]; rejects RGB (a reliefmap can't be misread as elevation). sea_level is stored in the PNG as a tEXt chunk (the heightmap is self-describing), with a caller-supplied default as fallback.
    • atlas_body_heightmaps is dropped; import_heightmaps.py writes the PNG file instead of a DB row. The bake runs in the content pipeline (numpy/scipy) once; the runtime cascade is pure Rust loading the file.
    • Implementation status (T-963): Consumer done — heightmap.rs::load_heightmap_png reads the 16-bit grayscale PNG + sea_level tEXt chunk, rejects RGB, downsamples for Layer 1. Producer done — import_heightmaps.py is the bake (rename legacy heightmap.pngreliefmap.png for all bodies incl. Sol; for non-Sol inhabited bodies write a fresh clean reliefmap.png + 16-bit heightmap.png from simulate() at the bumped 1024×512 grid). atlas_body_heightmaps dropped via MIGRATION_SQL + removed from systems-schema.sql. Godot client (atlas_viewer.gd) loads reliefmap.png for display. Sim determinism guarded by test_sim_determinism.py.
  • Rationale: Storing heightmaps in systems.db keeps the DB as the single source of truth for all generation inputs, avoids a separate file-fetching path in the Rust server, and allows the pre-push hook (asset pipeline rules) to detect stale heightmap data. The float32 LE layout matches what NumPy and PIL produce natively, minimizing conversion overhead in the Python pipeline. (Superseded by the T-963 amendment above — the file-based model won out because the DB-as-single-source goal conflicts with binary-merge-conflict avoidance and DB size; a per-body committed PNG is itself a queryable, diffable-by-render asset.)
  • Ticket: T-901 (schema), T-906 (import), T-916 (Rust loader)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-191 (atlas pipeline — heightmap source), D-203 (BodyWorldState — consumer), D-208 (D8 drainage — reads this table)

D-203: BodyWorldState Bevy Resource with LRU Cache

  • Date: 2026-05-01
  • Decision: BodyWorldState is a Bevy Resource holding the Layer 12 output for each recently-accessed planetary body. It functions as an LRU (Least Recently Used) cache:
    • Cache capacity: 50 bodies.
    • Memory budget: ~5MB total (50 bodies × ~100KB per entry average). A single body's Layer 12 data includes: processed heightmap (float32 grid, ~512KB pre-downsampled to ~8KB working resolution), river network (RiverNetwork struct: river cells, confluences, mouths), drainage basin polygons, attractor list, province boundary references.
    • Eviction policy: On cache overflow, evict the body with the oldest last_accessed timestamp. Bodies that are the current player location or adjacent-system neighbors are pinned (not evicted).
    • Population: The runtime-background tier (D-200) populates cache entries via Rayon tasks. Main thread reads are always from the cache; main thread code must never perform blocking DB reads for heightmap data.
    • Struct:
      struct BodyWorldState {
          body_id: u64,
          heightmap: Vec<f32>,               // downsampled working grid
          river_network: RiverNetwork,       // D-208 output
          drainage_basins: Vec<DrainageBasin>,
          attractors: Vec<GeographicAttractor>, // D-195 types
          last_accessed: SimTick,
      }
      
    • The resource is initialized empty and populated on demand. Accessing a body not in the cache triggers a background generation task (D-206).
  • Rationale: The D8 drainage analysis (D-208) and attractor extraction (D-209) are expensive (target: ~50ms/body). Running them on the main tick thread would cause frame drops. The LRU cache ensures the main thread only reads pre-computed data. 50-body capacity covers the typical gameplay scenario (player in one system, neighboring system pre-cached) with margin.
  • Ticket: T-917
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-200 (three-tier execution model), D-202 (heightmap BLOB — input), D-206 (background generation queue — populates cache), D-208 (D8 drainage — produces river network)

D-204: body_radius_km Column on bodies Table

  • Date: 2026-05-01
  • Decision: A body_radius_km REAL column is added to the bodies table in systems.db. This value is the mean radius of the planetary body in kilometers, used to:
    • Compute area_count (number of districts a settlement can contain, scales with surface area)
    • Convert province boundary pixel coordinates to real-world km distances
    • Derive the footprint_radius_km field on CityGenerationContext (D-200)
    • Schema change: ALTER TABLE bodies ADD COLUMN body_radius_km REAL (nullable, populated by import step)
    • Fallback derivation (applied when body_radius_km IS NULL): planet_class lookup table with canonical radii:
      • super_earth: 8,000 km
      • earth_like: 6,371 km
      • sub_earth: 4,500 km
      • ocean_world: 6,500 km
      • arid: 5,800 km
      • ice_world: 3,000 km
      • gas_giant: 50,000 km (no settlements)
      • moon: 1,737 km
      • other / unknown: 6,371 km (Earth default)
    • Fallback is applied at query time, not stored back. The column remains NULL until authoritative data is available.
  • Rationale: Surface area scales with radius squared; a body twice Earth's radius has four times the potential settlement density. Without this field the generator must use a flat default for all planets, producing physically implausible city counts on super-earths and moons alike. The fallback ensures the generator works before all bodies have explicit radius data.
  • Ticket: T-905 (schema), T-910 (populate from planet_class fallback)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-191 (atlas pipeline — body catalog), D-200 (CityGenerationContext — footprint_radius_km)

D-205: Province Boundary Pre-Computation (atlas_province_boundaries)

  • Date: 2026-05-01
  • Decision: Province boundaries (drainage basin divides) are pre-computed at build time from the D8 drainage analysis (D-208) and stored in atlas_province_boundaries:
    CREATE TABLE atlas_province_boundaries (
        body_id   INTEGER NOT NULL REFERENCES bodies(id),
        basin_id  INTEGER NOT NULL,
        path      TEXT NOT NULL,    -- JSON array [[row, col], ...] pixel-space polyline
        area_pct  REAL NOT NULL,    -- fraction of body surface area in this basin
        PRIMARY KEY (body_id, basin_id)
    );
    
    • Boundaries are stored as pixel-space polylines in the same [row, col] convention as markers.json (D-191 §8 canonical format).
    • area_pct is the fraction of the body's total surface area contained within this drainage basin.
    • Province boundaries are the basis for district-level political zoning and cultural corridor assignment at Tier 4 (Region) in D-201.
    • Province count target: 412 provinces per inhabited body, derived naturally from watershed analysis. Bodies with less topographic relief (plains worlds, ocean worlds) produce fewer, larger provinces.
    • At runtime: Province boundaries are read from atlas_province_boundaries at generation dispatch time and cached in BodyWorldState as drainage_basins (D-203). They are not re-computed at runtime.
  • Rationale: Province boundaries define the cultural geography of a world — the mountain ranges and river systems that separated civilizations and produced distinct regional identities. Pre-computing them at build time keeps the runtime-background tier focused on city placement and district generation rather than watershed analysis. Storing as polylines (not rasterized masks) keeps the table compact and human-readable.
  • Ticket: T-904 (schema), T-907 (populate from watershed analysis)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-191 (atlas pipeline), D-201 (spatial hierarchy — Tier 4 Region), D-203 (BodyWorldState — caches province data), D-208 (D8 drainage — source of basin divides)

D-206: Background Generation Priority Queue and Rayon Thread Infrastructure

  • Date: 2026-05-01
  • Decision: All non-urgent generator work runs through a prioritized Rayon thread pool:
    • Thread count: available_parallelism - 2, minimum 1. Reserves 2 cores for the main tick thread and Bevy scheduler.
    • Priority queue: Four levels: Immediate (player will arrive within 1 game-minute), High (player will arrive within 5 minutes), Medium (player is in the same system), Low (player has seen or heard of this location via NPC or news). Work items at higher priority pre-empt lower-priority items.
    • Work item types: AnalyzeBody(body_id) (D8 drainage + attractor extraction), GenerateSkeleton(city_id, context) (Phase 1 DistrictSkeleton), FillChunk(district_id, block_pos) (Phase 2 chunk fill for pre-loading).
    • Event-driven pre-generation: A SystemNameIndex (Aho-Corasick automaton over all body/system names from systems.db) scans NPC dialogue output and news ticker text. When a scan match hits, the referenced body is queued at Low priority if not already cached. This is the mechanism by which "NPC mentions a place → player travels there → world is already generated on arrival."
    • Completion notification: Completed tasks send a GenerationComplete event to the main tick thread via a crossbeam channel. The main thread drains this channel once per tick.
  • Rationale: The Rayon thread pool handles the D-200 runtime-background tier. The priority queue prevents low-priority speculation from blocking urgent work (player approaching). The Aho-Corasick name index enables cheap always-on scanning — NPC dialogue is low-bandwidth enough that scanning every output line has negligible cost. Pre-generation triggered by narrative content (NPC mentions a place) is the mechanism for making the world feel pre-existing rather than loading-on-demand.
  • Ticket: T-924 (background queue), T-926 (SystemNameIndex)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-200 (three-tier execution model — runtime-background tier), D-203 (BodyWorldState — output of background tasks), D-208 (D8 drainage — enqueued as AnalyzeBody)

D-207: Fully Generative Placement — markers.json Stripped to Topographic Features

  • Date: 2026-05-01
  • Amendment (2026-05-22, D-223): superseded in part. markers.json no longer retains topographic-feature geometry — it is reduced to names only (a flavored name pool). River/mountain positions are derived from the heightmap + drainage (D-208); settlement positions come from the economic sim + placement (D-211). The reserved=1 pinning and the 6 hand-authored templates (Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade) are removed — their bodies remain as ordinary named bodies, only the authored machinery is gone. See D-223.
  • Amendment (2026-07-16, D-242): the corp_id column and the "corp HQ cross-reference" (T-909, populate_atlas_city_names_corps) this record's Decision section describes below are removed. That insert path (kept alive through D-223's amendment above) had no UNIQUE(body_id, name) and produced duplicate co-named "cities" — one atlas_city_names row per corp HQ (e.g. 10 Groombridge rows on GJ380c). The corp↔settlement relationship now lives with the corp (corporations.headquarters_body/headquarters_city_id), not as a row in the city pool. atlas_city_names.corp_id is left in the schema (additive-safe) but is permanently NULL going forward — see D-242.
  • Decision: The atlas_city_names table replaces the authored city positions in markers.json. Going forward, markers.json files contain only topographic features (rivers, oceans, mountain ranges — per D-191 §8 canonical format). City positions, road networks, and rail networks are NOT authored in markers.json; they are generated from the terrain data and stored in atlas_city_names and derived tables.
    CREATE TABLE atlas_city_names (
        id            INTEGER PRIMARY KEY,
        body_id       INTEGER NOT NULL REFERENCES bodies(id),
        name          TEXT NOT NULL,
        economic_role TEXT NOT NULL,
        population    INTEGER NOT NULL,
        corp_id       INTEGER REFERENCES corporations(id),  -- nullable, corp HQ if applicable
        reserved      INTEGER NOT NULL DEFAULT 0,           -- 1 = reserved for authored scenario use
        kind          TEXT NOT NULL DEFAULT 'city'          -- 'capital' | 'city'
    );
    
    • name and position in the markers.json come from different sources: position is generated by the city placement algorithm; name is either authored (wiki), LLM-generated (Gemma 2 naming pipeline), or reserved (corp HQ name). The split allows position generation and naming to run independently.
    • corp_id links to the corporations table when a city is a corporation's headquarters or major hub city.
    • reserved = 1 rows are scenario-specific cities that must not be relocated by the generation algorithm; the generator places other cities around them.
    • markers.json authored city data (hand-written city center positions in the 6 hand-authored templates: Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade) is migrated to atlas_city_names and treated as reserved = 1 rows. The markers.json files for these templates then have their city arrays cleared.
  • Rationale: Authored city positions in markers.json created a split between hand-authored content and procedurally generated content that was impossible to query, diff, or validate consistently. Moving city identity to a table allows: SQL joins against economic data, corp HQ cross-references, scenario reservations, and attractor-matching validation. The topographic features (rivers, mountains) remain in JSON because they are polygon/polyline geometry better suited to JSON than relational rows.
  • Ticket: T-902 (schema), T-908 (populate from wiki), T-909 (corp HQ cross-reference)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-191 (atlas pipeline — markers.json canonical format), D-196 (SettlementClass — column in atlas_city_names), D-200 (CityGenerationContext — reads from this table)

D-208: D8 Priority-Flood Drainage Routing — Layer 1 Empty World

  • Date: 2026-05-01
  • Decision: Drainage routing (the computation of flow direction, flow accumulation, and river network extraction) uses the D8 priority-flood algorithm on the body's float32 heightmap. This is the first Layer 1 computation run on a body before any city placement or attractor extraction.
    • Algorithm: D8 assigns each cell's flow direction to one of 8 neighbors based on the steepest descent. Priority-flood fills depression cells before routing to avoid spurious sinks. Flow accumulation is the count of upstream cells draining through each cell.
    • River threshold: A cell is classified as a river cell when flow_accumulation > 200. This threshold produces river networks of realistic density on the 512×256 Layer-1 working grid (D8 runs at 512×256, downsampled from the 1024×512 stored heightmap per D-202 amended T-963).
    • Outputs stored in BodyWorldState.river_network:
      • river_cells: Vec<(u16, u16)> — pixel positions of all river cells
      • confluences: Vec<(u16, u16)> — positions where two or more rivers merge
      • mouths: Vec<(u16, u16)> — positions where rivers reach sea level or the heightmap edge
    • Province/basin output: Cells that divide adjacent drainage basins become province boundary candidates (D-205). Boundaries are traced as polylines after flow accumulation is complete.
    • Performance target: ~50ms per body on a single Rayon thread for canonical 512×256 resolution.
    • Determinism: Integer-only arithmetic throughout. No f32 in the priority-flood comparisons (use integer-scaled elevation). D-010 compliant.
  • Rationale: D8 is the standard GIS drainage routing algorithm and produces the river networks that drive attractor scoring (river mouths, confluences = high-value RiverMouth attractors). The flow accumulation threshold of 200 was chosen empirically against the Lendel heightmap to produce ~815 named rivers per inhabited body — enough for cultural geography without over-fragmenting the landscape.
  • Ticket: T-918
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-195 (attractor types — river mouth is highest-scoring), D-203 (BodyWorldState — output stored here), D-205 (province boundaries — derived from drainage divides), D-209 (feature tag extraction — reads river network)

D-209: Geographic Feature Tag Extraction (7 Settlement Attractor Tags)

  • Date: 2026-05-01
  • Decision: After D8 drainage analysis, 7 AttractorType tags are extracted from the heightmap + river network and stored as Vec<GeographicAttractor> in BodyWorldState. Each attractor has a position [row, col] and a strength: f32 (0.01.0) derived from local terrain quality.
    • Extraction rules per type:
      • RiverMouth: cells in river_network.mouths. Strength = flow_accumulation[cell] / max_flow_accumulation (normalized). Always high-value.
      • CoastalAccess: cells within 3 pixels of a sea/ocean polygon (from oceans[] in markers.json), not already RiverMouth. Strength = 0.6 baseline + coast length bonus.
      • RiverCrossing: cells at confluences or where a river crosses a topographic saddle. Strength = flow_accumulation / max_flow_accumulation × 0.7.
      • ValleyFloor: local elevation minima in non-river cells with positive habitability score (slope < 5°, elevation 1060% of range). Strength = habitability score.
      • PassEntrance: local saddle points between adjacent drainage basins. Strength = inverse of elevation percentile (lower passes score higher).
      • LakeShore: cells adjacent to lake polygons in markers.json. Strength = 0.5 baseline.
      • PlainCenter: cells in flat terrain (slope < 2°) away from all other attractors. Strength = habitability score × 0.4.
    • Sub-biome classification (vegetation, aridity, temperature zones) is derived in parallel and stored as SubBiomeVariant on the attractor for use by the ZonePalette modifier system (D-101).
  • Rationale: The 7 attractor types cover the terrain features that historically determine city placement. Their extraction from the heightmap is deterministic and cheap given the D8 analysis is already complete. The strength normalization ensures attractor scores are comparable across bodies with different elevation ranges.
  • Ticket: T-925 (types), T-919 (matching pipeline that consumes these)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-195 (CompatibilityMatrix — attractor types), D-203 (BodyWorldState — storage), D-208 (D8 drainage — prerequisite), D-211 (attractor-matching pipeline — consumer)

D-210: Sub-Biome Variant Classification and terrain_modification_cost

  • Date: 2026-05-01
  • Decision: Each GeographicAttractor (D-209) carries a sub_biome: SubBiomeVariant tag that classifies the local terrain more finely than the top-level SettingType. This drives two systems: ZonePalette modifier selection (which visual variant to use) and terrain_modification_cost (how expensive it is to build infrastructure at this location).
    • SubBiomeVariant values: TropicalWet, TemperateForest, TemperateGrassland, BorealForest, Tundra, Desert, Savanna, Alpine, Wetland, CoastalLowland, Volcanic.
    • terrain_modification_cost: f32 (1.0 = baseline, higher = more expensive): derived from sub-biome + local slope. Flat grassland = 1.0. Volcanic = 4.5. Wetland = 3.2. Alpine = 3.8. Coastal lowland = 1.4. Used by the attractor-matching pipeline (D-211) to penalize high-cost terrain for economically marginal cities.
    • Sub-biome classification uses: elevation percentile (of body total), local slope, moisture proxy (distance to nearest river mouth or coast), and temperature proxy (latitude of the equirectangular pixel).
    • Sub-biome data is stored in BodyWorldState alongside the attractors; it is not a separate DB table.
  • Rationale: Two cities on coastal terrain feel different when one is a tropical lowland port and the other is a cold Nordic fjord. Sub-biome tags enable the ZonePalette to select the correct visual register (T6 beach/coastal with tropical modifier vs T7 mountain/high with coastal modifier). The terrain_modification_cost gives the generator a principled reason to prefer some attractor positions over others for lower-prosperity cities.
  • Ticket: T-919 (attractor matching — uses terrain_modification_cost)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-101 (ZonePalette modifier system — consumes sub_biome), D-195 (attractor types), D-209 (feature tag extraction — assigns sub_biome)

Amended 2026-07-26 (T-1116 — surrogate-anchor-at-cost carve-out for the Layer-2 road graph): the road graph's routing grid (RouteGrid, road_graph.rs) downsamples terrain_modification_cost onto coarse routing cells (up to scale² native pixels per cell) and marks a cell IMPASSABLE when it is water-majority. A settlement's exact placement pixel is always land (D-209's attractor extraction guards !ocean_mask on every real attractor type), but at this downsample granularity the settlement's routing cell can still be majority water — a coastal-cell/downsample artifact, not a placement error. Ruling: a routing anchor (A* start/goal) whose own cell is water-majority MAY be surrogated to the nearest passable cell within a bounded search radius, priced as an explicit access-cost surcharge on the routed edge's reported length (never free, modeling a short quay/causeway link) — but general water-cell transit stays IMPASSABLE exactly as before; only the anchor lookup for a start/goal settlement is relaxed, not open-ocean pathfinding. This was adjudicated as a routing-layer relaxation rather than a D-211 placement nudge specifically because D-211's positions are seed-derived and already land-guaranteed for every real attractor path — moving them would touch a different layer's invariant to fix a downsample artifact that belongs to the router. Known gap flagged, not fixed here: D-211's Phase-4 synthetic-overflow path (synthetic_attractor) computes its position by grid arithmetic alone, with no terrain/ocean_mask check at all — unlike every real attractor type, a synthetic-overflow settlement is not guaranteed land. The routing relaxation above still degrades that case gracefully (surrogate-anchors it or leaves it unrouted beyond the search radius), but the placement guarantee gap itself is D-211's, unticketed as of this amendment.

D-211: Attractor-Matching Five-Phase Pipeline for Settlement Placement

  • Date: 2026-05-01
  • Decision: Given a body's Vec<GeographicAttractor> and a set of cities from atlas_city_names, settlement placement runs a five-phase matching pipeline:
    1. Score matrix build: Compute a city_count × attractor_count score matrix. Each cell = CompatibilityMatrix[economic_role][attractor_type] × attractor.strength × (1.0 / terrain_modification_cost).
    2. Tier A greedy assignment: For each city with SettlementClass::NameLocked or population ≥ 1,000,000, assign the highest-scoring unoccupied attractor using greedy selection. These cities must be placed first to anchor the spatial layout.
    3. Hungarian algorithm for Tier B+C: Apply the Hungarian algorithm to the remaining cities (population 50,000999,999) and remaining attractors. Produces optimal global assignment maximizing total score.
    4. Synthetic attractor overflow: Cities that cannot be matched to a real attractor (attractor pool exhausted) receive a synthetic PlainCenter attractor generated at a position that respects minimum city spacing (15 pixels minimum on 512×256 grid = ~50km minimum separation).
    5. Name fulfillment check: After placement, verify that all atlas_city_names entries for this body have been assigned a position. Log a warning for any unplaced city.
    • Two-tier mismatch flagging: If a matched city-attractor pair has score < 0.35, log a WARNING (below expected quality). If score < 0.15, log an ERROR and flag for manual review. Generation proceeds in both cases; the flags are for content auditing, not hard blockers.
    • Output: Vec<CityPlacement { city_id, position: [row, col], attractor: AttractorType, score: f32 }> written to atlas_city_positions at build time.
  • Rationale: Greedy-first for large/locked cities ensures anchor cities (capitals, corp HQs, wiki-named cities) are placed at terrain features that match their lore role. Hungarian for medium cities finds the globally optimal assignment, not just locally optimal. Synthetic overflow prevents the algorithm from failing on bodies where city count exceeds natural attractor count (dense, flat worlds). The two-tier warning system enables content QA without blocking generation.
  • Ticket: T-919, T-925
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-195 (CompatibilityMatrix), D-207 (atlas_city_names), D-209 (GeographicAttractor — input), D-210 (terrain_modification_cost — input)

D-212: TerritorialStatus Priority-Ordered Derivation Algorithm

  • Date: 2026-05-01
  • Decision: Each Province (watershed-derived drainage basin, D-205) receives a TerritorialStatus value derived by priority-ordered classification at generation time:
    enum TerritorialStatus {
        CommissionControlled,  // Commission faction_influence ≥ 0.6 in this province
        CorpTerritory,         // Single corporation faction_influence ≥ 0.5
        ContestedZone,         // Two or more factions each ≥ 0.3, no dominant faction
        FrontierUnclaimed,     // No faction with influence ≥ 0.2
        IndigenousHeld,        // Cultural corridor has indigenous autonomy flag
        Derelict,              // population_density < 0.01 AND no faction ≥ 0.1
    }
    
    • Classification applies checks in priority order: CommissionControlled checked first, Derelict last. The first condition that is true sets the status.
    • placed_at_generation: bool flag on Province distinguishes classification at build time (true) from runtime re-classification during simulation (false). Build-time status is the starting state; simulation can change it, and the flag ensures the original classification is recoverable for reset/new-game scenarios.
    • Faction influence values are read from systems.db (economics tables) at build time using the same D-199 economic read pattern.
  • Rationale: Territory status is a high-level descriptor visible to the player on the Atlas overlay (D-191 §7, political zones overlay). It must be derivable from the generation inputs without runtime simulation state. The priority-ordered algorithm ensures clear, predictable classification — no ambiguous provinces. The placed_at_generation flag enables the game to show "how this province was at settlement time" vs. "how it is now."
  • Amended 2026-06-05 (T-956 — implementation): two changes from the original spec.
    1. New variant AutonomistHeld added between FrontierUnclaimed and IndigenousHeld. The original four control buckets (Commission/Corp/Contested/Frontier) predate the richer faction canon (wiki/factions/): the Compact of Westphalia is a self-governing autonomist bloc that rejects Concord Assembly authority — it governs its systems firmly, so it is neither CommissionControlled (it is the Assembly's rival), nor FrontierUnclaimed (it is not ungoverned), nor locally ContestedZone (the Compact is locally dominant). AutonomistHeld is its bucket, and gives the Compact its own colour on the political-zones overlay.
    2. Derivation source. The numeric per-faction faction_influence thresholds the original record specifies are not present in the data — only a single authored dominant_faction per system exists (D-237's 8-value vocabulary). So the implementation maps dominant_faction → TerritorialStatus instead (attractor_matching::territorial_status_from_faction), grounded in faction canon: concord_assembly/veil_instituteCommissionControlled (the Assembly is the Reach's central government; the Veil Institute is Assembly-funded and -aligned); syndic_dominantCorpTerritory; compact/compact_sympatheticAutonomistHeld; disputed/mixedContestedZone; independent/NULL/unknown → FrontierUnclaimed. IndigenousHeld and Derelict remain unreachable from dominant_faction alone (they need the cultural-corridor autonomy flag / population density) — deferred. dominant_faction is system-level, so status is uniform across a body's provinces for now; it is still stored per-basin on DrainageBasin.territorial_status (forward-compatible for per-province faction data). placed_at_generation is not yet modelled (no runtime re-classification exists yet).
  • Ticket: T-921, T-956 (implementation + amendment)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-191 (atlas overlay — political zones), D-199 (economic read set), D-205 (Province — this status is a field on it), D-237 (authored dominant_faction source), D-214 (PoliticalArchetype — consumes this)

D-213: FoundingOrientation Enum and Spatial Grid Rotation

  • Date: 2026-05-01
  • Decision: FoundingOrientation describes the primary spatial axis of a city's original street grid, derived from the terrain feature that anchored the founding settlement. It controls the rotation of the district grid skeleton.
    enum FoundingOrientation {
        Coastal { facing_degrees: u16 },   // street grid perpendicular to coastline
        RiverAligned { bearing_degrees: u16 }, // street grid parallel to founding river
        TerrainFollowing,                  // grid rotated to follow local contours
        Cardinal,                          // grid aligned to N/S/E/W (commission-planned)
        Free { bearing_degrees: u16 },     // arbitrary bearing (pioneer settlements)
    }
    
    • facing_degrees and bearing_degrees are integer degrees 0359 (0 = North, clockwise). Integer to preserve D-010 determinism.
    • The founding orientation is derived from the matched attractor type (D-211): RiverMouthCoastal; RiverAligned; CoastalAccessCoastal; ValleyFloorTerrainFollowing; PlainCenter + Commission-controlled province → Cardinal; PlainCenter + other → Free.
    • The district skeleton generator (Phase 1) applies FoundingOrientation as the base rotation for the outermost district ring. Interior districts inherit the orientation unless overridden by a PoliticalArchetype modifier.
    • Hard constraint: Maximum ±45° deviation from the parent orientation per district (same limit as D-096 BlockPlacement.rotation_steps). Beyond ±45°, tile-based pathfinding produces movement artifacts.
  • Rationale: Street grids reflect the terrain and founding logic of the original settlement. Roman camps faced cardinal directions. River towns align with the river. Coastal cities face the water. Encoding this as a named enum rather than a raw angle makes the orientation legible in the data model and debuggable during generation.
  • Amendment (T-1076 §4, 2026-07-16 — RailHeadFacing variant added; the D-242-gated follow-on): the enum gains a sixth variant, RailHeadFacing { bearing_degrees: u16 } — the street grid faces the settlement's rail head. Unlike the five attractor-derived variants, it is assigned post-placement by the Layer-2 road-graph pass (road_graph::assign_railhead_orientations, run in the cascade immediately after build_road_graph): every settlement that is a high-connectivity junction (RoadGraph::high_connectivity_junctions(), incident-edge degree ≥ 3 — the criterion from this record's workshop source, paula-round3.md) has its attractor-derived orientation overridden to RailHeadFacing, with bearing_degrees the octant-snapped compass bearing (0 = N, clockwise; one of the eight 45° octants — integer-only math, D-010; the diagonal band is the |minor|·2 > |major| integer approximation, sound because the consumer snaps octants to quarter-edges anyway) from the settlement toward the dominant incident edge (longest length_cells, ties to the lowest edge index) — the direction the freight frontage faces. The Layer-4 skeleton consumes it via skeleton_gen::railhead_edge — the same octant→cardinal-edge snap as coastal_edge, feeding the same D-234b flush-frontage machinery: blocks on the rail-facing quarter edge present flush to the rail head the way waterfront blocks present flush to the quay (coastal wins if a settlement somehow carries both; variants are exclusive by construction). Deterministic: a pure function of the (already deterministic) road graph.
  • Ticket: T-914; T-1076 §4 (RailHeadFacing)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-096 (DistrictLayoutMode — inherits orientation), D-211 (attractor-matching — derives orientation), D-214 (PoliticalArchetype — may override orientation), D-215 (spatial arrangement patterns — uses orientation), D-242 (the road-graph hub refinement this variant shipped under; T-1076)

D-214: PoliticalArchetype Enum and Settlement Spatial Character

  • Date: 2026-05-01
  • Decision: PoliticalArchetype classifies a settlement's dominant power structure and its physical expression in district layout:
    enum PoliticalArchetype {
        Commission,   // Top-down Commission planning; rectilinear, institutional core
        Corporate,    // Corp-dominated; commercial density, restricted zones, campus blocks
        Pioneer,      // Self-organized; organic growth, mixed use, ad-hoc infrastructure
        Military,     // Garrison or fortification origin; defensible geometry, restricted perimeter
        Academic,     // University or research origin; campus-quad structure, green space
        Industrial,   // Factory-first; large-footprint industrial blocks, worker residential rings
    }
    
    • PoliticalArchetype is derived at generation time from TerritorialStatus (D-212) + economic_role: CommissionControlled province → Commission; CorpTerritoryCorporate; FrontierUnclaimedPioneer; military economic role → Military; research economic role → Academic; manufacturing + extraction → Industrial.
    • When multiple signals conflict (e.g., Commission-controlled manufacturing hub), TerritorialStatus takes precedence over economic_role for archetype derivation.
    • Spatial effect on district mix: See D-194. Each archetype applies weight multipliers to district type selection.
    • AttractorAssignment disambiguation: OrganicGrowth (a DistrictType value and also an EraCause value) is always unambiguous in context. On DistrictType, it means the district grew without a planning mandate. As EraCause, it means the era tag was acquired through organic settlement expansion rather than a discrete historical event. Both usages are permitted; the type system distinguishes them.
  • Rationale: Power structure should be legible in a city's spatial form without the player reading a wiki entry. Commission cities look different from Corporate cities look different from Pioneer cities — not just in palette, but in street geometry, district type distribution, and building scale. Encoding this as a named enum ensures the distinction is consistent across all generation code.
  • Amended 2026-05-31 (D-237 — authored specialization layer): for named systems dominant_faction (a D-214 derivation input via TerritorialStatus/D-199) is now an authored value (8-value vocabulary: concord_assembly | compact | compact_sympathetic | syndic_dominant | veil_institute | independent | disputed | mixed) rather than one the heuristic guesses from hop-distance/currency; the existing derivation remains the fallback for unauthored systems. The archetype mapping itself is unchanged — it now reads a more trustworthy faction for the ~4060 named systems where the heuristic was demonstrably wrong (e.g. Groombridge resolves syndic_dominant → Corporate, not the hop-2 default that would yield Commission). lattice_commission is deliberately not a faction value — the Commission is a regulator, not a governing faction (ACB and Bastion are concord_assembly).
  • Implemented 2026-06-05 (T-956): attractor_matching::political_archetype(territorial_status, economic_role) lands the derivation, stored per settlement on CityPlacement.political_archetype. TerritorialStatus precedence is enforced (a Commission-controlled manufacturing hub → Commission, not Industrial); statuses that don't dictate an archetype (ContestedZone/IndigenousHeld/Derelict) fall through to economic_role, and the new AutonomistHeld (D-212 amendment) → Pioneer (self-organized, no central planner). The "spatial effect on district mix" (D-194 weight multipliers) is consumed later by the Quarter-skeleton generator (T-957).
  • Ticket: T-914, T-956 (archetype derivation + storage)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-194 (district mix — archetype modifiers), D-212 (TerritorialStatus — primary input), D-213 (FoundingOrientation — archetype may override), D-215 (spatial arrangement patterns), D-237 (authored dominant_faction source)

D-215: Five Explicit Political Archetype Spatial Arrangement Patterns

  • Date: 2026-05-01
  • Decision: Each PoliticalArchetype maps to one of five spatial arrangement patterns that govern district adjacency and the placement of landmark multi-block reservations:
    1. Radial core (Commission, Academic): Central landmark (civic square, institutional plaza, or university quad) surrounded by mixed-use rings. Transit spokes radiate outward. Districts are denser near center.
    2. Campus grid (Corporate): Restricted campus block occupies 24 blocks in the district interior. Commercial districts ring the exterior. Worker residential on periphery.
    3. Ribbon development (Pioneer, Industrial): Districts string along a linear feature (river, road, industrial rail). No dominant center. Mixed adjacency at every edge.
    4. Fortified perimeter (Military): Restricted and Secured districts at the edge of the footprint. Open access in the interior core. Single controlled access point per district edge.
    5. Hub-and-spoke (transit_hub economic role, any archetype): Transit district at center, all other district types accessible via direct corridors. Maximum 2-district travel between any two districts.
    • The arrangement pattern constrains block adjacency during Phase 1 skeleton generation. Specifically: the first 23 districts placed in a settlement follow the pattern. Later districts are constrained only by the road network, not by the pattern.
    • Arrangement patterns must vary in angular orientation per seed (not just position) — the same archetype's radial core must not always face the same direction across seeds.
  • Rationale: The 14 D-ready items from the generator-architecture workshop established that spatial arrangement should encode power structure. These five patterns are the minimal set to cover the 6 archetypes (Pioneer and Industrial share ribbon development; hub-and-spoke is a cross-archetype pattern for transit-primary cities). Pattern variation in angular orientation prevents players from pattern-matching settlement layout after the first playthrough.
  • Implemented 2026-06-05 (T-956): the ArrangementPattern enum (the five patterns) and its derivation (attractor_matching::arrangement_pattern, from PoliticalArchetype + transit_hub override) land here, and the chosen pattern is stored per settlement on CityPlacement.arrangement_pattern. The block-adjacency enforcement (constraining the first 23 quarters' layout) is the Quarter-skeleton generator's job and is deferred to T-957; the per-seed angular variation rides on FoundingOrientation (D-213, seed-derived Free bearing).
  • Ticket: T-914 (types), T-956 (enum + derivation + storage), T-899/T-957 (skeleton-gen enforcement)
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-094 (spatial hierarchy — district sizes), D-194 (district mix — archetype modifiers), D-214 (PoliticalArchetype — pattern assignment)

D-216: BlockIrregularity from founding_age — Layout Age Character

  • Date: 2026-05-01

  • Decision: block_irregularity: f32 is a derived value on each block (range 0.01.0) that controls how much a block deviates from the district's canonical grid. It is computed from founding_age_years and PoliticalArchetype. The formula:

    base_irregularity = (founding_age_years / 1000.0).min(1.0)
    archetype_step = match archetype {
        Commission | Military => -0.3,   // suppresses organic deviation
        Corporate | Academic  => -0.1,
        Industrial            =>  0.0,
        Pioneer               => +0.3,
    }
    block_irregularity = (base_irregularity + archetype_step).max(0.05).min(1.0)
    
    • Minimum 0.05 is enforced — no block is perfectly regular, even new Commission-planned settlements.
    • block_irregularity feeds the BlockPlacement.offset magnitude in DistrictLayoutMode::Organic: max_offset_sim_tiles = (block_irregularity × 16.0) as i16.
    • An old Pioneer settlement (age 800+ years) can have block_irregularity ≈ 1.0, producing maximum ±16 sim tile offsets and ±45° rotations. A new Commission district (age < 50 years) will have block_irregularity ≈ 0.05.
    • All arithmetic uses integer-scaled intermediates wherever possible (age is integer years; archetype_step is stored as integer basis points internally). The f32 in the formula above is for documentation clarity only.
  • Rationale: Age is the single most reliable predictor of urban irregularity in the real world. Old cities that grew organically have crooked streets; new planned cities have grids. Encoding this as a formula rather than a lookup table allows continuous variation along the age axis while preserving the political meaning of the archetype modifier.

  • Ticket: T-922

  • Raised by: Generation cascade workshop (T-897)

  • Cross-reference: D-096 (DistrictLayoutMode::Organic — consumes block_irregularity), D-194 (district mix — founding_age is an input), D-214 (PoliticalArchetype — archetype_step source)

  • Amendment (2026-05-26 — founding_age_years backfill, ticket T-1000): founding_age_years is backfilled on every inhabited body in tooling/economy-db/import_economics.py MIGRATION_SQL via COALESCE(events-first, wave-fallback). Events-first: the system's authored historical_events.age_years for event_type = 'colonial_charter' — the canonical founding event authored during the economics built-world workshop. 9 systems in the live DB carry a colonial_charter row, driving 11 inhabited body values; 5 of those diverge meaningfully from their wave fallback (e.g. GJ 144 = 580 vs wave-1 fallback 600, GJ 338B = 590, GJ 380 = 480). Wave-fallback — canonical founding-edge of each wave's range (docs/design/systems-framework-final-miri.md:495499):

    settlement_wave era range (years ago) founding-edge fallback
    wave_1 Core Founding 500600 600
    wave_2 Trade Expansion 300500 500
    wave_3 Working Reach 100300 300
    wave_4 Frontier Push 40100 100
    wave_5 Activation Edge 040 40
    origin Sol — predates the wave taxonomy NULL (Sol excluded per D-236)

    Founding-edge (not midpoint) so a wave-1 body reads as ~600 years old, matching the canonical Reach age. ±50yr drift from per-system reality is negligible for the founding_age_years / 1000 arithmetic above.

D-217: Tile Condition Thresholds (0.63 / 0.43 / 0.23)

  • Date: 2026-05-01
  • Decision: A tile's visual condition is derived from the district's prosperity_score (live pressure simulation value, 0.01.0) using four threshold bands:
    Band Condition prosperity_score range Tile visual state
    1 Intact > 0.63 Clean, undamaged, well-maintained
    2 Worn 0.43 0.63 Scuff marks, minor discoloration, partial repairs
    3 Cracked 0.23 0.43 Visible damage, incomplete repair, graffiti
    4 Broken < 0.23 Structural damage, debris, derelict appearance
    • Cache invalidation: A tile's condition only changes when prosperity_score crosses a threshold boundary (from band N to band N±1). This avoids per-tick visual updates. The simulation checks threshold crossings once per game-minute (D-031 day-phase tick rate).
    • Baseline floor: The block's EraCause sets a minimum condition floor:
      • Decay era: minimum Cracked (no tile in a Decay-era block is ever Intact or Worn without an active renovation event)
      • EmergencyExtension era: minimum Worn
      • All other eras: no floor (condition follows prosperity_score freely)
    • Phase 2 application: Chunk fill applies the baseline condition at fill time. Subsequent condition updates from simulation crossing thresholds are applied as ChunkMutations.tile_overrides.
    • Condition thresholds are authored constants, not computed. Any change to the thresholds (0.63 / 0.43 / 0.23) requires amending this D-record.
  • Rationale: Threshold-crossing invalidation is a standard visual LOD technique that avoids expensive per-frame recalculation. The four bands (Intact/Worn/Cracked/Broken) match the visual fidelity budget for the current art direction — more bands require more tile variants per palette. The era-based floor ensures that historical context is always visible: a Decay-era block cannot spontaneously look pristine from a prosperity spike alone.
  • Ticket: T-923
  • Raised by: Generation cascade workshop (T-897)
  • Cross-reference: D-100 (DamageOverlay — post-condition modification), D-194 (district mix — prosperity_baseline is the seed for prosperity_score)

D-218: WorldTier Enum Canonical Values (Epicenter/Regional/Backwater/Passage/Waypoint)

  • Date: 2026-05-01
  • Decision: The canonical WorldTier enum values are:
    enum WorldTier {
        Epicenter,  // Hub system. Full simulation. High faction pressure. Multi-district cities.
        Regional,   // Regional hub. 14 districts per city. Partial full-budget districts.
        Backwater,  // Small community. Dense isolated settlement. Full sim budget — NOT capped.
        Passage,    // Transit stop. Pass-through. ComplexityTier ceiling: Moderate.
        Waypoint,   // Not simulated until player approaches. ComplexityTier ceiling: Minimal.
    }
    
    • The values Peripheral, Connected, and Core used in generator.rs prior to Sprint 38 are incorrect — they were stubbed values that do not match the workshop design (workshop-outcomes.md §WorldTier and ComplexityTier). They must be replaced with the five canonical values above.
    • ComplexityTier ceiling per WorldTier:
      • Epicenter → Full
      • Regional → Full
      • Backwater → Full (critical: Backwater is network-insignificant, NOT budget-capped; isolated communities can be socially complex)
      • Passage → Moderate
      • Waypoint → Minimal
    • Source of truth: workshop-outcomes.md §WorldTier and ComplexityTier table (generator-architecture workshop, lead decision L-3).
    • All code referencing WorldTier::Peripheral, WorldTier::Connected, or WorldTier::Core must be updated to the canonical values. This includes generator.rs, any tests, and any serialized data that references these variants.
  • Rationale: The three-value stub (Peripheral/Connected/Core) was authored before the generator architecture workshop established the five-value canonical model. The mismatch between the code and the design means any generator code built against the stub types would need rewriting anyway. Correcting it now before the Phase 1 implementation work begins eliminates that rework. The Backwater full-budget exception is architecturally significant: dense isolated communities (mining towns, research outposts) should be as socially rich as regional hubs — their isolation is their drama, not their limitation.
  • Ticket: T-900 (bug fix), T-912 (full enum implementation)
  • Raised by: Generation cascade workshop (T-897). Original workshop-outcomes.md §WorldTier (generator-architecture workshop, lead decision L-3).
  • Cross-reference: D-096 (DistrictLayoutMode — WorldTier is a DistrictSkeleton field), D-097 (GuaranteeAuditResult — tier-conditional guarantees), D-201 (spatial hierarchy — WorldTier assigned at Tier 2 System level)

D-220: Settlement Density Model — Tile Scale, Density Classes, and Vertical Pressure

  • Date: 2026-05-03

  • Decision: Settlement footprint, building height, and basement depth are all driven by a single density scalar on each settlement, derived from population, available land, terrain constraints, and economic pressure. This decision establishes the tile scale, the density classification system, and the rules for how density shapes the built environment.

    Tile scale (amends D-201 Tier 0):

    Unit Dimensions Real-world size Notes
    Subtile 1×1 0.5m × 0.5m Rendering unit — character positioning, furniture placement, visual detail
    Tile 1×1 1m × 1m Spatial unit — generation pipeline, pathfinding, district grids
    Chunk 64×64 tiles 64m × 64m Streaming unit (was 32m at 0.5m/tile; now 64m at 1m/tile)
    Block 128×128 tiles 128m × 128m 2×2 chunks
    District 512×512 tiles 512m × 512m = 0.262 km² 4×4 blocks

    Each tile contains 2×2 subtiles. The generation cascade operates in tiles. The renderer subdivides to subtiles for visual fidelity. All higher tiers (Province, Area, Body) remain as defined in D-201.

    • Amendment (2026-05-22, D-222): the 512m unit this table calls District is renamed the Quarter; District is promoted to a new 2048m tier (4×4 quarters). The Tile/Subtile scale above is unchanged. Throughout this record — including the footprint formula (district_count = footprint / 0.262) and the density examples — "district" denotes the 0.262 km² Quarter cell, not the new 2048m District. See D-222 for the canonical ladder and the lore-vs-code rule.

    Density classes:

    Density is measured in people per km² of settlement footprint. It is the primary driver of settlement character — not population alone. The same population at different densities produces completely different settlements.

    Class Density (ppl/km²) Horizontal character Vertical character Infrastructure cost Who builds at this density
    Frontier ~400 Large gaps between structures, open ground dominant, private roads 1 floor, no basement Very high per-capita (long utility runs, private maintenance) The very rich (villa estates, corp executive compounds, prestige vanity builds) OR the very poor (pre-utility squatter settlements, homesteads with no services). Frontier density is a luxury or an absence, never the default.
    Settled ~1,500 Planned grid, moderate spacing, shared streets, visible utility corridors 1-2 floors, optional basement Efficient — the utility-cost-optimized default Default for most Reach colonies. Every meter of sewer, power conduit, and road is new capital expenditure on a virgin world. Settled density is what you get when infrastructure cost constrains the plan. Kiruna is the archetype.
    Established ~3,000 Mixed residential, shared walls appearing, streets narrowing, green space intentional not incidental 2-3 floors, basement common Amortized — mature infrastructure absorbing incremental growth Mature settlements. Second-generation growth over an existing utility grid. The settlement has existed long enough for the infrastructure investment to pay off and densification to begin. Tiel, Périgueux.
    Dense ~6,000 Shared walls dominant, narrow streets, minimal private outdoor space, commercial ground floors 3-6 floors, 1-2 basement levels (parking, storage, utilities) High per-area but efficient per-capita Economic hubs. Transit nodes, extraction chokepoints, terrain-constrained sites where the jobs pull people in faster than the settlement can spread. Kanazawa, Uzès. Density is a signal that economic gravity is compressing the settlement.
    Compressed ~15,000 Vertical dominant, street canyons, multi-use buildings, public space carved from built volume 6-12+ floors, 2-3 basement levels (metro, parking, utilities, subsurface logistics) Very high per-area, requires vertical infrastructure (elevators, pressurized systems, multi-level utilities) Extreme economic pull. Major corp HQs, gate-adjacent trade hubs, capital worlds. Rare in the Reach. The land itself is too valuable for anything but vertical. Paris, Amsterdam inner ring.

    What drives density — the four pressures:

    1. Utility cost (compressing force). The dominant pressure on new colonies. Infrastructure is expensive per meter. This pushes settlements toward Settled as the floor, not Frontier. Every colony pays this tax unless someone absorbs the cost deliberately.

    2. Economic pull (compressing force). Jobs, trade, resources, transit — anything that draws people to a specific location faster than the settlement can grow outward. The stronger the pull, the higher the density. Corp HQ worlds, gate-adjacent systems, extraction sites all have high economic pull. This is the force that pushes settlements from Settled through Dense to Compressed.

    3. Terrain constraint (compressing or dispersing force). Coastal strips, canyon floors, mountain passes compress settlements along one axis (Bisbee: 2.5km × 300m). Open plains allow radial spread. River valleys elongate. The terrain doesn't set the density class directly — it shapes the footprint geometry within that class.

    4. Wealth/status (dispersing force — at a premium). The only force that pushes density DOWN below the utility-cost floor. Low density in an otherwise dense settlement is conspicuous consumption of space — private utility runs, private road maintenance, private security perimeters. Villa estates, executive compounds, prestige campuses. This is Frontier density as luxury, not as poverty.

    The U-shaped wealth curve:

    Density has a U-shaped relationship with wealth. The very rich and the very poor both live at low density — but for opposite reasons. The rich can afford the infrastructure cost of space. The poor live where no infrastructure exists. Everyone in between clusters at Settled-to-Dense because that's what utility economics demands.

    The player reads this instantly: low-density district on a dense world = money. Low-density district on a frontier world = nothing built yet. Same tile spacing, completely different story — and prosperity_baseline (D-197) disambiguates. High prosperity + low density = wealth. Low prosperity + low density = absence.

    Density → vertical pressure mapping:

    Density drives building height and basement depth via simple thresholds. These are tendencies, not hard limits — individual buildings vary, but the district-level character follows the pattern.

    Density class Typical above-ground floors Typical basement levels Vertical pressure description
    Frontier 1 0 Single-story. Horizontal only.
    Settled 1-2 0-1 Mostly single-story, occasional two-story. Basement where terrain allows.
    Established 2-3 1 Two-story default. Three-story at intersections and commercial streets. Basements standard.
    Dense 3-6 1-2 Multi-story default. Ground-floor commercial. Structured parking appears underground.
    Compressed 6-12+ 2-3 Highrises. Street canyons. Multi-level subsurface: parking, metro, utilities, logistics.

    The transition from "buildings grow out" to "buildings grow up" happens at Dense. Below Dense, the settlement solves population pressure by adding more districts horizontally. At Dense and above, horizontal expansion is constrained (by terrain, by utility cost, by land value) and vertical growth begins.

    The transition from "no basement" to "buildings grow down" happens at Established. Below Established, basements are optional amenities. At Established and above, basements become structural necessities — storage, utilities, parking that can't fit at grade.

    Density derivation:

    The density scalar for a settlement is derived from existing systems.db data:

    base_density = SETTLED_DEFAULT  // 1,500/km² — the utility-cost floor
    
    // Economic pull increases density
    if economic_role in [transit, commercial, manufacturing]:
        base_density *= 1.52.0
    if is_corp_hq_world:
        base_density *= 1.5
    if gate_adjacent:
        base_density *= 1.3
    
    // Terrain constraint modulates
    if terrain_constraint_factor > threshold:  // coastal strip, canyon, etc.
        base_density *= 1.21.8
    
    // Wealth disperses (for specific districts within a settlement)
    if district.prosperity_baseline > 0.8:
        effective_density *= 0.40.6  // villa district within a dense city
    
    // Clamp to class boundaries
    density_class = classify(effective_density)
    

    The exact multipliers are tunable. The structure — base at Settled, economic pull compresses, wealth disperses — is the locked architectural decision.

    Settlement footprint formula:

    footprint_km2 = population / effective_density
    district_count = ceil(footprint_km2 / 0.262)
    grid_side = ceil(sqrt(district_count))
    

    Not all districts within the grid are built. A Settled-density village of 2,000 people covers ~1.3 km² = 5 districts in a 3×3 grid, but only 5 of 9 grid cells contain structures. The remaining 4 are open ground, agricultural land, or wilderness. The fill ratio increases with density class:

    Density class Typical grid fill ratio What fills the empty cells
    Frontier 20-40% Wilderness, private grounds, no development
    Settled 50-70% Agricultural plots, utility corridors, planned expansion reserves
    Established 70-90% Parks, low-density transition zones, institutional grounds
    Dense 90-100% Nearly full — remaining gaps are intentional public space
    Compressed 100% Full. No gaps. Every cell is built.

    Reference calibration (real-world sanity check):

    Real settlement Pop Real area Real density Model class Model area Match quality
    Wadenoijen, NL 695 0.10 km² 6,950/km² Dense 0.12 km² Close
    Kerk-Avezaath, NL 1,250 0.40 km² 3,125/km² Established 0.42 km² Close
    Bisbee, AZ 5,000 1.50 km² 3,333/km² Established 1.67 km² Close
    Kiruna, SE 17,000 11.40 km² 1,491/km² Settled 11.33 km² Near-exact
    Périgueux, FR 30,000 9.82 km² 3,055/km² Established 10.00 km² Near-exact
    Tiel, NL 39,000 13.00 km² 3,000/km² Established 13.00 km² Exact
    Kanazawa DID, JP 466,000 59.00 km² 7,898/km² Dense 77.67 km² Reasonable
    Paris proper, FR 2,040,000 105.00 km² 19,429/km² Compressed 136.00 km² Reasonable

    The model is calibrated to real-world settlements. Discrepancies come from the square-grid assumption (real settlements elongate along rivers, coasts, and roads) and from the discrete density class boundaries (continuous real densities mapped to five bins).

    Interaction with other D-records:

    • D-194 (district mix): District types are still determined by the three-component mix. Density determines how much space those districts occupy and how they're spaced.
    • D-197 (prosperity_baseline): Disambiguates the wealth curve. Same low density reads as luxury (high prosperity) or absence (low prosperity).
    • D-201 (spatial hierarchy): Tile scale amended from 0.5m to 1m with 0.5m subtile. Chunk real-world size doubles from 32m to 64m. All higher tiers unchanged.
    • D-215 (spatial arrangement patterns): The five archetype patterns operate within the footprint that density determines. CompanyTown spine is longer at Settled density, compressed vertically at Dense density.
    • D-216 (BlockIrregularity): Irregularity affects street grid geometry within a district. Density affects building height and spacing within that geometry. Orthogonal — a Dense district can be Organic (irregular streets, tall buildings) or Grid (regular streets, tall buildings).
  • Rationale: Population alone cannot determine settlement character. A village of 2,000 at medieval European density (Kerk-Avezaath, 3,200/km²) and a homestead spread of 2,000 at frontier density (400/km²) produce a 10× footprint difference. In the Reach, utility cost on virgin worlds is the dominant constraint, making Settled (~1,500/km²) the natural floor. The density scalar unifies footprint calculation, vertical pressure, and inter-building spacing into a single derivable parameter. The tile/subtile split preserves 0.5m visual fidelity for character movement and furniture placement without inflating the generation pipeline's spatial grid.

  • Ticket: (pending — implementation tickets to be created when cascade Tier 3/4 work begins)

  • Raised by: Jeroen + Claude, design session 2026-05-03. Grounded against real-world settlement data (Wadenoijen, Kerk-Avezaath, Uzès, Tiel, Périgueux, Kiruna, Kanazawa, Paris, Bisbee, Alice Springs, Brasília).

  • Cross-reference: D-194 (district mix), D-197 (prosperity_baseline), D-201 (spatial hierarchy — tile scale amendment), D-215 (spatial arrangement patterns), D-216 (BlockIrregularity)

  • Dissent: None


D-222: Spatial hierarchy and naming — Subtile to District, lore vs code

  • Date: 2026-05-22

  • Decision: Canonical naming and sizes for the sub-settlement spatial hierarchy. This record is the single source of truth for the ladder; it supersedes the dimensions/naming scattered across D-094 (hierarchy), D-201 (tier table), D-220 (tile scale), and D-066 (dual-scale grid), which are amended to point here.

    The ladder (each tier nests cleanly in the one above):

    Unit Side Composition Real-world analogue Used for
    Subtile 0.5m a footstep server simulation, entity positioning, render detail
    Tile 1m 2×2 subtiles a doorway the unit all sizes are quoted in; generation cascade, pathfinding, grids
    Chunk 64m 64×64 tiles half a block streaming / serialization unit (technical)
    Block 128m 2×2 chunks a city block (~100m real) generator planning unit
    Quarter 512m 4×4 blocks (16) a superblock (≈ Barcelona superilla, 0.262 km²) the cell a settlement footprint is tiled into
    District 2048m 4×4 quarters (256 blocks) a real urban district (4.19 km²) large urban division
    (above) fluid borough / sector / city not a fixed tier — settlements vary too much; groupings above District are settlement-specific

    Nesting rhythm: Chunk→Block is 2×2 (Chunk is a technical sub-block streaming unit); Block→Quarter→District is a uniform 4×4 (the legible "human" ladder).

    Lore ≠ code (load-bearing): the tier names above are code/generation units — fixed grid cells. The same words used in lore, narrative, and UI ("Sova Transit District", "the market quarter") are free-form region labels with no obligation to map to a code tier. A narrative "district" may span several code Quarters, sit inside one, or ignore the grid entirely. Reviewers and the clerk must not reconcile a lore name to a code tier — they are different registers that happen to share vocabulary.

    Rename from prior records: the 512m cell that D-094 / D-201 / D-220 called a District is now the Quarter. "District" is promoted to the 2048m tier (4×4 quarters), matching the real-world scale of a named urban district. The tile scale (Tile = 1m, Subtile = 0.5m) and the doubled footprints established by D-220 are unchanged. Code that uses "District" for the 512m skeleton unit (e.g. DistrictSkeleton) is now misnamed → see follow-up ticket.

  • Rationale: The old hierarchy named only Chunk/Block/District and jumped from a 512m "District" straight to the 130km Settlement, leaving the 512m cell wearing the name of a unit two rungs too large — a real district is kilometres and many neighborhoods, not 16 blocks. Grounding against real sizes (city block ~100m; Barcelona superblock 400m / 9 blocks; real district several km) puts Block at 128m (correct), the 512m cell at Quarter/superblock, and a true District at 2048m (4.19 km²). Leaving everything above District fluid matches reality — boroughs/sectors/cities vary too much to force a fixed generation tier, and the Reach's settlements range from frontier homesteads to compressed capitals. Separating the lore register from the code register lets narrative use "district" naturally without dragging the generator's grid into it.

  • Raised by: Jeroen + Claude, design session 2026-05-22. Grounded against real-world block/superblock/district scales.

  • Cross-reference: D-094 (spatial hierarchy — renamed/superseded), D-201 (tier table — superseded for the sub-settlement tiers), D-220 (tile scale — Tile/Subtile retained; its "district" footprint cell is the Quarter), D-066 (dual-scale — Subtile is the 0.5m grid), D-093 (Sova Transit District — a lore district, not bound to the code tier)

  • Dissent: None


D-223: Authored content as a flavored name pool — markers names-only, hand-authored templates removed

  • Date: 2026-05-22

  • Decision: Per-planet authored content feeds the deterministic generator as a flavored name pool only — never as authored geometry or pinned positions. This supersedes the topographic-geometry retention and the reserved=1 pinning of D-207, and the hand-authored-template references in D-191 §8.

    markers.json → names only. A body's markers.json carries a flavored pool of names (river names, mountain names, settlement names) and no positions or geometry. Positions are not authored anywhere; they are derived or generated:

    • Rivers — courses come from the deterministic D8 drainage (D-208) on the fixed heightmap; the largest rivers take names from the pool.
    • Mountains — positions are a given from the fixed heightmap; they take names from the pool.
    • Settlements — the economic sim runs, the largest population areas are found, the capital and cities are picked by the placement pipeline (D-211), and they take names from the pool.

    The 6 hand-authored templates are removed. Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade were a superseded hand-authored-showcase direction that kept resurfacing and complicated the landscape. Their hand-authored marker positioning, the reserved=1 pinning, and the "6 templates" framing/special-casing are all removed. The bodies themselves remain real places — their wiki pages, lore, corporations (e.g. comptoir-lendel), and names stay and feed the name pool; deleting them would only force regenerating equivalent content. Only the authored machinery is tossed. After this there is no distinguishable category of "template" left — the six are processed by the exact same pipeline and data model (fixed heightmap + names-only markers) as every other body, with no special code path, schema flag, or pinning. They differ only in content richness (more developed lore/names/economic profile), which is a difference of degree shared by any notable body, not a difference of kind. The concept dissolves; that is why it can be removed wholesale rather than carefully migrated.

    No pinned positions anywhere. Determinism comes from fixed heightmaps + deterministic drainage + deterministic economic sim + seeded placement — not from authored anchors. The lore-vs-code split (D-222) governs how pool names attach to generated features: names are free-form labels on generated geometry, not bound to grid cells.

    Sub-city authored set-pieces (e.g. D-093 Sova Transit District, station interiors) follow the same principle when Phase 4 reaches that scale — generated geometry, lore names/roles attached, nothing pinned — to be applied (and prior authored layouts superseded) at that point.

  • Rationale: Authored positions created a hand/procedural split that (per D-207's own rationale) was impossible to query, diff, or validate. Reducing authored content to a name pool removes that split entirely while preserving the flavor that makes places feel hand-made — names follow culture and region, geometry follows terrain and economics. The templates were leftover scaffolding from before fully-generative placement; removing them simplifies the pipeline without losing any canonical place.

  • Implementation: tracked under Phase 4 (epic T-750) — strip markers to names, remove template machinery + reserved pinning, update the atlas pipeline/schema, regen. Historical archives (sprints, workshops, audits, CHANGELOG, atlas proposals) are left untouched.

  • Implementation status (T-951, 2026-05-22): done. All 2,398 markers.json reduced to a names-only pool (25,264 names; 349 city names across 271 bodies). The Python atlas geometry generator (generate_atlas.py) and the LLM naming cluster (gemma_naming.py, naming_core.py, apply_name_fixes.py, their tests/QA, the fix_fewshot_bleed/prune_atlas_features geometry tools, the redundant import_city_names.py, and the run-atlas-naming.sh runner) were retired — the procedural server cascade (Phase 4) supersedes them. The Gemma prompting methodology is preserved in docs/gemma-naming-methodology.md. Shared atlas-DB utilities moved to tooling/planet-gen/atlas_common.py; import_economics.py is now the sole regen-db generator that owns the atlas index — it loads the names pool into atlas_city_names and empties the 8 geometry tables (atlas_cities/roads/railroads/pois/rivers/oceans/mountain_ranges/body_grids), which the cascade fills. population/kind/settlement_class on atlas_city_names are deferred to placement (T-955) SUPERSEDED (D-242, T-1075): both are now install-baked at import time (a documented rank-size derivation from bodies.population; settlement_class defaults PopulationBudget with a small NameLocked override list) — see D-242. The reserved=1 corp-HQ cross-reference stays (corp HQ names are real places) SUPERSEDED (D-242, T-1074): that insert path produced duplicate co-named "cities" (no UNIQUE(body_id, name)) and is removed; the corp↔settlement relationship now lives on corporations, not as a city-pool row — see D-242. A latent duplicate-accumulation bug in name population (no clear + no unique constraint) was fixed with a deterministic rebuild. Sol (system GJ 0) is permanently exempt from the normal generators: it uses real Earth/Mars/Luna geography via the offline sol_import.py (left in place for future scripted integration), so its bodies keep geometry-bearing markers.json as preserved positional config and are skipped by the names-pool importer — Sol names will come from its own integration, not the cascade. make regen-db green.

  • Raised by: Jeroen, 2026-05-22 — resolving the open question on merging preconfigured content into the deterministic cascade.

  • Cross-reference: D-207 (superseded — names-only, no reserved pinning), D-191 §8 (markers format — names-only), D-208 (drainage → river courses), D-211 (settlement placement), D-199 (economic read set), D-222 (lore≠code names on generated geometry), D-242 (corp-HQ cross-reference removed + population/settlement_class baking — both supersede notes above)

  • Dissent: None


D-224: SeedChain — deterministic seed-derivation contract

  • Date: 2026-05-23

  • Decision: All deterministic generation — the Phase-4 world cascade now, and NPC / storyteller / economic generation later — descends from a single master world seed through one shared type, SeedChain, living at server/src/seed.rs (top-level, because seeds are broader than the atlas). It is the only sanctioned way to derive a child seed; the ad-hoc pre-mixing (seed.wrapping_add(C)) that was in skeleton_gen.rs / district_mix.rs is removed, and the doc-comment references to a "SeedChain" in those files become real.

    Mixing primitive. splitmix64 — already used by EntityRng::from_seed_and_id (simulation/rng.rs), chosen there specifically to avoid the wrapping_add collision class where (seed=0,id=N) == (seed=1,id=N-1) — is promoted to a shared pub(crate) function in server/src/seed.rs and reused. One canonical mixer for the whole codebase. (EntityRng keeps its own domainless combine — splitmix64(world_seed) ^ splitmix64(stable_id) — for now; re-expressing it as derive(Npc, stable_id) would be a deliberate, stream-changing migration, not a free cleanup, so it is left as a follow-up.)

    Contract:

    pub struct SeedChain(u64);                        // Copy
    pub enum SeedDomain { Body, Layer1Topography, Layer3Settlement, Layer4Quarter, Block, Npc /* … */ } // u64 tags
    impl SeedChain {
        pub fn root(world_seed: u64) -> Self;          // top of the chain
        pub fn for_body(world_seed: u64, body_id: &str) -> Self; // root + derive(Body, fnv1a_64(id))
        pub fn derive(self, domain: SeedDomain, id: u64) -> Self;
        pub fn atlas_rng(self) -> AtlasRng;            // integer-only LCG stream (D-010)
        pub fn seed(self) -> u64;                      // raw — for SimRng/ChaCha or further derive()
    }
    

    Derivation (load-bearing — pinned, because changing it changes every generated world): derive(domain, id) = splitmix64(self.0 ^ splitmix64(domain as u64)) ^ splitmix64(id). Properties: deterministic; domain-separated (distinct SeedDomain tags never share a stream); full avalanche (splitmix64 on each input); integer-only (D-010 T-4); chainable (root → Body → Layer3Settlement → Quarter → Block). The output is well-distributed, so AtlasRng::new is fed the derived seed directly — no | 1 or golden-ratio pre-mix guard.

    Body identity (the Body domain id). Bodies are identified by a string body_id, not a numeric StableId, so SeedDomain::Body is keyed by FNV-1a (64-bit) of body_id — the repo's standard deterministic &str → u64 convention (matching TemplateId/TriangleId). SeedChain::for_body(world_seed, body_id) (root(world_seed).derive(Body, fnv1a_64(body_id))) is the single sanctioned path; callers must use it rather than inventing their own string→u64 hash, or they would silently derive divergent worlds from the same seed — the very class of nondeterminism this record exists to kill, one level up.

    Stability guards. SeedDomain carries explicit #[repr(u64)] discriminants and is append-only; the unit test seed_domain_discriminants_are_pinned fails CI if any is renumbered (which would re-roll every world). AttractorType likewise carries explicit #[repr(u8)] discriminants because it is cast as u8 as a sort key (features.rs); reordering it would change attractor ordering and flip the cascade golden.

    Scope of effect (verified 2026-05-23): SeedChain changes only the RNG-using layers — the existing skeleton_gen.rs (Layer 4 block placement) and the future Layer-3 settlement placement (T-955). It does not affect Layer 0 heightmaps (produced by the Python planet_simulation pipeline, seeded separately via --seed, committed as heightmap.png files) nor Layer 1 (drainage/features/subbiome are RNG-free — pure functions of the heightmap). The T-952 Layer 0→1 golden fixtures are therefore SeedChain-independent and can be captured in any order relative to the SeedChain work.

  • Rationale: Three seeding paths had drifted apart — AtlasRng (LCG, "callers pre-mix"), EntityRng (correct splitmix64 mixing), and ad-hoc wrapping_add in atlas callers — while the code already named a SeedChain that didn't exist. A single typed derivation chain with domain separation makes every sub-stream reproducible from one world seed, eliminates the (seed,id) collision class wrapping_add invites, and gives the determinism harness (T-952) a stable contract to verify against. Promoting one mixer prevents two divergent implementations.

  • Implementation: T-952 (Phase 4, epic T-750) — server/src/seed.rs (incl. for_body/fnv1a_64), SeedChain threaded through the atlas RNG callers, an extensible cascade harness (run_cascade/CascadeSnapshot/CascadeLayer), and a golden-seed regression test (SHA-256 of heightmap.png + JSON-serialized Layer1Output, run at 256×128 so the river network is non-empty — JSON not msgpack, to match the diffable golden_suite.rs convention). Pre-Phase-5: no savegames exist, so the seed-stream change needs no migration; D-202's schema_version lineage covers future changes once saves exist.

  • Raised by: Jeroen + Claude, /whats-next refinement of T-952, 2026-05-23.

  • Cross-reference: D-010 (determinism — integer-only, seed→identical output), D-200 (three-tier execution model), D-208 (RNG-free drainage), D-223 (names-only pool — placement uses seeded RNG), simulation/rng.rs (EntityRng / splitmix64 precedent)

  • Dissent: None


D-225: Atlas layer-stream proxy — compute-on-demand, mod-first (resolves Q-098)

  • Date: 2026-05-23

  • Decision: How per-body generation-cascade layer data reaches the Godot client for the Phase-4 Atlas progress viewer (T-960). Resolves Q-098. Derived layer data is never baked into the install — that would both bloat the install (~100MB+ for ~267 bodies) and make modded bodies second-class. Instead a server-side layer-stream proxy computes on demand from moddable source files and streams to the client:

    (1) Transport — existing IPC stream + additive message tag. Not a second socket, not a per-frame envelope rewrite. The bridge today carries no message-type discriminator (server→client is always ObserverSnapshot, client→server always Vec<PlayerInput>). The atlas request/response ride the same TCP stream as new message types, disambiguated structurally in v1 (a snapshot has entities/tick; the atlas messages do not). A full BridgeMessage envelope-everywhere migration is deferred — it would be a needless wire break, and client+server co-ship (D-005/D-192) so it can be done later as cleanup.

    (2) Single framed MessagePack response, not chunked. Layer1Output is ~hundreds of KB worst case (512×256), well under the 16 MB frame cap. The raster is not streamed — the client already loads reliefmap.png from disk; the proxy streams only the computed Layer1Output (rivers, basins, attractors + sub-biome). MessagePack matches the rest of the bridge.

    (3) Mod-first source resolution. A new BodySourceResolver with an ordered search: mod dirs (override) → base install (wiki/..., the floor). It reads the body's relative terrain_reference from systems.db (read-only) and returns the first existing <root>/<terrain_reference>. First-party and mod bodies flow through the identical resolve→run_cascade→stream path — the heightmap.png is the sole source of truth, no baked layer data. v1 wires the base root only; the resolver type + search order exist and are tested with a synthetic mod root, so the seam is mod-first from day one.

    (4) Cache via background queue on miss (D-203/D-206). Request → BodyWorldStateCache lookup. Hit → serialize and return synchronously (a serialize, no compute). Miss → enqueue an Immediate AnalyzeBody on the GenerationQueue (D-206 background tier) and reply Pending; push the layer response when the completion lands. The ~45 ms cascade must never run on the tick thread (D-203: no blocking CPU/DB for heightmap data on main). The client shows the existing "TERRAIN DATA PENDING" panel until the response arrives. Eviction → recompute (always valid — determinism guaranteed).

    (5) Whole Layer1Output per response; client composites additive overlays. The layers are produced together in one drainage pass, so per-layer requests save no compute and only add round-trips. Overlay toggles (heightmap + rivers + attractors + sub-biome shown together) are a pure client-side render concern (the existing _overlay_visibility pattern). The request carries body_id + up_to_layer (a forward-compat seam; v1 honors Topography).

  • Critical-path dependency: the proxy is inert until gen_queue.rs::run_work_item's AnalyzeBody actually runs run_cascade → builds BodyWorldState → populates the cache (today a documented stub, deferred from T-142), and GenCompletion::BodyAnalyzed carries the computed state, not just body_id. This activation is the long pole and is tracked as its own ticket blocking T-960.

  • Rationale: Baking privileges first-party content (a mod body cannot ship baked artifacts it cannot produce) and adds install bloat. Computing from the moddable heightmap on demand — the cascade is deterministic and ~45 ms, and D-200/D-203/D-206 already provide the background-compute + LRU tiers — keeps mods first-class, adds zero storage, and uses the architecture as intended. Reusing the existing IPC stream (vs a second socket) avoids a parallel connection lifecycle for an occasional, user-initiated, latest-wins-irrelevant request.

  • Deferred / spun off: the full BridgeMessage envelope-everywhere migration (later cleanup, not this ticket); the mod content catalog — mods adding new bodies need body rows + terrain_reference discoverable, but systems.db is binary / source-canonical (D-189) and mods cannot append to it → Q-099. D-225 resolves mod file resolution only; base-install resolution is enough to ship T-960.

  • Implementation: T-960 (client viewer + proxy) plus the AnalyzeBody activation ticket. New surface: server/src/atlas/source_resolver.rs, server/src/atlas/layer_proxy.rs; gen_queue.rs run_work_item activation + GenCompletion payload; bridge receive/send branching; client protocol.gd / sim_bridge.gd / atlas_viewer.gd.

  • Raised by: Jeroen (mod-first directive) + Tyre (design pass) + Claude, /whats-next refinement of T-960, 2026-05-23.

  • Amendment (2026-06-12, extension constraint — fable-ous.md S-10): decode_inbound (bridge/mod.rs:38-74) distinguishes the two inbound message types purely by MessagePack shape (array = Vec<PlayerInput>, map = AtlasLayerRequest) and serde ignores unknown map fields — so a future second map-shaped inbound type with overlapping required fields would silently mis-decode as an atlas request. Constraint: the next inbound message type must introduce a tagged envelope (or a required marker field) rather than extending shape-based detection.

  • Amendment (2026-07-23, body-map-viewer workshop — deferred constraint DISCHARGED; see D-255): the 2026-06-12 constraint above ("the next inbound message type must introduce a tagged envelope / a required marker field") is now executed by D-255. The step-canvas payload cannot ride the existing district_window windowed carrier (measured 21×–563× over the ~30 KB windowed ceiling across the three canvas sizes), so it introduces a required marker field (step_canvas: bool on a new StepCanvasRequest inbound variant + a dedicated StepCanvasResponse outbound message), extending the same star_map/city_names/browse discriminated-shape pattern. This is the sanctioned tagged-envelope path D-225 deferred, now built — no note change beyond recording that the constraint is discharged there.

  • Cross-reference: Q-098 (resolved by this), Q-099 (mod content catalog — spun off), D-191 (Atlas viewer), D-166 (per-layer Atlas progress viewer), D-200 / D-203 (three-tier execution, LRU cache), D-005 / D-192 (client+server co-ship — no version handshake), D-224 (SeedChain — feeds the cascade the proxy runs), D-255 (body-map-viewer render architecture — discharges the tagged-envelope deferral)

  • Dissent: None


D-226: Live-pause inspection harness — agent-navigable, real-UI debug/review

  • Date: 2026-05-24

  • Decision: A multi-layer harness that reuses the real client UI so both a human and an automated agent can review/debug deterministic server-computed state, by attaching to the live (auto-pausing) server. It supersedes the offline file-dump idea floated during D-225: the review tool is the production tool (zero divergence), inspecting live data through the real path rather than stale dumped artifacts. The stack, built bottom-up:

    (1) Live-pause substrate. The client attaches to the running server; entering a fullscreen implant app (the Atlas) auto-pauses the sim. Pause freezes the world-advancing tick phases (Movement / Simulation / Economy / Storyteller / Knowledge / TickAdvance) but keeps PreInput (the gen-drain), Input (to receive resume), Snapshot and PostSnapshot (the bridge) alive — so the UI keeps fetching data while the world is frozen. Built on the existing TickRate::Paused + the input.rs paused-allowlist seam, triggered by HudGroups::gameplay_occluded (D-170). For static Layer-1 geography pause is only a compute-saver; for dynamic state (economics) a stable frozen snapshot is essential.

    (2) Data — layer-stream proxy (D-225). Streams Layer1Output (later economics state, save state) from server to client on demand, mod-first, no bake.

    (3) Human-visual viewer (T-960). The Atlas renders cascade layers as additive, toggleable overlays (relief base + rivers + drainage basins + attractors; shape encodes attractor type, color encodes sub-biome), extending the existing OVERLAY_DEFS / AtlasOverlayBar with a generation overlay group + a left-side legend panel. Buttons start pending/locked and unlock as each layer's data arrives ("grows as each layer lands", D-166).

    (4) Agent-navigable channel. A client-side AtlasAgentInterface exposing JSON observe (current data state + a walkable UI affordance tree) and act (named semantic intentsselect_body, open_regional, set_overlay, back, … — backed by the same handlers a click calls, not pixel coordinates; the map uses _gui_input). Runs headless. Turns human-eyeball review into an agent-automatable QA sweep across the whole Reach (and later economics/saves).

    (5) Capture — reuse, don't reinvent. Interactive screenshots reuse the existing tests/run-visual capture primitive (frame-delayed save_png, proven by character_creation.gd's standalone capture). The agent channel adds interactive explore-and-capture; the existing scenario/flow visual-golden regression role stays distinct and unchanged. Clear hover state before capture.

    Consumers (build geography now; the others adopt the pattern in their own phases — do not build them now): Layer-1 geography (Phase 4) → economics (dynamic; pause essential) → save-game inspection (load → pause → inspect; save_state.rs substrate).

    Amended 2026-07-13 (T-960 rescope — Si audit ratifications):

    • (a) Overlay mechanism simplified. The per-button pending/locked + notify_gen_layer_ready(id) unlock mechanism in item (3) was never built; the shipped pattern — plain always-toggleable buttons plus a single per-body "generating" pending indicator — has carried two layer deliveries (the L1 triad, then T-1046's district grid) and is ratified as the standard. Stable gen_* button ids remain the contract.
    • (b) gen_l0_heightmap dropped. Relief already renders via the always-on terrain overlay (D-191 §7); a second generation-labeled L0 toggle would duplicate it.
    • (c) Legend panel stands. The left-side legend (shape/color key for attractor types and sub-biomes) remains an unshipped, in-scope deliverable of item (3)/T-960.
    • (d) No tile-level Atlas map. The planetary Atlas maps generation layers down to settlement/quarter-skeleton granularity only; chunk/tile/voxel fill (L5) is verified by the believability/derivation harnesses and inspected in-world in Phase 5 — never as a planetary map layer (at most aggregate stats). Ratifies T-1046's implementation precedent; matching D-191 amendment + CLAUDE.md Phase-4 wording updated the same day.

    Amended 2026-07-16 (T-1112 — coarse quarter-footprint Atlas layer, the L4 skeleton on the planetary map): the fourth generation-cascade layer to land on the Atlas, sitting between T-1046's district_grid (D-239 coarse morphology) and item (d)'s hard ceiling. Design only — implementation is a follow-up ticket (touch points named in §4 below, none built here).

    • (1) QuarterFootprintLayer data shape. Source of truth is BodyWorldState.quarters: BTreeMap<QuarterId, QuarterWorldState> (QuarterWorldState { skeleton: QuarterSkeleton, block_tags }), where QuarterSkeleton.blocks: [[BlockSkeleton; 4]; 4] carries zoning: ZoningType, district_type: DistrictType, density_pct: u8, landmark: Option<LandmarkSlot> per block, plus corridors: Vec<CorridorSpine> at the quarter level. Critically, QuarterWorldState carries no independent spatial positionQuarterId is a content-addressable hash (SeedChain::for_body(world_seed, body_id).derive(SeedDomain::Layer4Quarter, city_id).seed(), plugin.rs::build_skeleton_work_item), not a coordinate. The only spatial anchor a quarter has is the city_id it was generated for — which SettlementLayer/CityPlacement (T-960 §2, D-211) already carries as (city_id, position). So the layer is keyed by city_id, joined to a quarter by recomputing the same deterministic quarter_id derivation the dispatch path already uses (a pure function of already-public inputs — no new field needed anywhere) and looking it up in state.quarters. This also means no city-outline geometry is derived or served — at planetary-map projection a 512 m quarter is roughly 1/80th of a heightmap pixel (a district ≈ 2048 m is already sub-pixel at ~4078 km/px, D-243), so there is no real silhouette to trace; the layer is aggregate stats anchored at the existing L3 settlement position, mirroring RoadGraphLayer/SettlementLayer's established "trim the internal struct to what an overlay needs" pattern rather than inventing outline geometry with no data behind it.

      Per settlement-with-quarters, the served aggregate is:

      pub struct QuarterFootprintEntry {
          pub city_id: u64,
          pub density_avg_pct: u8,           // basis-point mean of BlockSkeleton.density_pct, 16 blocks
          pub dominant_district_type: DistrictType,  // mode across 16 blocks; ties → lowest declaration-order variant
          pub dominant_zoning: ZoningType,           // mode across 16 blocks; same tie rule
          pub landmark_count: u8,            // count of Some(LandmarkSlot) across 16 blocks (max 16)
          pub corridor_count: u8,            // QuarterSkeleton.corridors.len(), clamped to u8
      }
      pub struct QuarterFootprintLayer {
          pub entries: BTreeMap<u64, QuarterFootprintEntry>,  // keyed by city_id, D-010 determinism
      }
      

      Five fields earn their place: density_avg_pct and the two dominant-mode fields are Araminta's color/shape encoding inputs (§3); landmark_count/corridor_count are inspection-only (the D-226(d) ceiling forbids them as a map-visible channel — they surface in the existing city-click sidebar instead, an ImplantDataRow addition, not a new draw call). Rejected from the set: per-block detail (violates the ceiling outright), reservations/social_sites counts (no consumer identified — Araminta's encoding doesn't need them and nothing else asked), and a float density (D-010 integer discipline — density_pct is already u8 basis-point-flavored on the source struct, so the mean stays u8, no f32 anywhere on the wire). dominant_district_type/dominant_zoning serialize as their named enum variant (serde default), following the RoadGraphLayer/SettlementLayer precedent (RoadNodeKind, MaintenanceAuthority — neither repr(u8)-pinned, serialized as names) rather than district_grid's as u8 byte-packing, which was specific to a dense cols×rows array where MorphologyZone is deliberately repr(u8)-pinned for that purpose; a handful of per-settlement aggregate fields have no such packing need. BTreeMap<u64, _> throughout for D-010 determinism, matching block_tags' own BTreeMap<(u8,u8), _> precedent on the source struct.

    • (2) Hard ceiling (binding). This layer is the concrete instance of item (d)'s "at most aggregate stats" clause: five scalar fields per settlement, quantized u8, no per-block zoning/street/tag detail ever reaches the wire, and no chunk/tile/voxel data is touched (this layer reads only QuarterSkeleton/BlockSkeleton, never FillChunk output — a different generation phase entirely, D-230). If a future ticket wants finer planetary-scale detail than this, the answer is "no" per item (d), not "extend this struct" — the settlement/quarter-skeleton granularity ceiling applies to this layer by construction, not by restraint that could erode.

    • (3) Overlay encoding (Araminta). New overlay id gen_l4_quarters (label QTR, group: "toggle", matching the gen_* convention in OVERLAY_DEFS). No outline is drawn (per §1) — the layer is a density-scaled glyph anchored at the existing L3 settlement position, drawn in the same pass immediately after _draw_gen_settlements so it reads as "on top of" the city dot it annotates. Square side scales off density_avg_pct (e.g. 4.0 + density_avg * 6.0 px at 1.0 zoom, clamped [4.0, 12.0]) — dense build reads as a bigger block, sparse as smaller, without pretending to show real shape. Shape carries identity, color carries intensity (the same convention as _sub_biome_color/MORPHOLOGY_COLORS): shape = dominant_district_type, a small corner-notch glyph family on the filled square (plain = mixed/no clear dominant, corner tab top-right = commercial, corner tab bottom-right = industrial, small diamond cutout center = civic/landmark — capped at 34 variants, a coarse skeleton read, not a legend of every DistrictType); color = density_avg_pct on a single-hue intensity ramp within the existing settlement-gold family (COLOR_SETTLEMENTCOLOR_SETTLEMENT_CAPITAL-adjacent bright gold at high density), so the new layer reads as part of the settlement-marker family rather than a competing hue, since it always co-renders beside gen_l3_settlements. landmark_count/corridor_count are not a visual channel (§2's ceiling) — they surface as ImplantDataRows in the existing city-click sidebar panel. Zoom gating reuses SETTLEMENT_LABEL_MIN_ZOOM = 2.0 (no new threshold): below it, glyph draws at minimum size with color only (the notch is illegible at a few px anyway); at/above it, full size with the dominant-type notch visible. Legend entry (GENERATION_LEGEND):

      {
          "overlay_id": "gen_l4_quarters",
          "title": "QUARTER FOOTPRINT — L4 (color = density, shape = dominant type)",
          "rows": [
              {"glyph": "▪", "color": Color(0.55, 0.48, 0.30, 0.6), "label": "low density"},
              {"glyph": "▪", "color": Color(0.94, 0.82, 0.38, 1.0), "label": "high density"},
              {"glyph": "◪", "color": Color.TRANSPARENT, "label": "dominant type (corner tab)"},
          ],
      }
      
    • (4) Six wiring touch points (follow-up ticket, not built here). AtlasLayerResponse gains quarter_footprints: Option<QuarterFootprintLayer> + a build_quarter_footprint_layer(state, placements) function mirroring build_district_grid's "empty source → None" contract (server/src/atlas/layer_proxy.rs); ZoningType gains PartialOrd, Ord derives mirroring DistrictType's T-994 precedent (server/src/simulation/generator.rs — declaration order is not a stability-pinned wire format on either enum, so the additive derive is safe), which is what makes §1's lowest-declaration-order tie-break computable for dominant_zoning (PR #179 review: DistrictType already carries the derives, ZoningType does not — the tie rule itself is unchanged); protocol.gd passthrough for the new field (mirrors the existing district_grid/road_graph/settlements fields); a gen_l4_quarters entry in OVERLAY_DEFS (client/ui/implant/apps/atlas/atlas_viewer.gd); a _draw_gen_l4_quarters() function imitating _draw_gen_district's "read viewer.get_generation_quarter_footprints(), guard on Dictionary, draw" shape (atlas_marker_overlay.gd); and the GENERATION_LEGEND entry above (atlas_legend_panel.gd). layer_proxy.rs was mid-concurrent-edit for T-1113's region_grid addition at design time — read-only pass, no conflict expected (both land as new sibling Option fields on AtlasLayerResponse, following the same one-field-per-layer pattern the growth-ceiling note on that struct already anticipates naming T-1112 and T-1113 as the last two candidates).

    Amended 2026-07-18 (T-1124 — windowed district-resolution regional map, the jet-plane altitude between the planetary Atlas and the never-mapped chunk/voxel world): a new Atlas viewing mode, deliberately not counted alongside item (3)/T-1112/T-1113's whole-body-layer sequence, because it is a different kind of thing from all of them — those are whole-body layers (computed once, cached per body, one screenful, up_to-gated but otherwise request-independent); this is a windowed viewport query (parameterized by a client-chosen rect, re-issued on every pan, never a whole-body snapshot). District resolution (2,048 m/cell, D-243) sits strictly between the D-226(d) settlement/quarter-skeleton ceiling and the never-Atlas-mapped chunk/voxel tier — in remit, and the first time the D-227 "invented deterministically" terrain (everything finer than the ~4078 km/px heightmap) has ever been surfaced to a screen rather than a debug probe. Design only — implementation is a follow-up ticket (T-1124 report; touch points named below).

    • (1) Request — extend AtlasLayerRequest, do not add a sixth inbound shape. bridge/mod.rs's Inbound demux doc names BrowseRequest (T-1131, PR #184) the fifth and last map-shape probe this hand-rolled scheme should ever carry; a sixth top-level request shape is explicitly forbidden without migrating the whole channel to the tagged-envelope framing D-225 deferred. The window parameters therefore ride on the existing AtlasLayerRequest{body_id, up_to} as new #[serde(default)] fields, absent = whole-body (today's behavior, byte-unchanged for every existing caller):

      pub struct AtlasLayerRequest {
          pub body_id: String,
          pub up_to: CascadeLayer,
          /// District-window centre (T-1124). `None` = no window requested
          /// (whole-body layers only, today's behavior).
          #[serde(default)]
          pub window_center: Option<DistrictPos>,
          /// Window side length in districts. Ignored when `window_center` is
          /// `None`. Clamped server-side to `[1, DISTRICT_WINDOW_MAX_N]` (§4) —
          /// never trusted from the wire.
          #[serde(default)]
          pub window_n: u32,
      }
      

      This is the identical pattern StartupMessage.role already uses (bridge/types.rs, D-254 §2) — an old client sending only {body_id, up_to} still decodes cleanly, window_center defaults to None, no protocol version bump, no new demux branch. up_to is unaffected and keeps gating which whole-body layers run; a window request rides alongside any up_to value — the window derivation depends only on TerrainAnalysis + BodyParams being resolvable for the body (the same precondition aliveness_probe --render has), not on which whole-body layers the cascade has cached.

      Serving model — the window derives on the Rayon background queue, NOT inline on the tick thread (binding). serve_atlas_requests runs in TickPhase::PreInput and drains all queued atlas requests synchronously in one loop (plugin.rs:99-128), sharing that tick's response flush with the star-map/city-names/browse serve systems. Every expensive path in that system today goes to the Rayon background queue and returns Pendingdrain_generation_completions' own doc is explicit that this drain is "a cheap channel drain + cache insert, never the ~45 ms cascade itself" (plugin.rs:207). A window derive at n=32/64 is ~729 ms (§4); running it inline would blow that "cheap" contract, and a pan-burst would stack several inline derives in one drain loop, delaying the entire tick's response flush (star-map, browse, everything). Client debounce (§5) is courtesy — the server cannot enforce it and must not depend on it. So a window request follows the exact same background-queue pattern as a whole-body cache miss: handle_atlas_request submits a window-derive work item to the GenerationQueue (a new GenWorkItem variant carrying body_id + (center, n) + the resolved terrain/params) rather than deriving inline; a later tick's drain_generation_completions receives the finished DistrictWindowLayer and caches it (keyed by (body, center, n), alongside BodyWorldState or in a sibling window cache — a wiring-ticket call). Because district_window is an Option, an as-yet-underived window is simply served as None — the same "layer hasn't produced yet → None" signal every whole-body layer already uses, independent of the body-level AtlasLayerStatus (a body whose whole-body layers are cached still answers Ready with district_window: None until the window job completes; the client re-requests via the existing D-225 poll loop and gets the window on a later response once cached). This avoids overloading the body-level status with window-readiness — the Option carries it. §5's border-fade already covers this multi-tick wait as UX. Recommended (not mandated here): per-connection window-request coalescing — a newly-queued window request for the same body/connection supersedes an unserved older one, so a pan-burst collapses to one derive server-side even if the client's debounce let several through. The precise GenWorkItem shape, the coalescing key, and the completion-routing wiring are a follow-up-ticket concern (T-1137); this record fixes only that window derivation is background-queued like every other expensive atlas path, never inline on the PreInput drain.

    • (2) Response carrier — a distinct payload, not a sixth/seventh dense-layer Option field (RULING, binding). The growth-ceiling note on AtlasLayerResponse (this record's base text, re-affirmed by T-1112 §4's "last two candidates" framing) governs one specific family: dense, whole-body, cache-keyed-on-body-alone layersdistrict_grid, road_graph, settlements, region_grid, and quarter_footprints (T-1112/T-1119, landing concurrently with this design). A windowed district payload is a different kind of traffic by construction: its content is keyed on (body, center, n), it is re-requested on every pan (not cached once per body and reused), and a stale response must be detectable and discardable by the client rather than silently rendered — none of which is true of the five/six-member family the ceiling was written for. Retrofitting it into that family as a bare district_window: Option<DistrictWindowLayer> sitting next to region_grid would misrepresent its semantics (implying the same "cached snapshot, always current" contract its neighbours have) even before the slot-count argument. The ceiling's subject is therefore explicitly re-scoped here to the dense whole-body layer family — it does not gate this field, and this field does not count against it. This is option (c) from the T-1124 refinement's three choices, chosen over (a) (a bare seventh/eighth Option peer — technically fits the struct, dishonestly fits the family) and (b) (a second response message — real complexity, a new framing concept, for a problem the existing struct already solves once the semantics are named correctly).

      Windowed-family ceiling (binding, replaces the migration trigger the re-scoping removed). Re-scoping the whole-body ceiling to exclude windowed queries must not leave the windowed family uncapped — that would let a second windowed field (a windowed chunk-preview, a second simultaneous viewport) land frictionless as district_window_2, exactly the drift the whole-body cap exists to prevent. So the windowed family gets its own hard rule, mirroring the request side's "five HARD, a sixth migrates" discipline: there is exactly ONE windowed-query field on AtlasLayerResponse (district_window), and a second windowed query is choice (b) — a dedicated response message — by rule, not by case-by-case judgment. The rationale is symmetric with (b)'s rejection here: one windowed payload fits the existing response struct honestly (the client asked for a window, got a window); two concurrent windowed payloads riding one AtlasLayerResponse would need per-field request-correlation (which echo matches which in-flight request?) that the single-field echo-key design deliberately avoids — that correlation machinery is the tagged/multiplexed framing a dedicated message provides, so a second windowed consumer is the trigger to build it, not a reason to bolt a second Option on. So: district_window is the windowed family's five-HARD equivalent at one, and the next windowed field is a migration, full stop.

      AtlasLayerResponse gains exactly one new field along these lines:

      pub struct AtlasLayerResponse {
          // ...existing fields unchanged...
          pub region_grid: Option<RegionGridLayer>,
          /// The requested district window (T-1124), or `None` when the request
          /// carried no `window_center` / no window data is cached yet for a
          /// pending body. Distinct from the five layers above: keyed on the
          /// REQUEST (body, center, n), not on the body alone — see §2.
          pub district_window: Option<DistrictWindowLayer>,
      }
      

      DistrictWindowLayer echoes center/n back on the response — this is the client's race-condition guard, not a convenience field. Because window derivation is pure and deterministic (D-227: derive_district is a function of (seed, body_id, body_params, terrain, district_pos) only — no hidden request-order dependence), the same (center, n) query always yields the same payload, so the echoed tuple is the cache/staleness key: the client compares it against whichever window it most recently asked for and discards any response whose echo doesn't match (superseded by a later pan). No sequence number or request-id is needed — D-227's purity is what makes the echo sufficient. body_id is not part of the echo tuple because it does not need to be: the echo rides inside AtlasLayerResponse, whose existing body_id field already scopes the whole response to one body (the same field the whole-body layers use), and the response-routing path is per-connection-and-body already — so a body switch (or a window response arriving in-flight across a body switch) is disambiguated by the enclosing AtlasLayerResponse.body_id, not left to (center, n) to catch. The client's cache key is the full (body_id, center, n) (§4) — body_id from the response envelope, (center, n) from the echo — so cross-body confusion is ruled out by the routing that encloses the echo, and the echo's job is narrowed to exactly what it is good at: disambiguating which window of the current body a response answers.

      pub struct DistrictWindowLayer {
          pub center: DistrictPos,
          pub n: u32,               // window side length in districts (n × n cells)
          pub morphology: Vec<u8>,  // MorphologyZone discriminant, 17-entry frozen vocab (D-239 §6)
          pub elev_q: Vec<u8>,      // 0-100, matches DistrictGridLayer.elev_q encoding
          pub temp_dc: Vec<i16>,    // deci-°C, REGION_TEMP_NONE_DC sentinel — same scheme as RegionGridLayer.mean_temp_dc, deliberately NOT a separate district-tier quantization (see rationale below)
          pub moisture_q: Vec<u8>,  // 0-100, matches DistrictGridLayer precedent
          pub vegetation: Vec<u8>,  // VegetationClass discriminant, 0-6 incl. Marine (T-1126)
          pub glaciation: Vec<u8>,  // GlaciationGrade discriminant, 0-4 (T-1127, accepted — see §3)
      }
      

      All six arrays are dense row-major n × n, same indexing convention as DistrictGridLayer/RegionGridLayer (i = row * n + col), built by iterating derive_district over [center.0 - n/2, center.0 + n/2) × [center.1 - n/2, center.1 + n/2) exactly as aliveness_probe --render's render_window_panels already does — this design promotes that probe's window loop from a debug binary to a served layer, unchanged in mechanism. Temperature stays i16 deci-°C with the existing REGION_TEMP_NONE_DC sentinel, not a new u8 band-relative scheme (a live design-round proposal, overruled here): the district window and the region climate overlay (item T-1113) must share one temperature colorizer on the client, and D-243's edge-fuzz discipline ("climate does not change on a line") argues against two independently-chosen quantizations that could paint a visible ramp discontinuity at the region/district zoom-swap threshold — a rendering seam standing in for a data seam that D-243 explicitly rules out. The 1 extra byte/cell this costs over u8 is immaterial at the window sizes in §4.

    • (3) Field-list dispositions (mandatory per the T-1124 refinement). Six fields ship, all already-derived DistrictProfile members with no new derivation logic:

      • glaciation_grade: ACCEPT. T-1127 (done, PR merged) explicitly deferred this field's wire half to this design pass and flagged the render pattern already works — the per-pixel ice tint on the morphology panel (aliveness_probe::apply_ice_tint) is production-proven, and glaciation_grade is already a first-class DistrictProfile field with a stable 04 discriminant. Shipping it as its own array (rather than baking the tint server-side into morphology) matches Araminta's encoding needs: the client can choose to tint, use a dedicated glaciation overlay, or ignore it, exactly as the probe renderer offers a sixth dedicated panel alongside the tinted morphology panel.
      • 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. 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 >= 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.

    Amended 2026-07-21 (T-1145 — Jeroen, second companion hands-on, KALLAST window): three regional-window presentation fixes, all client-only. Cover-fit supersedes contain: fit_window_view()'s zoom now derives from the LARGER viewport dimension with no margin factor (max(viewport.x, viewport.y) / composite_native, not the old 0.9 * min(...)), so the square district-window composite fills a wide/tall viewport edge to edge instead of leaving side margins, with the shorter axis' data extending into pan-space (the same "cover" concept as CSS object-fit: cover) — the existing §4 pan-edge refetch is unaffected (it keys off the screen-center-to-DistrictPos mapping, which any fit already centers on _held_center by construction, so no refetch churn at rest). WASD + edge-scroll supersedes drag-pan: LMB-drag panning is removed entirely (Jeroen's ruling — drag conflicts with click semantics for the map objects, e.g. settlements, this window will host later); panning is now held WASD/arrow keys (continuous, frame-rate-independent, _process-polled, physical-keycode reads to stay independent of the project's existing move_north/etc. gameplay-movement InputMap actions bound to the same keys) plus edge-scrolling (cursor within ~24px of a viewport edge, suppressed over UI and while the OS window lacks focus); wheel zoom is unchanged; pole-wall (§5 amendment above) and east-west wrap (T-1142) semantics are preserved unchanged under the new input source. Smoothing is an interim presentation, pending T-1143: the composite renders as an n×n Image/ImageTexture (one pixel per district, the identical existing per-cell color pipeline) drawn scaled with linear filtering — the same treatment the planetary heightmap already gets — instead of n×n flat rects, so GPU bilinear sampling reads as a terrain gradient rather than hard blocks; the original crisp per-cell path survives behind a compile-time const specifically so T-1143's design pass can compare both directly, and this smoothing is not T-1143's answer to district-tier legibility, only a stopgap ahead of it.

    Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, docs/architecture/atlas-zoom-ladder-t1143.md): three rulings on the pass's reserved decisions. (1) The item-(d) ceiling is opened for the Atlas ladder — Jeroen: "we set a new BHAG so old restrictions are up for debate." The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read literally: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a whole-body planetary map layer, and the below-quarter rungs are implementation-gated on their own measurement pass (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. (2) Planetary rung wire carrier: progressive capped-density tiling riding the generalized district_window carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. (3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, with the condition that a full zoom-out resets to the original canonical planetary frame and location (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the sole entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is restored for this seam. Wire-contract note (T-1150, PR #191 review — Tyre): the window_granularity field that ruling (2) rides on expresses finer-than-district integer multiples only (1 = district, 4 = quarter today; each new rung is a deliberate widening of resolve_window_granularity's whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row. Refinement-semantics note (T-1153, PR #192 review — Tyre): the ladder's progressive cross-rung refinement (hold the coarse composite, fetch the finer rung, swap in place on arrival; per-tile arrival in the orbital mosaic) extends the T-1124 §4 float-on-center/debounce async contract — it does not supersede it. §4 still governs the per-request mechanics unchanged (district_window: None-until-derived polling, the 150 ms debounce, float-on-center refetch); rung crossings add a second request class on top, per the design pass §3's progressive-refinement model. This record is the one that governs the swap-on-arrival behavior. The legacy window_granularity: u32 wire field is now fully shadowed by window_granularity_v2 (the server always echoes both); it is scheduled for retirement once pre-T-1152 wire-compat is confirmed unneeded (single-repo client/server pair — no external clients exist today; ticketed). Pending-shape protocol note (T-1163, PR #193 review — Tyre): AtlasLayerResponse has two legal wire shapes for one logical "still deriving, client must re-poll" state, and both are contract: whole-response status: Pending (whole-body cache cold — nothing about this body computed yet) and status: Ready with district_window: null (body warm, this window still in the derive queue). Ready is set only by the whole-body cache-hit branch, independent of the window's own derivation. Any AtlasLayerResponse consumer must treat BOTH shapes as retry-with-backoff and only NotFound/Error as terminal — the T-1163 cold-launch starvation (every first launch black) was precisely a client reading status != Ready as ignorable. A server refactor that "cleans up" the Pending/Ready-null asymmetry must migrate every consumer in the same change. Filter-axis note (T-1161, PR #194 review — Tyre): COMPOSITE_SMOOTH is retained as the compile-time pipeline axis (texture vs. per-cell rects, crisp path kept for debug/compare); the ladder's crispness-at-sparse-rungs requirement is met by the per-rung sampling-filter policy (_filter_for_granularity_v2: Region/orbital-mosaic NEAREST, District/Quarter LINEAR, unknown falls back LINEAR), not by deleting the const. The design pass §8 step 6 "retire COMPOSITE_SMOOTH" is errata'd accordingly; T-1155's retirement framing is cancelled, superseded by T-1161. Wave-1 nature-overlay carrier note (T-1156, 2026-07-23 — Tyre): river skeletons (rivers/basins/attractors) ride the Atlas zoom ladder on the existing whole-body layer1 field (RiverNetwork/drainage_basins/attractors, already serialized on every AtlasLayerResponse), not on the windowed district_window carrier — so the windowed-family ceiling (§2, [HARD], exactly one windowed field) is untouched and no tagged-envelope migration is triggered. Rationale: the skeleton is discrete vector geometry ((u16,u16) cell chains, boundary polylines, point attractors), computed once per body in the Layer-1 pass and cached "valid forever" (D-227) — categorically the whole-body family, not a per-pan viewport query. Rasterizing rivers into district_window's per-cell arrays is rejected: a 1-cell-wide thalweg is sub-cell at every ladder rung (512 m/cell and coarser), so a presence-byte either over-fattens (the Lendel failure the ladder mandate kills) or drops the river on the sampling grid. Per-rung refinement is client-side (trunk at Region → +tributaries at District → +streams at Quarter), a filter on a new quantized river_class per cell added to RiverNetwork (derived from the flow-accumulation drainage.rs already computes; additive, #[serde(default)]-safe, no ceiling impact) — NOT server-side per-rung re-transmission. This is distinct from T-1162's server-side min_wavelength_m cutoff: that truncates a continuous per-metre field at the rung's Nyquist limit; the skeleton is a fixed finite graph with nothing to truncate. General rule established: discrete map features (linear + point geometry) ride the whole-body overlay family, filtered per-rung client-side; only continuous per-metre fields ride the windowed per-cell arrays — Wave 2's roads/rail/settlements (already whole-body fields) inherit this carrier unchanged. Consistency scope: the river skeleton is upstream of and independent from T-1162's perturbed moisture_q (drainage runs on elevation, never samples moisture), so the two cannot disagree; the vegetation layer's riparian response to rivers (near_perennial_water) is a named forward contract, deferred to T-1168 — not fixed in Wave 1. Until it lands, the river overlay draws over terrain whose vegetation layer does not yet respond to it (an accepted nature-layer-first gap). Visibility-direction note (same PR, Araminta): the per-rung visibility DIRECTION is Araminta's presentation ruling on the 76 km skeleton-resolution evidence — fade-down (Region shows the full skeleton, District trunk-only de-emphasized, Quarter off), consciously inverting the provisional add-as-you-descend mapping the ticket brief carried. This posture is explicitly pre-T-1170: it is revisited (in RIVER_CLASS_VISIBLE_BY_RUNG, the single client-side revisit point) when course invention gives finer rungs real geometry to reveal. Two-waterline note (T-1172, 2026-07-23 — Tyre): the river skeleton is extracted against the raw heightmap sea level (drainage.rs), a rung-independent graph; the drawn coast is the derived morphology verdict — which is rung-dependent by construction (the coast-warp crinkle, coast_invention.rs, adds octaves at finer rungs, so the drawn coastline is a family of curves indexed by rung, not a single curve). There is therefore no single authoritative server-side waterline to reconcile the skeleton against — a server-side classification would bake one rung's coast into the wire and be wrong at every other rung. Reconciliation is a presentation-frame operation: the draw site clips river dots/confluences/mouths against the arrived composite's per-cell water verdict at the rung being painted (drop-in-water, no snap). The skeleton stays rung-independent (its correct nature per the carrier note); the clip is retired into T-1170 when course invention terminates courses at the invented coast with continuous geometry. Course-invention carrier note (T-1170, 2026-07-23 — Tyre, full ruling in docs/architecture/river-courses-t1170.md): river course geometry below D8 resolution is invention, not skeleton, and rides the windowed payload as a vector field inside DistrictWindowLayer (courses, additive) — invented server-side per window on the background queue (T-1137 discipline) at the window's rung, terminated server-side against the same rung's morphology water verdict the window's cells carry. This does not count against the windowed-family ceiling (§2 [HARD]): the ceiling counts windowed query fields on AtlasLayerResponse; courses are content of the single existing windowed payload, same echo key, same staleness semantics. The wave-1 carrier rule is hereby refined three-way: (i) rung-independent discrete features (skeletons, graphs, markers — computed once, valid forever) ride the whole-body family, filtered per-rung client-side; (ii) continuous per-metre fields ride the windowed per-cell arrays; (iii) rung-indexed invented detail rides the windowed payload regardless of geometric kind, because rung is a request parameter and only the windowed query carries one — the coast crinkle has always implicitly been (iii); courses are its vector sibling. The two-waterline note's reconciliation resolves per-rung server-side for courses (the terminus is windowed content at a known rung); the Region-rung skeleton path retains the presentation-frame clip until Region itself goes windowed (T-1143 ruling 2), which retires the last clip site. The skeleton itself gains river_downstream (additive per-river-cell D8 pointer with MOUTH/EDGE_DRAIN/reserved-TERMINAL sentinels) on RiverNetwork — whole-body, rung-independent, per rule (i). Pole-edge drains are grid artifacts, not mouths — excluded from mouths at extraction. Implementation notes (PR #197 review): (a) Godot's line rasterizer floors stroke widths below ~1.0 canvas units to a 1-px hairline; the client floors compensated stroke widths accordingly (zoom_compensated_stroke_width), with the consequence that Ruling 5c's per-class width differentiation is inert at every shipping fit zoom — course classes are distinguished by opacity alone until T-1175's per-vertex tapering (Polygon2D strips) supersedes stroke-width rendering; the 'trunk widest' promise is design intent, not current pixels. (b) The pole-row edge-drain branch is structurally unreachable (flow_direction bounds-checks before assignment); the real EDGE_DRAIN mechanism is the interior k<0 no-valid-downstream case — discovered by revert-verification, test fixture exercises the reachable path. Amended 2026-07-23 (body-map-viewer workshop — stepped render architecture; full record D-255): the T-1143 design-pass rulings above (2026-07-21) are revised by the body-map-viewer workshop, which relocated content determination to the server, made the client a map-art function, and made zoom stepped. Six consolidated revisions to this record's amendment chain:

    • Ceiling re-scope (§2 windowed-family ceiling, [HARD]) — tagged-envelope migration TRIGGERED, superseding T-1143 ruling 2. T-1143 ruling 2's "progressive capped-density tiling... no forced tagged-envelope migration" is superseded: measurement (T-1179) shows the step-canvas payload at 21×–563× the ~30 KB windowed-payload reference across the three canvas sizes — a cell-count gap of two-to-three orders of magnitude no encoding closes, so it cannot ride the district_window carrier. The ceiling's purpose survives (no uncorrelated concurrent windowed queries); its mechanism is re-scoped to the legacy district_window carrier only. The step canvas rides a new tagged-envelope carrier (D-225's deferred migration, now executed — D-255 §b) that the "exactly one windowed field" rule does not apply to by construction (it is the new shape, not a second field on the old). district_window survives byte-unchanged for its existing consumer until that consumer is replaced, then goes cold.
    • Rung selector superseded (T-1143 §6 select_rung / MAX_COVERAGE_M coverage-walk). The coverage-ceiling walk (Quarter→District→Region→tile-mode mosaic; compute_tile_grid) is replaced by a discrete step index into the stepped gridunit ladder (D-255 §c). Rungs-as-derivation-granularity survive; the selector is "which step the viewport is on," not a coverage walk. resolve_window_granularity's whitelist-validation discipline (T-1150) is the right shape for the step index's own validation.
    • Zoom transport superseded (T-1143 ruling 3). Continuous cursor-anchored zoom → stepped (one server-canvas fetch per crossed step boundary). Survives unchanged: cursor-anchored centering, edge-scroll pan, and the [HARD] full-zoom-out reset (now to the canonical Global rung-0 body-surface frame, per D-255).
    • Item (d) restated per-request + accumulation cap. The item-(d) whole-body prohibition is stated as a per-request / per-derivation constraint, not aggregate-storage: no single StepCanvasRequest derives whole-body coverage at sub-Region spacing. The client-side persistent-cache accumulation path (a systematic exhaustive pan assembling the forbidden artifact as N cached files — most plausibly the item-(4) AtlasAgentInterface QA sweep) is closed structurally by a per-body deep-rung client-cache retention cap (D-255 cache section), not by practical improbability. The rule's purpose is about information content, not file count.
    • Item (d) floor partial-restore at chunk (Jeroen, interview 2 — narrows T-1143 ruling 1). T-1143 ruling 1 opened the item-(d) floor "toward block/tile"; interview 2 scopes that opening — it does not reverse it. Jeroen: "the actual tile level rung seems unusable. maybe replace with 64?" The Atlas ladder bottoms out at chunk (64 m); tile/voxel (1 m) returns to never-Atlas-mapped (a 10-px-per-tile full-screen view is ~192×108 m of ground — Phase-5 in-world content, not a map). Block (128 m) and chunk (64 m) are legal Atlas rungs, riding the viewport-sized carve-out; tile/voxel L5 fill stays harness-verified, shown in-world in Phase 5, exactly as item (d) originally required. A deliberate scoping of the BHAG, not a walk-back.
    • Courses carrier gloss + cliff sparse-list sibling (river-courses-t1170.md rule (iii)). The T-1170 course-invention carrier rule survives; "the windowed payload" is repointed to "the per-step data-canvas payload (formerly district_window, now the tagged step-canvas envelope)." Rule (iii) (rung-indexed invented detail rides the windowed payload regardless of geometric kind) gains a second vector member alongside courses: cliffs: Vec<CliffSegment> — carved-gorge geometry (dominant elevation + per-segment channel_depth + cliff_edge, direct solver-output carry), a sparse list, zero-length when nothing is carved (T-1177's population survey: zero carved cells across all 267 real committed bodies — structurally rare, #[serde(default)]). Phase-4 Atlas scope (a map showing a smooth shoreline where the settled solver computed a carved channel would misrepresent the "settled hydrology" the workshop premised). The cliffs list is NOT the endorheic-lake cue (0/267 carve — see D-227's lake amendment).
  • 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.

  • New surface: server pause-gating (run-conditions on the world phases keyed to a pause command); client AtlasAgentInterface (observe/act, Control-tree walker) + its local transport; the generation overlay rendering + selector + legend; interactive capture wired to run-visual.

  • Implementation: Phase 4 (epic T-750), built bottom-up — auto-pause substrate, T-969 proxy (D-225), T-960 viewer, agent channel, agent capture. Geography is the first consumer.

  • Raised by: Jeroen + Claude (design), with Tyre (channel/pause/headless architecture) + Araminta (overlay encoding + affordance UX), 2026-05-24.

  • Cross-reference: D-225 (layer-stream proxy — the data path), D-166 (per-layer Atlas progress viewer), D-191 (Atlas viewer), D-169 / D-170 (implant components / HUD occlusion — gameplay_occluded trigger), D-200 / D-203 (execution tiers / LRU cache), Q-099 (mod content catalog), tests/run-visual (capture primitive), save_state.rs (save-inspection consumer). T-1112 amendment additionally: D-222 (Quarter terminology — the 512m unit this layer surfaces), D-234 (footprint geometry — the block-subdivision source the aggregates summarize), D-243 (quarter = 512m rung, and the containment ladder that makes a quarter sub-pixel at planetary projection — Araminta's no-outline rationale), D-010 (determinism — integer-only aggregates, BTreeMap keying). T-1124 amendment additionally: D-227 (derive-don't-store + the "invented deterministically" clause this design is the first to surface on a screen; determinism is what makes the echoed-center staleness guard and the client-side window cache both sound), D-243 (district = 2,048m rung — this is precisely the D-226(d) ceiling's floor, the regional altitude above quarter-skeleton and below the never-mapped chunk/voxel tier; edge-fuzz discipline — the temperature quantization-consistency rationale), D-239 §6 (the frozen 17-zone MorphologyZone vocabulary this layer serves unchanged) / §8 (vegetation climate law, amended by T-1126's Marine), D-225 extension / T-1131 / PR #184 (the five-map-shape demux ceiling — why the window rides on AtlasLayerRequest rather than a new inbound shape), D-010 (wire-integer discipline — all six per-cell fields are integer/quantized, no f32 on the wire). §5 (screen) additionally leans on D-191 (the AtlasViewer whose _view_zoom LOD vocabulary and SETTLEMENT_LABEL_MIN_ZOOM threshold the district window extends in place), D-169 / D-170 (implant chrome / theme accent-role discipline — map palettes stay out of ACCENT_ACTIVE gold the settlement marker owns), and D-013 (diegetic navigation — the zoom gesture owns spatial descent, so it is not overloaded onto the city-click sidebar). Tickets: T-1123 (the derive_district window-render precedent this design promotes to a served layer), T-1127 (glaciation_grade derivation + probe render pattern, wire half deferred here and now accepted), T-1126 (VegetationClass::Marine), T-1118 (region-grid climate overlay — §5's temperature/moisture overlays reuse its ramp so one colorizer spans both zoom levels), T-1119 (concurrent quarter_footprints wiring — the sibling whole-body layer this design's §2 distinguishes itself from).

  • Dissent: None

  • Amendment (2026-07-25, T-971 / PR #209 — agent-channel vocabulary reconciled to the stepped Atlas): the layer-4 AtlasAgentInterface ships with its intent vocabulary re-derived against the post-D-255 stepped Atlas, superseding this record's original list. Dropped: select_city and open_regional — post-D-255, settlements are settlement_id canvas cell values with no hit-test affordance (no server get-settlement-by-id, no client hit-test; a settlement-selection intent becomes its own ticket when a real consumer exists), and Region→Chunk is one screen (descent is scroll_rung, not a screen push — open_body replaces open_regional). Added: open_atlas (a driver must be able to start the session), and jump_to_center — the fixed-center revisit pattern proven by the T-1157 eyeball drivers, made first-class via StepCanvasViewer.jump_to (same request tail, extent cap, and cache keys as cursor navigation, so an agent cannot request a state a player couldn't). get_layer_data_summary reports the real EncodedStepCanvas fields (courses-by-class, cliffs, draw-matched settlement counts) — the original attractor/river/basin-count phrasing was retired-AtlasViewer vocabulary. Transport narrowed: in-process consumers only (gdUnit, -s drivers, the T-1157 capture harness — the committed reference driver replaces the scratch eyeball drivers); the original curl/terminal remote transport is a follow-up when a remote consumer exists. Full handler-by-handler mapping on T-971.


D-227: Deterministic-rebuild world model — derive-don't-store, cache, tile mutators, volumetric

  • Date: 2026-05-25

  • Decision: The walkable world is a pure function of seed + atlas, materialised on demand and cached, never persisted as a tile grid.

    • Derive-don't-store. subtile(x,y,z) = derive(seed, atlas, position) — deterministic (D-010), recomputed on demand, held in a transient by-chunk/region cache for performance, evictable (eviction → recompute, always valid). No generated tile/voxel is ever saved.
    • Volumetric, in voxels. Unit vocabulary, fixed here: a voxel is the 1 m cube (the 3-D framing of D-222's tile); a subvoxel is its 0.5 m subdivision — eight per voxel, the finest derivation/dig granularity, in all three axes. Derivation is 3-D — surface and subsurface — over this grid. The heightmap/atlas sets the top; below it a deterministic geology model (strata by depth → bedrock; per-region lithology; ore / aquifer / cave as 3-D noise) fills downward. Digging reveals derive(...) for newly-exposed subvoxels — never "generated on dig"; the hole merely stops hiding seed-math.
    • Floors are semantic and variable-height, not a fixed voxel count. A floor — a walkable story, the z-level of D-049 / D-110 — groups voxels and defaults to 3 voxels (3 m) but is not locked: a cathedral nave or a hangar is one ~10 m floor, and that must be expressible. The voxel/subvoxel substrate is uniform and continuous in z; a floor exists wherever the volume is walkable void, and that void can be built (buildings), excavated (basements, mines), or natural (caves, lava tubes, caverns — derived in the geology model as 3-D voids). A cave is as much a floor as a basement; only truly solid rock is floorless. z-level addressing indexes stories whose real height in voxels varies.
    • Vertical extent is physical, not a floor count. The old "±50 floors" framing was a stored-grid artifact (each z-level was a filled, stored layer with memory/fill cost). Derive-don't-store eliminates that cost, so: down is bound by the body's own geology (crust → bedrock → pressure/heat-impassable interior, a per-body depth tied to radius / max_elevation); up is bound by max built-structure height (towers are built/authored or player-placed, not natural-derived) over free-to-derive air. This supersedes any ±50 cap — none was ever recorded as a D-record; it survived only as a 50-floor-skyscraper example in the generator-architecture workshop.
    • Persistence has two parts, and only one is mutators. (a) Tile mutators capture direct physical gameplay — the player or sim physically altering the world (pave a road, build a wall, dig a strip-mine, rocket-launcher the bank, fell a tree). Save = seed + sparse mutator log; load = re-derive base + replay. (b) Live simulation state (the rolling economy now; NPC / storyteller state later) is history, not seed-derivable, so it is saved — but as sim-state, not as mutators. The derived economic base is handled by cache expiration — recomputed on expiry, and force-evicted on an event (e.g. the player tanks a system's economy) — while the rolling state persists alongside the save. The physical world stays re-derivable; the living state is the small saved delta on top. (Mutator op schema → Q-103.)
    • Determinism reclassified safety-critical. Because mutators reference derived state, any derivation drift (a non-deterministic algorithm, an f32 comparison/ordering, HashMap iteration) desyncs the whole save — not merely a cosmetic difference. D-010's integer-only + ordered-collection discipline is load-bearing for saves, not just for golden tests.
  • Rationale: A world that stores its tiles cannot scale to body-sized 3-D volumes and bloats saves; a pure-function world with a transient cache + a sparse mutator log scales to any size, makes saves trivially small, and is the only model under which "dig anywhere, to any depth" is free (the subsurface was always computable — digging just reveals it). It also forces the determinism discipline the whole cascade needs anyway. The downward floor cap fell because it was solving a problem — per-layer storage cost — that derive-don't-store eliminates.

  • Open sub-questions: the geology-model fidelity (simple depth-horizon stack vs tectonic-grade folding/faults) and how far FloorMaterial is derived now vs deferred to the city layers (both tracked in D-228 / Q-101); the mutator op schema (Q-103).

  • Implementation: Phase 4+ (epic T-750). The caching substrate exists at the atlas level (BodyWorldStateCache, D-203/D-225); the tile/voxel cache, mutator overlay, and geology derivation land as the cascade reaches the tile layers. No migration needed pre-save (D-202 schema_version lineage covers future drift once saves exist).

  • Amended 2026-07-17 (T-1125 — the invention carries geographic content into district classification; two-tier driver model): the "invented deterministically (interpolation + domain warp + detail-scatter)" clause is now implemented with character at the district tier, closing the T-1123 finding that classification consumed only the raw bilinear envelope (pixel-smooth coasts; scatter amplitude slaved to coarse heightmap slope ≈ 0 exactly on low-relief coasts). Mechanism (atlas/coast_invention.rs + district_profile::invent_primitives, shared by the on-demand derive_district AND batch derive_district_profile paths so they can never silently diverge): (a) coastline domain-warp — every envelope field (elevation, slope, ocean mask) is bilinearly sampled at the same warp-displaced position (C¹ multi-octave value noise, ≈16262 km band, sub-pixel amplitude cap 0.75 px, distinct salted hash stream — never correlated with the terrain scatter or climate edge-fuzz), inventing bays/capes/fjord inlets while the heightmap stays the truth at its own scale; (b) slope-independent scatter floor — the detail-scatter envelope gains a character-driven floor (invention no longer collapses on flat coasts; the old "flat envelope → zero invention" reading is superseded — the envelope rule survives as a ceiling: gentle bounded relief, never mountains on an authored plain); (c) shoreline carving — ridged character contributes real slope in shoreline patches so the steep coastal families (Fjord/CliffCoast) can fire where glacially/tectonically justified. Crinkle varies (Jeroen's ruling): two driver tiers, zero new authored data. Tier 1 (body personality envelope): planet_class (via TectonicClass) + hydrosphere + atmosphere + body_radius_km (via the pixel⇄metre seam) only — D-240 stands: no orbital/tilt inputs; erosion-proneness is derived (more ocean → wetter/rainier → higher erosion → smoother mature coasts; dry/thin-atmosphere → sharp young coasts). Tier 2 (position): latitude, driver-tier GlaciationGrade (fjordy high-latitude glaciated coasts), local wetness, and a seeded ~100400 km heterogeneity field so stretches of the same coast differ; longitude participates via absolute world-metre noise keying. Circularity ruling: the driver-tier climate (glaciation/moisture) reads the unwarped raw-bilinear primitives — one-step-stale by design, documented at the call site. All pure (seed, body, position) (D-010); character never steps on a district/region line (D-243 edge-fuzz discipline).

  • Amended 2026-07-23 (T-1170 — river courses join the invention family; full ruling in docs/architecture/river-courses-t1170.md): the "invented deterministically" clause now covers linear features: river courses between D8 cells are pure (seed, body, edge, rung) functions — a rung-independent valley-seeking coarse path (bilinear TerrainAnalysis proxy, never full re-derivation) plus rung-indexed perpendicular warp octaves under the min_wavelength_m truncation discipline, distinct salted stream, amplitude tapered to zero at cell-centre anchors (confluence continuity), stations at global arc-length positions (window-independent). Round-1 relief reconciliation is course-follows-terrain (valley preference); terrain-carves-for-course is the deferred converse, targeted at block/tile rungs.

  • Amended 2026-07-24 (body-map-viewer workshop — four cache/derive additions; full record D-255): the derive-don't-store model gains four additive amendments from the body-map-viewer render architecture. All four leave derive-don't-store's core intact; none makes any derived value a function of mutable state.

    • (1) Map-time TTL-split + staleness-vs-storage as distinct eviction axes (Jeroen). The Atlas map shows current state via a TTL-split: static geometry (morphology/elevation/moisture/vegetation/glaciation/height — everything this record covers) is cached indefinitely-fresh (determinism → re-derivation is byte-identical, so "stale" does not apply); sim-state planes (frozen/flooded, D-253-driven) are separately-cached short-TTL planes re-requested as sim time advances. Two distinct eviction axes (Jeroen, verbatim: "we still may also want to evict non global level geometry based on time to save storage for planets the player visits but never goes back to"): staleness-eviction applies only to sim-state planes (they genuinely go stale); storage-eviction applies to all sub-global cache entries including geometry — evicted on time-since-last-visit purely as a storage-budget policy, not because the data is wrong (a re-derive on next visit is byte-identical and cheap). The global tier alone (the rung-0 Global body-surface canvas, D-255 §c) is keep-always, exempt from both axes.
    • (2) Persistent client-cache schema/version tag (the one place D-192's co-ship guarantee does not reach). D-255's disk-backed client cache (user://atlas_cache/) survives a game update — a cache file written by version N read back by N+1 crosses a version boundary D-192 assumes away for the live wire. Requirement: every persistent cache entry carries a schema/version tag (the game's project.yaml version or a generator_sha-style stamp), checked at read time — a mismatch is treated as a cache miss, re-fetched, never decoded. Consistent with this record's "cache never truth, evict → recompute always valid": a version-mismatched entry is just another eviction case.
    • (3) Seed-chaining as a "cache-accelerated pure function" (Jeroen ratified — D-255 §a.10). A finer step may consume a coarser step's resolved output (the outline's "serves as seed information for the deeper cascade") without weakening derive-don't-store: the DEFINITION stays pure (derive(seed, position), byte-identical every time); the IMPLEMENTATION may read a resident coarser canvas as an OPTIMIZATION with a derive-fresh fallback. This is an optimization, not a semantic dependency, because the coarser canvas is itself evictable derived data — a pure function of the same seed — so correctness never depends on the cache being warm (evict → derive fresh → byte-identical input, this record's own "eviction → recompute, always valid" test). What is consumed is the coarser rung's continuous primitive baseline (the shipped district-reads-region pattern), never its resolved categorical classification (that would violate the per-rung dominant-mode re-derivation discipline). A mandatory determinism test (cache-hit path == cache-miss path, byte-exact) is the correctness gate; the ①②③ benched costs are the cache-cold worst-case ceiling (the fallback path), so the shipped system is never slower than measured. No chain-reaction on eviction: each rung's fresh-derive fallback is self-contained (derive(seed, position) takes nothing but those two inputs).
    • (4) Lakes sourced from settled hydrology (data-source fix, no wire/vocabulary change — D-255 §a.11). MorphologyZone::Lake (discriminant 1, already in D-239 §6's frozen 17-zone set — not a new zone) is sourced from HydrologyResult's (T-1177) settled-equilibrium basins instead of the crude ocean_fraction_q >= 60 heightmap heuristic, falling through to that heuristic where no basin exists. Water derives from the continuous filled_scaled field sampled bilinearly per rung (mechanism B above — the same way sea_level is sampled), not projected basin-cell membership (which would give a blocky, non-refining lake edge — the D-166-corollary magnified-composite artifact); so lake edges refine with zoom. Lake basin geometry is static (settled equilibrium, cached indefinitely-fresh), distinct from the short-TTL flooded sim-state plane of (1). Endorheic-vs-overflow reads via OUTFLOW-COURSE PRESENCE — an overflow basin's outlet edge appears in courses (T-1170), an endorheic basin's does not — no wire bit, no 18th zone (proportionality: 4.63% endorheic = 1,030/22,270 basins does not justify permanently widening the frozen vocabulary; the outlet-wiring is required regardless for hydrologic honesty; and the inference is exact — every Overflow basin has a non-empty outlet_path, every Endorheic none). The cliffs list is NOT the cue (T-1177 survey: zero carved cells across all 267 real bodies). Honest sequencing: until the basin-outlet→D8 wiring ticket ships (additive, pre-cleared by T-1170 Ruling 7b's reserved TERMINAL sentinel), the map shows lakes but not the drains-vs-closed distinction.
  • Raised by: Jeroen (derive-don't-store, volumetric, drop-the-floor-cap directives) + Claude, atlas-derivation workshop, 2026-05-25.

  • Cross-reference: D-010 (determinism — now save-critical), D-222 (subtile/tile/chunk hierarchy), D-110 (signed z-levels), D-225 (layer-stream proxy + cache pattern), D-203 (LRU cache tier), D-224 (SeedChain — feeds derive), D-228 (composite tile schema — the derived value type), D-253 (transient sim-state — the short-TTL plane the map-time split carries), D-255 (body-map-viewer render architecture — the four amendments above), Q-101 (refinement contract), Q-103 (mutator op schema), Q-104 (floor↔voxel-z mapping)

  • Dissent: None


D-228: Composite tile schema — orthogonal axes, derived shape, region-level morphology

  • Date: 2026-05-25
  • Decision: A tile's "type" is not a flat enum but a small bundle of orthogonal axes (all derived per D-227 — this is the cache's value type, not stored state). Named types ("fjord wall", "mountain pass", "river bank") are derived display labels, never stored: a fjord wall and a sea cliff are identical to the simulation (Cliff + Rock + DeepWater), differing only in name.
    • Per-subtile axes: TerrainMaterial (permanent natural ground — Soil / Sand / Gravel / Rock / Wetland / Lava) · FloorMaterial (built surface over the ground — None / Concrete / Pavement / Carpet / Metal / …) · Vegetation (ground cover — Barren / Grass / Scrub / Thicket / Forest / Crop / Cleared / …) · Water (local depth state — Dry / Shallow / Deep) · elevation (scalar, metres). Snow and Ice are not TerrainMaterial — they are seasonally dependent, so they live in the seasonal cover overlay (Q-105), present only when the region's seasonal state puts them there (permanent only where climate never melts them — poles, glaciers). The FloorMaterial / Vegetation vocabularies stay open, extended by the layers that own them.
    • FloorMaterial and Vegetation are override stacks, not single derivations. Each resolves in precedence wild/natural (biome + climate) → economic (managed: farmland, plantation, paving, clearance — from the settlement layer) → user (mutators: chopped, planted, built, demolished); the topmost present layer wins — the same derive-don't-store + mutator pattern (D-227) as the rest of the world. Vegetation carries cover/concealment, the movement sound profile, and economic yield (timber / crops) on one axis — and managed crops follow the region seasonal cycle (sown → growing → ripe → harvested → fallow), so farmland visibly turns over with the year (Q-105).
    • Water is a dynamic depth state, plus derived flow. The axis is depth/wetness only; the water feature semantics (river / ocean / lake / sea / tidal-flat / delta) are the region morphology zone (below), not a per-tile value, and flow direction derives from the D8 network where moving water applies. Depth is time-varying via a deliberately cheap, deterministic, clock-bound water-height — a seasonal term phased by hemisphere (latitude sign) and a tidal term that exists only if the body has a moon (no satellite → no tide), amplitudes as body parameters. The water-height is a region property computed once per phase (not per tile, not per frame); per-tile flood state is then just region-water-height vs local elevation. So floodplain / tidal-flat / seasonal-river emerge rather than being placed — a low alluvial-plain floods at high water and recedes at low. The static world stays static; only this region water-height carries the clock overlay (sim-time as the derivation input), recomputed on phase change — the dynamic-state path D-226 anticipates. Passability derives from Water + elevation + material together. (Model → Q-105.)
    • Derived, not stored: shape / geometry (flat / angled / drop / cliff) = f(elevation-step × material) — sand slumps to an angle of repose, rock breaks to a vertical face on the same 1 m step; drives the rendered tile geometry. Tactical form (crest / hollow / channel / bank — cover, sightline, concealment) read from elevation curvature + water adjacency on demand. Storing either would mean updating 8 neighbours per elevation change, and both are clean functions of data the tile already holds.
    • Region-level (shared by all tiles in a region, not stored per-tile): morphology zone — fjord / delta / meander-reach / alluvial-plain / open-ocean / lake / sea / … — the whole-shape decision a single tile cannot see; it (a) governs which tile-tags get laid down, (b) carries affordances a tile can't (this inlet is a sheltered harbour), and (c) owns the water-feature semantics (river vs ocean vs lake vs sea) the per-tile Water axis deliberately omits. Morphology resolves top-down (region → tile), never bottom-up — a tile cannot know it is in a fjord from its neighbours; deriving it per-tile would produce the squaring the model exists to avoid. Sub-biome, province, and the region's seasonal/clock state are likewise region properties — the season is computed once per region per phase and inherited by its tiles. That single cheap seasonal state drives water-height (flooding / tides), seasonal snow cover, weather, and the farmland crop cycle alike — one calculation, many dynamic overlays (→ Q-105).
    • Separate axes: biome (climate / sub-biome — region-level, authority unresolved, Q-100; distinct from, but informs, the per-tile Vegetation cover), resource hints (bitmask: fertile / mineral / timber / groundwater).
    • Cohesion matrix (anti-squaring at the material layer): intra-region material + sub-biome scatter (a dirt patch in grass, a lone rock in beach sand) comes from a global, position-keyed continuous noise field — never per-chunk — so transitions never reveal chunk/grid seams. Straight lines appear only when authored (roads, plazas, field edges); a straight line must always have a placed cause, never be a generation-grid artifact. (Algorithm → Q-102.)
  • Amended 2026-07-02 (D-246 — intra-class micro-habitat mosaic): the Vegetation and TerrainMaterial vocabularies (left above "open, extended by the layers that own them") gain the values the sub-chunk micro-mosaic distributes over — Vegetation::Meadow (forb-rich herbaceous), Vegetation::Deadfall (dead woody debris — obstruction + fuel, no canopy concealment), Vegetation::Lichen (crustose/pioneer biological crust on rock/lava); TerrainMaterial::Hardpan (compacted flat crust — hard footing, does not slump like sand), TerrainMaterial::Scree (loose angular rock debris — unstable footing; the derived name is "talus" at a cliff foot, "scree" on an open slope). These are new values on existing axes, not a new axis — the composite schema is unchanged. They are front-loaded in one amendment (not grown incrementally) because each value is a distinct selectable outcome in the mosaic's weighted palette, so adding one later re-rolls the deterministic realization of every affected voxel. The full per-class vocab→axis table and the palette-key rule live in D-246.
  • Rationale: A flat enum explodes combinatorially (≈5×5×8 ≈ 200 mostly-incoherent variants), can't be queried by axis ("all water-adjacent tiles"), and grows a new variant for every new mechanic. Orthogonal axes are set independently by different cascade passes (material from sub-biome, water from drainage, shape from elevation), independently queryable, cheap to serialize, and compose without explosion. Names are visual/narrative and belong downstream of the simulation.
  • Open sub-questions: how far the FloorMaterial / Vegetation vocabularies are enumerated now vs deferred to the settlement layers; the exact morphology-zone vocabulary; the dynamic water-height model (→ Q-105).
  • Implementation: Phase 4+ (epic T-750), as the cascade reaches the tile layers. Today's TileKind (D-049 render stack) is the seed of the per-subtile axes; SubBiomeVariant (D-210) is the region biome axis.
  • Raised by: Jeroen (FloorMaterial, shape-from-material, cohesion-matrix directives) + Claude, with the atlas-derivation workshop four — Tyre (composite data-structure), Gestalt (tag model + tactical form), Nigel (morphology-as-character), Burnelli (economic distinctions), 2026-05-25.
  • Cross-reference: D-227 (derive-don't-store — these axes are the derived value type), D-010, D-210 (sub-biome — region biome axis), D-222 (subtile/tile), D-049 (z-stack render — TileKind seed), D-208 (D8 — flow-direction source), D-226 (dynamic-state inspection — the water overlay), Q-100 (biome authority), Q-101 (refinement contract), Q-102 (cohesion-matrix algorithm), Q-105 (dynamic water-height)
  • Dissent: None

D-229: Building-property-tag schema — the step-3 fill output

  • Date: 2026-05-25
  • Decision: Define BuildingPropertyTag, the typed step-3 output that replaces the String stubs (era, society_profile, zone_palette, chunk_layout) on the skeleton. One tag per building footprint placed in a block; written once at plan time (inside the extended GenerateSkeleton, D-230), read-only thereafter by three consumers — FillChunk (D-230), the guarantee audit (D-097), and the Phase-6 interior generator (D-231). Fields:
    • zone_type_id: ZoneTypeId (Box<str> matching a RON id) — what the building is; the wire to the 31 D-142 zone-type RON files. Refined from district_mix.rs's per-block ZoningType via a generator lookup (ZoningType + economic_role + seed) → ZoneTypeId.
    • footprint: TileRect — integer tile-space rect within the 128-tile block (D-010 integer-only).
    • extent: FloorExtent — floor/basement extent; resolves Q-104 (below).
    • entry_class: BuildingEntryClass — physical access character: Public | Commercial | Restricted | BreachOnly. Named BuildingEntryClass, not "access tier", to avoid colliding with D-028's relational dialogue layers. Derived from zone_type × layout_mode (D-096) × prosperity (D-197): Commission-Grid → formal/logged/corporate-or-authority credentials; Organic → social/reputation/unlogged credentials. U-curve degrade: low prosperity on a normally-Commercial zone → BreachOnly (derelict).
    • flavor_ref: ArchitectureFlavorRef — which trait template characterizes this building (D-232); no rolling-economy read. (Amended 2026-07-08, T-994/T-1003: now an enum, not a bare index — InVocabulary(u8) (index into the body's closed trait_selection, resolved via the phase-2 district-dominant pick) | Swerve(tag) (the rare out-of-vocabulary deviation draw, or the sparsity escape hatch). The original (seed + zone_type) → index mechanism was the pre-three-phase-draw stopgap.)
    • era: ConstructionEra (Founding | Established | Modern | Derelict) + era_cause: EraCause — feeds ZonePalette modifier axis C (D-101) and sets the D-217 condition floor. Derived from founding_age_years + prosperity_baseline + seed; a body carries mixed-era buildings (founding period anchors the distribution; seed scatters outliers).
    • initial_condition: TileCondition — frozen-amber snapshot from prosperity_baseline (D-197/D-217). The rolling condition overlay (D-198) paints over this; it never mutates the tag.
    • FloorExtent { base_floor: i8, floor_count: u8, heights: FloorHeightProfile } where FloorHeightProfile = Uniform(u8) | Variable(Vec<u8>). Q-104 resolution (the D-110 ↔ D-227 bridge): two pure functions — floor_at_voxel_z(z) -> Option<i8> and voxel_range_for_floor(f) -> Option<(i32,i32)> — map D-110 floor-index addressing onto D-227 physical voxel-z. Default Uniform(3) (3 voxels ≈ 3 m/floor, per Jeroen); a cathedral/hangar is Uniform(10); a mixed-use stack is Variable([5,3,3,3,3]). The Variable branch carries per-floor memory only when floors actually differ.
  • Rationale: A typed tag is the single contract that lets the atlas render a building, the guarantee audit validate a district, and Phase 6 seed an interior — all from one frozen object. String stubs cannot carry any of that. Orthogonal fields (what / where / how-tall / who-may-enter / cultural / era / condition) compose without a combinatorial enum explosion, matching D-228's axis discipline at the building scale.
  • Implementation: Phase 4+ (epic forthcoming). Replaces the stub fields on BlockSkeleton/QuarterSkeleton in server/src/simulation/generator.rs.
  • Amended 2026-06-05 (T-957 — authored zone-type selection table): the (ZoningType + economic_role + seed) → ZoneTypeId lookup is made concrete for the planetary cascade. A third input, setting (SettingType), is added as a tweaker (not a surface/station switch — station & orbital bodies run a separate cascade per Q-109 and own the station-only ids residential_station/extraction_space/port_space/rural_orbital, which this table never selects). zone_type_for(zoning, role, setting, seed) builds a candidate slice from the base table below, applies the setting tweaker, then deterministically seed-picks one id. Every base cell is non-empty (no empty slices — mirrors D-195's no-zero rule); an unknown role falls back to the ZoningType default.
    • Base table (planetary variants): Commercial → [commercial_market, entertainment_hospitality] (financial→[diplomatic_elite, commercial_market]; transit_hub→[commercial_transit, commercial_market]; service_mixed→ +entertainment_venue). Residential → [residential_surface] (agricultural→[rural_agricultural, rural_pastoral]; extraction→[residential_dispersed, residential_surface]). Industrial → [industrial_manufacturing, industrial_freight] (manufacturing→[industrial_manufacturing, industrial_processing]; extraction→[extraction_surface, industrial_processing]; agricultural→[industrial_processing]). Administrative → [administrative_civil] (institutional→ +administrative_judicial, diplomatic_elite; research→[research_station, administrative_civil]; service_mixed→ +medical_facility). Transit → [port_surface] (transit_hub→[commercial_transit, port_surface]; manufacturing|extraction→[industrial_freight, port_surface]). Recreational → [entertainment_venue, entertainment_hospitality] (research|institutional→ +archaeological_site). Restricted → [security_checkpoint] (military→[military_garrison, security_checkpoint, detention_facility]; research→[research_station, security_checkpoint]; institutional→[detention_facility, security_checkpoint]). Mixed → [residential_surface, commercial_market, administrative_civil].
    • setting tweaker (post-pass): Maritime/Water → port_surfaceport_maritime (+port_fishing for Transit), rural_*rural_aquaculture, extraction_surfaceextraction_platform. Agricultural → bias rural_agricultural/rural_pastoral into Residential/Mixed. Wilderness → surface wilderness_frontier/residential_dispersed at the frontier. Urban/other → base unchanged.
    • Entry-class U-curve threshold (gap fill): the "low prosperity on a Commercial zone → BreachOnly" degrade uses the D-217 bands — prosperity_baseline_bps < 2300 (the D-217 Broken band) → BreachOnly. Era follows this record as written (founding_age_years + prosperity_baseline + seed); the round-3 "distance-to-origin" note is dropped (not in the data model). FloorExtent uses the D-220 density-class floor midpoints with seed-jitter within the class range. Doors are not populated here — doors: Vec::new(); door derivation (D-231) is T-979.
  • Raised by: Tyre (schema + FloorExtent/Q-104), Gestalt (BuildingEntryClass, derivation), Miri (flavor_ref), economic-built-world workshop round 2, 2026-05-25.
  • Cross-reference: D-142 (zone-type taxonomy), D-096 (layout mode), D-197 (prosperity baseline), D-217 (tile condition), D-110 (signed z-levels), D-227 (voxel substrate), D-101 (ZonePalette), D-028 (dialogue access — name disambiguated), D-230, D-231, D-232, Q-104 (resolved here)
  • Dissent: None

D-230: FillChunk two-phase execution model — background-plan / on-demand-derive

  • Date: 2026-05-25
  • Decision: Building fill runs in two phases, split by latency budget. Plan phase (background, no budget): building-property tags + footprints are produced inside the extended GenerateSkeleton Rayon task — it already holds the full CityGenerationContext, so footprint subdivision + tag assignment stay contiguous with the skeleton work. Output: DistrictWorldState { skeleton: DistrictSkeleton, block_tags: BTreeMap<(u8,u8), Vec<BuildingPropertyTag>> }, stored in BodyWorldState.districts: BTreeMap<DistrictId, DistrictWorldState>BTreeMap everywhere for D-010 determinism. Derive phase (FillChunk, on-demand, <5 ms): pure geometric shell derivation — read the cached DistrictWorldState, and for each tag shell_derive(seed, footprint, extent, z) → {Void | Wall | FloorSlab | Roof} per voxel; fill interstitial tiles (street / open space) from density_pct + the D-215 pattern; apply initial_condition. Cost estimate: ~40 k voxels/chunk × ~1015 ns = 0.40.6 ms, well under budget even for tall skyscrapers. Pre-condition: FillChunk is only dispatched after GenCompletion::SkeletonGenerated for that district has been processed; if block_tags is absent, re-enqueue at High and warn — never a blocking read on the main thread.
  • Rationale: The expensive, variable planning work (district mix, tag assignment, footprint layout) has no place under a per-chunk latency budget; doing it once in the background and reducing FillChunk to cache-read + rectangle-containment + z-range lookup is what makes on-demand fill trivially fast and re-derivable (D-227).
  • Code-gap flagged (→ implementation ticket, independent of this workshop): GenCompletion::SkeletonGenerated currently returns only city_id: u64; it must carry the DistrictWorldState back so the main thread can insert it into BodyWorldState.districts. The completion routing is incomplete today regardless.
  • Implementation: Phase 4+. Amends D-200 (extends the runtime-background execution tier). FillChunk/ChunkGenWorker are no-op stubs today (gen_queue.rs, workers/stubs.rs).
  • Raised by: Tyre, economic-built-world workshop round 2, 2026-05-25.
  • Cross-reference: D-200 (three-tier execution — amended), D-203 (LRU cache), D-225 (layer-stream proxy), D-227 (derive-don't-store), D-215 (arrangement patterns), D-220 (density), D-010 (determinism), D-229
  • Dissent: None

D-231: DoorSpec and InteriorDescriptor — the step3→step4 boundary and the Phase-6 seed

  • Date: 2026-05-25
  • Decision: The door is the boundary descriptor between the generated exterior (step 3) and the lazy interior (step 4, Phase 6) — not yet an interactive object (that is Phase 5). Each building tag carries doors: SmallVec<[DoorSpec; 4]> (≥ 1). DoorSpec { facing: CardinalDirection, door_class: Main|Service|Emergency|Hidden, entry_class: BuildingEntryClass, initial_state: Open|Closed|Locked|Sealed, credential: None|TemporalWindow(hours)|Corporate(corp)|Resident(block)|Authority|Social(f32), connects_to: Street(id)|AdjacentBuilding(block)|Interstitial, interior_descriptor: InteriorDescriptor }. Door-count derivation: 1 Main minimum; Service if the zone has a logistical function; Emergency if floor_extent.above_ground ≥ 2; Hidden seeded by zone_type (research ~60% / admin ~20% / residential ~5%) — Hidden carries the D-106 Rooftop-Bar discovery layer. Initial-state derivation: Public → Open/Closed, Commercial → TemporalWindow(zone hours), Restricted → Locked + credential, BreachOnly → Sealed. Mutation semantics (D-227): initial state is frozen-amber derived; runtime changes (faction lockdown, a picked lock) are tile mutators over the frozen base, never edits to the tag — the base is always re-derivable. InteriorDescriptor { zone_type_id, entry_class, floor_extent, era, flavor_ref, prosperity, layout_mode } is the complete Phase-6 seed: a future interior generator produces a deterministic floor plan from this descriptor + SeedChain with no other system queried. The voxel at a door position is a conditionally-passable solid — solid until Phase 6 activates the interior, then void.
  • Rationale: Phase 6 (D-166) defers interiors, but its seed must be fixed now or step 3 cannot guarantee a generable interior later. Carrying the descriptor on the door (the exact threshold where generation will fire) keeps the lazy-interior contract local and self-sufficient, matching the planet-down-cascade "descriptor + catalog behind the door" rule.
  • Implementation: DoorSpec/InteriorDescriptor structs land in Phase 4 (planned on the tag); the door-open fill is Phase 6. ChunkMutations/TileOverride already exist as the mutator layer.
  • Raised by: Gestalt, economic-built-world workshop round 2, 2026-05-25.
  • Cross-reference: D-229, D-097 (guarantee audit reads connectivity), D-106 (rooftop clause / hidden door), D-110, D-227 (mutators), D-142, D-217, D-166 (Phase-6 deferral)
  • Dissent: None

D-232: Architecture-flavor — trait-template catalog (economically gated, seed-drawn, wiki-biased)

  • Date: 2026-05-25 (round 3 — supersedes the round-2 per-body-Gemma-array draft of this record)
  • Decision: The architecture-domain completion of D-167 (which retired the 7 abstract heritage roots and made the wiki the cultural register but never built the wiki→generator mapping). Cultural flavor is economically founded, seed-randomized from a shared trait-template catalog, with the wiki biasing the draw for hero bodies (Jeroen's model). This replaces both the round-2 per-body Gemma arrays and the rejected dimensional per-axis draw.
    • The catalog (holistic templates, not per-axis traits). A single shared architecture_trait_catalog.tomltrait_templates table. Each template is a coherent bundle — a template defines a relationship between its axes, so it is never decomposed: { tag, label, cultural_description, allow: [ObjectTag], block: [ObjectTag], zone_affinity: {DistrictType→weight}, era_scope, eligibility (two-tier, below), base_weight, visual_bundle (→ D-235) }. Per-axis mixing is forbidden (it produces incoherent grammar, e.g. stone walls + flat roof).
    • Two-tier eligibility — and role is per-layer, not global. Hard gates (bulk_class, prosperity, production_ubiquity) exclude a template from the pool when failed. Weight modifiers (economic_role, dominant_faction, founding_age, geographic_sector, morphology_zone) multiply base_weight but never exclude. Key principle: the same input plays different roles at different layers. morphology_zone is a hard gate on street geometry (D-234 — a fjord cannot have radial streets) yet only a soft weight on cultural-template eligibility. Economics founds and hard-gates what a building is (D-233) but only weights how it is characterized. geographic_sector (corridor) is always a soft weight, never a gate — "corridors are tendencies, not borders"; a hard corridor gate collapses the pool to ~7 templates/corridor → ~100 % within-corridor collision.
    • All eligibility numbers are integer basis-points (min_prosperity_bps etc.), never f32 — D-010 determinism, now save-critical under D-227.
    • The draw (three phases). (1) Body vocabulary (background, at skeleton time): hard-gate filter → weight (mods × wiki-bias) → SeedChain-seeded weighted draw of K templates → stored as trait_selection: Vec<String> on the skeleton. K is locked to complexity_tier: Full = 5, Moderate = 3, Minimal = 1, Empty = 0 (Nigel's birthday math: K=4 fails within-corridor uniqueness at a 35-template catalog; K is not a range and not keyed to prosperity). The draw is coverage-aware — it must cover the body's actual district-type mix, not draw 5 templates that all starve the civic district. (2) District-dominant (at fill): each district draws one dominant template from the body's vocabulary, weighted by zone_affinity, applied whole to that district (Araminta's composition rule); secondaries surface on outbuildings/secondary streets and in their own high-affinity districts. The body vocabulary is closed by default (a mining town's civic hall looks like that town's civic — a feature). (3) Within-template variation: the fill seed picks within each template's allow-lists per axis.
    • Deviation system — closed by default, rare tasty swerves. On top of the closed vocabulary, a rare per-building wildcard can draw a coherent whole template from outside the body's vocabulary (the Shinto temple in Amsterdam — a complete foreign building, never an axis-scramble). The swerve is cultural only; the building's function still passes the normal economic hard gates. Three sources, two opposed active drivers: foreign import (another corridor's grammar) driven up by cosmopolitanism / centrality / transit / Epicenter tier; heritage callback (the body's own corridor heritage pool) driven up by remoteness / isolation / conservatism (the cut-off Latin world that reaches back to haciendas); and the passive past-vogue holdover (just old, see era below). The sparsity escape hatch is the same mechanism triggered by necessity rather than dice — when the closed vocabulary genuinely cannot serve a district, it reaches the full catalog.
    • Corridor = a two-part pool. Each corridor is authored as a baseline character (its default cohesive look — e.g. East-Asian: cyberpunk density + utilitarian industry) plus a heritage sub-pool (deep-history callbacks — East-Asian: fishing-village/temple enclaves; Iberian/Latin: haciendas, colonial-revival) tagged so the remoteness dial draws specifically from it. A shared cross-corridor pool feeds the foreign-import swerves.
    • Era = maintenance/wear, NOT a material-tech ladder. The Reach is post-space-travel throughout; there is no stone→concrete→glass progression — fashion cycles fast and arbitrarily. A building's construction era (D-229, from distance to the founding origin) reads primarily as age/wear, realized through the condition layer (D-217 / D-198 — maintenance modulates how much wear shows). The "different-looking old building" is just the past-vogue holdover — the temporal sibling of the spatial swerve, one deviation system with sources in elsewhere and elsewhen. (This retires the round-2 era-band material-progression and the era_fallback-as-tech-ladder; era_fallback survives only as the asset-resolution fallback chain, below.)
    • Logical catalog vs visual-asset layer (the incremental-content split). The logical catalog — tags, eligibility, allow/block, token references — is cheap data, authored complete and frozen at launch; the draw reads only this, so derivation is deterministic and stable forever with no catalog versioning needed. The visual-asset layer is a fallback hierarchy: every specific texture/material token declares a generic parent it degrades to (temple_wall_wood → generic wood_wall placeholder until the specific art ships, then it upgrades in place). Themes/textures are patched in incrementally behind stable tokens — a shipped world's structure is frozen at birth (the temple was always a temple), only its render fidelity sharpens. Versioning re-enters only if a genuinely new logical template is added post-launch (minimized by authoring the catalog generously up front).
    • Wiki bias (hero bodies only, ~3040). A sparse atlas_body_trait_bias row: pin (mandatory, counts toward K), boost (≤ 3× weight), suppress (≥ 0.33×, never 0 — preserves second-playthrough surprise). Bias is per-body, never per-corridor. Non-hero bodies (the ~240 remainder) run the identical algorithm with no bias — economics + corridor weight + seed.
    • CI guardrails (Nigel, build-time validation): ≥ 5 templates eligible after hard gates per economic class; no single template > 60 % of pool weight after modifiers; bias is per-body; the catalog grows ≥ 1 template/dimension per new corridor or archetype.
    • Catalog population: ~2535 templates at floor, 4045 target. Core hand-curated (Miri authors cultural meaning + eligibility; Araminta authors the matching visual_bundle), then a bounded Gemma corpus-distillation pass (one read of all wiki, propose new templates, human-gated) — not per-body generation.
  • Channel separation from D-233 (held): D-233 decides what a building is (vocabulary, coverage); D-232 decides how it is characterized. Same inputs (e.g. bulk_class) used non-conflictingly — D-233 as hard function gate, D-232 as soft cultural weight. They compose at fill.
  • Amended 2026-05-31 (D-237 — authored specialization layer): the template draw now reads an authored cultural_specialization (new column on system_economy) that selects/biases the template pool when a system's cultural character diverges from its corridor baseline. The field carries two value sub-types in one column — activity/character values (agrarian, industrial_heritage, institutional, scholarly, etc.) and heritage values (scottish, vietnamese, zulu, french_provencal, etc.); heritage values take precedence where present. This is a Phase-4 correctness fix, not just flavor: without it, a system whose founding heritage diverges from the corridor (e.g. Vietnamese-founded Dài Lộ in the east_reach Korean/Japanese corridor) draws the wrong cultural templates. NULL = use the existing corridor-pool algorithm unchanged. Consistent with the held channel separation — cultural_specialization is a D-232 cultural-weight input, never a D-233 function gate. Singular landmarks (e.g. Groombridge's GSH within a financial_hub district) are expressed via D-222 multi-block reservation + an atlas_body_trait_bias hero pin, not the system-level field.
  • Amended 2026-07-08 (T-1003 — deviation/swerve system implemented, driver inputs pinned): the two opposed drivers now map to real fields (server/src/atlas/trait_swerve.rs): foreign-import scaled up by WorldTier::Epicenter/Passage (transit), dominant_faction = "mixed" (cosmopolitanism), and road/rail-graph node degree (centrality); heritage-callback scaled up by road-graph isolation (degree ≤ 1), remote tier (Waypoint/Backwater), and founding_age_years bands (conservatism). Rates are integer bps: base 100 bps/building per driver, hard cap 300 bps — placeholder constants pending Nigel/Burnelli calibration; a dist_ly-percentile remoteness input is deferred until the read-set carries it. The wildcard result is recorded as ArchitectureFlavorRef::Swerve(tag) (out-of-vocabulary by construction); pools are hard-gate-eligible only (cultural-only rule held). The sparsity escape hatch is implemented as the same mechanism, necessity-triggered at the phase-2 district-dominant pick (deterministic max-weight, no dice). The passive past-vogue holdover stays on the D-217 condition layer, as decided.
  • Amended 2026-07-08 (T-994 — phase-2 "district" pinned to the D-243 tier): the word "district" in the phase-2 district-dominant draw means the D-243 2 048 m District cell (4 quarters), not the 512 m Quarter — this record was written after D-222 renamed the 512 m unit to Quarter, but the pin was never made explicit and the shipped code had no District-tier representation at all (caught in the 2026-07-07 /whats-next refinement; the "one template per district, applied whole" composition rule reads at 2 048 m). Implementation (T-994): the dominant template per (DistrictType) is pre-resolved at L3→L4 dispatch time, seeded by (SeedChain::for_body, district-cell position, district type) — so any settlements whose quarters share a District cell independently derive the identical dominant template with no cross-settlement coordination, and assign_block_tags is a pure lookup. Phase 1's body K-draw is likewise seeded from SeedChain::for_body (never the per-settlement chain), preserving the closed-vocabulary invariant.
  • Supersedes (architecture/generator domain only): D-104 (HeritageGrammarOverlay + per-root data → the catalog + allow/block), D-105 (heritage-root→informal-zone lookup → flavor-filtered selection; the three zone types survive), D-101 modifier axis A (HeritageRoot → catalog draw; axes B/C + faction/climate/condition/season unchanged), D-107 (per-root trauma decay → per-template/condition; the "trauma intensifies culture" principle survives). Also retires the round-2 atlas_body_culture / atlas_body_culture_era tables.
  • Storage: trait_templates (the catalog) + atlas_body_trait_bias (sparse, hero bodies). The per-body draw result lives as trait_selection: Vec<String> on the skeleton — re-derivable from catalog + economics + bias + SeedChain, no Gemma in the hot path. CityGenerationContext carries trait_selection + morphology_zone (replacing the round-2 flavor_profile; D-199 amend).
  • Source-location deferred: the human-authored source home (catalog file + bias.json) rides on Q-107 (wiki → Atlas content-set consolidation). The generator-facing tables are invariant to it, so the fill seam is unblocked regardless.
  • Implementation: Phase 4+ (mechanism + sparse catalog); visual themes fill in across Phase 5+ and post-launch behind the fallback chain. ObjectTag/material vocabulary is Miri + Araminta co-maintained.
  • Raised by: Jeroen (the trait-template / economically-founded / wiki-biased model + the swerve, corridor-pool, era-as-maintenance, and logical/asset-split refinements), with Miri (lead synthesis), Burnelli (eligibility), Nigel (draw + variety math + guardrails), Araminta (visual bundle + composition), economic-built-world workshop rounds 23, 2026-05-25.
  • Cross-reference: D-167 (corridor cultural system — completed here), D-223 (Gemma naming pipeline + corridor mixing), D-142 (zone types — the function baseline filtered by templates), D-228 (morphology zone), D-233 (economic channel — what vs how), D-234 (morphology→street — the hard-gate layer), D-235 (visual bundle + fallback hierarchy), D-217/D-198 (condition — where era's wear lands), D-224 (SeedChain — the draw PRNG), D-199 (read-set — extended), D-237 (authored cultural_specialization — template-pool selection), D-101/D-104/D-105/D-107 (superseded in part), Q-106 (era reframed), Q-107 (source location)
  • Dissent: None

D-233: Economic signal → block-fill vocabulary — BulkClass × ProductionUbiquity

  • Date: 2026-05-25
  • Decision: Within a block of a given district type (D-194), the building vocabulary, coverage density, and interstitial character derive from two commodity signals for the settlement's dominant commodity, both read from the systems.db commodity catalog (D-184) at build time — seed-independent, no D-199 tier split, no rolling-economy touch:
    • BulkClass = BulkSolid | BulkLiquid | PrecisionDense | Perishable | NonPhysical
    • ProductionUbiquity = Ubiquitous | Common | Specialist | MonopolySource
    • Coverage: BulkClass sets a roofed-coverage fraction (NonPhysical 0.850.95 → PrecisionDense 0.750.90 → Perishable 0.500.65 → BulkSolid 0.250.40 → BulkLiquid 0.200.35), scaled by density_class (D-220) and prosperity_baseline (D-197). For bulk industries the non-roofed remainder is operations surface (haul roads, ore pads, conveyor runs, tank berms) — tagged built economic infrastructure, not interstitial open space.
    • Vocabulary: each (BulkClass × production_tier) draws from a frozen building-vocabulary pool (e.g. mine_head/conveyor_run/tailings_area; tank_farm/flare_stack; cleanroom_facility/qc_lab; field_shed/cold_store; office_tower/civic_hall). A mine block places mine buildings, full stop.
    • Concentration/dispersion rule: ProductionUbiquity sets the spatial spread of zone-type blocks, not per-block weight — Ubiquitous scatters small instances through mixed-use (the settlement doesn't read as "a water town"); MonopolySource concentrates contiguous block groups (the mine is the settlement, everything else is support).
    • Residential follow-on: every non-residential production block emits a labor-demand signal sizing adjacent residential blocks (BulkSolid extraction ~3.0× → BulkLiquid ~2.5× → Perishable ~2.5× → PrecisionDense ~2.0× → NonPhysical ~1.5×); housing character from prosperity_baseline (>0.7 market-rate → 0.40.7 standard worker → <0.4 company barracks/squatter).
  • Frozen-amber: the vocabulary is t=0-derived and immutable; the condition overlay (D-198) paints Maintained/Worn/Abandoned but cannot change a BuildingTag — a mine processing plant becomes an abandoned mine plant, never an office.
  • Amends D-199 (adds dominant_bulk_class, dominant_production_ubiquity, morphology_zone to CityGenerationContext) and D-184 (back-fills the bulk_class enum values it named but left open). Enforcement: fill_chunk(ctx, seed) holds no PressureState reference.
  • Refined 2026-05-26 (batch refinement): BulkClass's 5 values are built-form archetypes — a projection of the 8 commodity cargo-types in commodities.toml (bulk/standard/compact/oversizedBulkSolid [size is orthogonal to bulk-form; a future axis if it earns visual payoff], precisionPrecisionDense, liquidBulkLiquid, perishablePerishable, non_physicalNonPhysical). The 8 stay on commodities for the economic model — no fidelity is lost; the 5 are only the built-form driver. The settlement's dominant commodity is derived from its highest-output location-bound production chain (D-178), not an authored role→commodity table — so the built form reflects the already-authored production/trade data (where sector specialization lives) and varies body-to-body for free.
  • Rationale: Floor-area-to-output ratio is the capital structure of an industry, not a style choice — services maximize floor, bulk extraction is mostly open operations surface. Driving coverage and vocabulary from BulkClass makes settlements that share an economic role look physically distinct by what they make, and the ubiquity rule gives the settlement its identity (a MonopolySource town vs background infrastructure).
  • Implementation: Phase 4+. bulk_class/production_ubiquity lookups added to the economics import; consumed in the plan phase (D-230).
  • Raised by: Burnelli, economic-built-world workshop round 2, 2026-05-25.
  • Cross-reference: D-194 (district mix — upstream), D-184 (commodity catalog — amended), D-199 (read-set — amended), D-220 (density), D-197 (prosperity), D-142 (zone types — downstream), D-176, D-180 (EconEvent — overlay trigger), D-198 (condition overlay — amended), D-229
  • Dissent: None
  • Re-amended 2026-05-31 (D-237 — authored specialization layer): The settlement's dominant_bulk_class and dominant_production_ubiquity are now sourced from the authored specialization layer (D-237) rather than derived directly from the highest-output production chain. Derivation chain: (1) If system_economy.economic_specialization is authored → resolve via specialization_vocabulary(economic_specialization)(commodity_id, production_ubiquity_override)(BulkClass, ProductionUbiquity). Use the vocabulary-specified ProductionUbiquity (which may override the catalog default per the equal-or-higher rule). (2) If economic_specialization is NULL → run the deterministic-varied heuristic (D-237 §fallback: seed-hashed weighted draw over the vocabulary using economic_role + corp HQ signals + noise term) → same projection. In both cases the resolved (BulkClass, ProductionUbiquity) populates CityGenerationContext.dominant_bulk_class and CityGenerationContext.dominant_production_ubiquity. Frozen-amber constraint unchanged: fill_chunk(ctx, seed) holds no PressureState reference. Within-system body variation (weighted draw over body-level candidates anchored to system commodity) remains seed-hashed. Groombridge clarification: The vocabulary expresses district-level character. Singular MonopolySource-class landmarks within a Specialist district (e.g. GSH within Groombridge's financial_hub cluster) are expressed via D-222 multi-block reservation + D-232 hero-element pin, not via composite vocabulary values.

D-234: Morphology zone → street and footprint constraints

  • Date: 2026-05-25
  • Decision: The D-228 region morphology zone constrains street geometry and footprint subdivision so terrain shapes settlement form — the variety guardrail that stops the generator placing a radial-core city in a fjord. Two rules:
    • (a) Permitted street patterns by morphology — a lookup that gates the D-215 archetype arrangement patterns: fjord/canyon → ribbon or hub-and-spoke only (streets linear along the terrain axis, radial impossible); delta/braided → hub-and-spoke following channels (bridges as forced nodes); alluvial-plain → any pattern; island → hub-and-spoke (perimeter access priority); mountain-pass → ribbon only (elevation steps as block boundaries).
    • (b) Waterfront footprint rule — any block adjacent to a water morphology feature uses pier/quay geometry on the water-facing edge (no standard setback, dock-orthogonal subdivision, access priority toward the water) and standard street frontage on inland edges. Applies uniformly wherever a block touches a water feature — body-independent, zone-independent. This is what makes port towns present to the quay differently than to the street.
    • Street geometry obeys the ±45° pathfinding cap (D-096) — no curves; straight lines appear only where authored (the D-228 cohesion rule). Fills the Step-1 chunk_layout / corridors / access_points stubs.
  • Refined 2026-05-26 (street-network algorithm — two layers): streets generate in two layers, mirroring real cities and the Civ road lineage. Arterials (corridors) = a minimum-spanning / least-cost graph over the district's key nodes (access-points, reservations, landmarks) — emergent trunk topology (Civ6-style, "roads where traffic wants to go"), ±45°-snapped; this is the node/edge graph the guarantee audit (D-097) reads (a chokepoint is an arterial bottleneck; an encounter-corridor is an arterial through-route). Ribbon = MST on linearly-constrained nodes; hub-and-spoke = MST with a forced centroid hub. Local streets (chunk_layout) = a ±45° grid lattice within each block (Civ4-style), modulated by the D-096 Grid/Organic mode (offsets + rotation). Voronoi was rejected — its arbitrary-angle edges violate the ±45° cap and its irregular cells break the axis-aligned TileRect footprint fast-path (D-229), degrading per-chunk fill from rectangle-containment to polygon rasterization. (Civ14's "road on every tile" is the anti-pattern this avoids; the arterial/local split is the city-scale fix.)
  • Rationale: Morphology zone and architecture_flavor were added to the context (D-199) as inputs without consumers — two economically-similar bodies otherwise produce topologically identical street networks and identical block subdivision (Nigel's same-y-grid failure). These two rules give morphology structural teeth so the fjord port and the delta port are categorically different cities, not reskins.
  • Implementation: Phase 4+. Consumed in the plan phase (D-230) when laying streets and subdividing blocks.
  • Implemented 2026-06-06 (T-957): the street network + footprint subdivision land in skeleton_gen.rs (absorbing T-976 into T-957 — one coherent walkable-quarter deliverable). AccessPoint/CorridorSpine/ChunkLayout are now typed structs (were String stubs): access nodes from road-entry octants + reservation gates; arterial corridors as a morphology-gated graph (Ribbon for fjord/canyon/mountain-pass, HubSpoke for delta/island/enclosed water, Prim-MST mesh otherwise), ±45°-snapped (a); per-block local lattice modulated by D-096 Grid/Organic. Footprint subdivision is a density-scaled BSP into axis-aligned TileRects with D-233 BulkClass roofed-coverage. Waterfront rule (b): the water-facing quarter edge (from the settlement's Coastal founding orientation, D-213) drops its street setback to 0 so buildings present flush to the quay. The water bearing itself is now extracted in Layer 1 (TerrainAnalysis::water_bearing, 8-octant integer) and fed into D-213 founding orientation (T-956's 0 stub is gone for coastal/river settlements). Pending dependency: the waterfront rule reads CityGenerationContext.founding_orientation, which city_context_reader still stubs to Cardinal — real per-settlement orientation only reaches quarter generation once the Layer-3 placement → Layer-4 GenerateSkeleton dispatch is wired (it must copy the placement's founding orientation into the context). That cross-layer dispatch is the remaining integration; the rule is correct and tested, awaiting its input pipeline.
  • Raised by: Nigel, economic-built-world workshop round 2, 2026-05-25.
  • Cross-reference: D-228 (morphology zone), D-215 (arrangement patterns — gated), D-096 (layout mode / ±45° cap), D-220 (density), D-229, D-213 (founding orientation — water bearing), Q-106, Q-109
  • Dissent: None

D-235: Building exterior visual grammar and material vocabulary

  • Date: 2026-05-25
  • Decision: The visible-form layer over the tags. (template × zone_type × density) → BuildingExteriorTag { wall_material, roof_form, setback_tier, facade_rhythm, color_range }, where template is the district's dominant trait template (D-232), derived in three steps: (1) the template's allow/block filters the available material + roof token set (a template is a coherent bundle — its axes are chosen together, never mixed across templates); (2) zone_type biases within the filtered set (probabilistic — the template can override, e.g. an allow: [stone_base] industrial block stays stone, not corrugated metal); (3) density sets setback_tier (Dense → zero_lot → … → Frontier → campus), which drives interstitial type (void / court / garden / plaza / dock_slip / market_pad / open_lawn). The fill seed then picks within each axis's allow-list. Era is NOT a material filter here (D-232 reframe): the Reach is post-space-travel throughout, with no stone→concrete→glass tech ladder — material choice comes from the template (fashion), and era reads as maintenance/wear via the condition layer (D-217), with the occasional out-of-vogue building handled as a deviation (D-232's past-vogue holdover), not a material rule.
    • Exterior vocabulary (extends D-228's FloorMaterial axis): WallMaterial (stone_cut/stone_rough/fired_brick/clay_render/heavy_timber/pile_timber/timber_frame/reinforced_concrete/corrugated_metal/steel_panel/composite_panel/precision_glass/smart_facade), RoofForm (pitched_steep/pitched_shallow/flat/composite_curved/corrugate_shed/dome), FacadeRhythm (bay_window/grid_panel/solid_punched/arcade/open_front/blind_wall), StreetSurface (cobble/packed_earth/poured_slab/elevated_boardwalk/dock_plank/rail_embedded — derives from district + density + template, the same filter as buildings, for a consistent world). Color is template-bounded (cultural palette cue), seed-selected within range — always within the template's register.
    • Fallback hierarchy (the incremental-content mechanism, D-232). Every specific texture/material token declares a generic parent it degrades to: temple_wall_wood → generic wood_wall placeholder until the specific asset ships, then it upgrades in place. The logical token a building uses is fixed at generation (deterministic, frozen); only its rendered fidelity sharpens as themes/textures are patched in. This is where incremental content lives — an asset-resolution concern, not a generation one — so no catalog versioning is needed. (era_fallback from the round-2 draft survives only in this generalized form — a fallback chain, not a tech ladder.)
    • Worked example: a fjord port (template fjord_maritime → stone base + steep roof + zero-lot + solid-punched + cobble) and a delta port (template river_delta → pile-timber + shallow roof + arcade + boardwalk) share density and zone types yet read as completely different cities.
    • Amended 2026-07-07 (T-995 — ObjectTag vocabulary ratified, resolves Q-049): the WallMaterial/RoofForm/FacadeRhythm/StreetSurface example token lists above never shipped. The canonical ObjectTag vocabulary is the shipped 28-template architecture_trait_catalog.toml (T-1005) material palette, now formalized as a machine-readable registry at wiki/economics/object_tag_vocabulary.toml, importer-validated by economy_import/traits.py (V-TT-03 existence, V-TT-04 fallback-graph). The ratified tags, by axis: wall (10) — concrete_wall stone_wall brick_wall rendered_wall stucco_wall timber_wall rammed_earth_wall steel_frame glass_curtain_wall composite_panel; roof (7) — flat_roof pitched_roof corrugated_roof clay_tile_roof terraced_roof vaulted_roof green_roof; facade (8) — regular_facade ornamental_facade industrial_glazing arcade_facade shuttered_facade screen_facade colonnade lattice_screen; street (7) — paved cobble packed_earth canal_way elevated_walkway heavy_haul boardwalk; plus the four fallback-terminal generic placeholders — generic_wall generic_roof generic_facade generic_street. Every specific tag's registry entry declares its generic fallback parent directly (the per-template fallback maps in the catalog remain illustrative/non-exhaustive documentation, not the validated source).
  • Formally retires Araminta's generator-architecture Round-4 D-READY-9 ten-root heritage-modifier TOML system — superseded by the D-232 trait-template catalog (body-specific draw, allow/block-bounded, no root taxonomy).
  • Rationale: The template is the differentiator, but it needs a concrete visual vocabulary to act on, filtered consistently across walls, roofs, facades, and streets or the world reads incoherent. The "old quarter vs new development" texture comes from wear + occasional past-vogue holdover (D-232), not from a material-technology ladder — because in a post-space-travel setting there is no such ladder. The fallback hierarchy lets the logical world be complete and frozen at launch while the art catches up over patches.
  • Implementation: Phase 4+ (the token logic + Atlas-level data); textured render + the bulk of the theme library are Phase 5+ and post-launch, behind the fallback chain. ObjectTag/material vocabulary is Miri + Araminta co-maintained.
  • Raised by: Araminta, economic-built-world workshop round 2, 2026-05-25.
  • Cross-reference: D-228 (composite tile / FloorMaterial — extended), D-232 (flavor — the filter), D-217 (condition / heritage markers), D-142 (zone types), D-220 (density → setback), D-106 (height tiers)
  • Dissent: None

D-237: Authored per-system specialisation layer — economic_specialization + cultural_specialization + dominant_faction

  • Date: 2026-05-31

  • Decision: Each inhabited star system carries three authored per-system identity fields, statically compiled from wiki/economics/system_specialization.toml at build time via import_economics.py. These fields constitute the lore-authored identity layer above the heuristic and are the primary source for D-233/D-232/D-214 generator inputs for named systems. All three fields are static (t=0), deterministic (D-010), and NULL-safe (NULL = heuristic fallback runs).

    (1) system_economy.economic_specialization TEXT (new column). A 27-value curated vocabulary compiled from wiki/economics/specialization_vocabulary.toml into a specialization_vocabulary DB table. Each value maps to (commodity_id, production_ubiquity_override_or_null)(BulkClass, ProductionUbiquity) for D-233. Scale is encoded in the value (no separate scale column): breadbasket pins ProductionUbiquity = Specialist where the catalog default would be Ubiquitous; terroir_agriculture pins MonopolySource. Equal-or-higher rule: a production_ubiquity_override may only be equal or higher concentration than the commodity's global default (CI hard error V-SES-03). Authored for ~80100 named systems; deterministic-varied heuristic fallback for ~200 unnamed systems (seed-hashed weighted draw over the vocabulary: W_ROLE_PRIMARY=8,000 bps, W_CORP=5,000 bps, W_NOISE=400 bps to every vocabulary value; same seed+system_id always produces the same value; the noise term ensures ~510% of unnamed systems draw an off-primary value, preventing all unnamed agricultural worlds from being identical).

    Relationship to economic_base_primary (the seam — clarified 2026-06-01): system_economy.economic_base_primary/_secondary is pre-existing free-text prose (e.g. "fusion_fuel, financial_services") rendered into the wiki index.md "Industries/Exports" infobox by wiki_sync.py. economic_specialization is a deliberate parallel representation at higher fidelity, NOT a duplicate: the prose is uncomputable GTTR flavor for human readers; the enum is the machine-actionable projection the D-233 generator consumes. They are the same concept (what the system produces) at two resolutions. The two must stay mutually consistent — economic_specialization should never contradict the economic_base_primary prose for the same system (the T-1016 content pass authors the enum from the same lore the prose describes, and CI soft-warning W-SES-03 flags prose/enum divergence). Unification onto one source was considered and rejected: the prose carries multi-sector nuance and narrative voice the enum cannot, and the enum carries the deterministic (BulkClass × ProductionUbiquity) projection the prose cannot — collapsing either direction loses information. systems.db is canonical; index.md infobox is a read-only DB projection; only the index.md prose sections (Supply Dependency / Faction Notes / …, round-tripped by wiki_sync PROSE_SECTIONS) are authored in-place.

    (2) system_economy.cultural_specialization TEXT (new column). Directs D-232's template pool when a system's cultural character diverges from corridor baseline. Single field carrying two value sub-types: activity/character values (agrarian, industrial_heritage, institutional, scholarly, artistic, financial_technocratic, cosmopolitan, compact_cooperative, etc.) and heritage-type values (scottish, vietnamese, zulu, afrikaans_cape, tagalog, chinese, italian_northern, french_provencal, norse_compact, etc.). Heritage values take precedence when both apply. NULL = use corridor default (D-232 existing algorithm). Authored for ~60100 systems where the corridor default would produce wrong D-232 architectural draws in Phase 4 (not a thin Phase 6 concern: a Vietnamese-founded system in the east_reach Korean/Japanese corridor draws the wrong templates without an explicit pin). Does NOT replace system_culture.cultural_register (prose NPC/dialogue voice); consumed by the physical generator only.

    (3) system_factions.dominant_faction TEXT — populated, not added. Column already exists (D-199 field 4); VALUES for named systems are authored in this pass via system_specialization.toml. 8-value vocabulary: concord_assembly | compact | compact_sympathetic | syndic_dominant | veil_institute | independent | disputed | mixed. lattice_commission is dropped — the Commission is a regulatory body under Assembly authority, not a governing faction; ACB and Bastion are concord_assembly. Authored for ~4060 named systems; existing Commission-presence + currency-zone derivation serves as fallback.

    Groombridge landmark mechanism: Where a system's authored identity includes a singular MonopolySource-class institution within a Specialist-scale district (Groombridge: financial_hub/Specialist + GSH clearing house), the district character is encoded as Specialist in the vocabulary. The singular landmark is expressed via D-222 multi-block reservation + D-232 atlas_body_trait_bias hero pin — not via a composite vocabulary value. The vocabulary expresses district-level character; landmark-level identity is the D-232/D-222 mechanism.

    CI guardrails: 6 hard errors (vocabulary FK integrity, inhabitance coverage, production_ubiquity monotonicity, vocabulary constraint for all three fields); 8 soft warnings (MonopolySource review list, distribution balance, prose/field divergence, D-172 faction/currency alignment, Compact monoeconomy flag, authoring gap lists). Full CI spec in docs/workshops/system-economic-specialization/workshop-outcomes.md §8.

    Source: wiki/economics/system_specialization.toml (per-system entries) + wiki/economics/specialization_vocabulary.toml (vocabulary definition). Both added to IMPORT_ECONOMICS_SOURCES for meta-stamp tracking. system_economy gains two new columns via COLUMN_MIGRATIONS in import_economics.py; specialization_vocabulary is a new read-only reference table.

  • Rationale: The pre-existing D-233 heuristic (economic_role + corp HQ + planet_class → dominant commodity) fails predictably for: (1) output-vs-input confusions (Cygni B: heuristic sees ore inputs, not freight_hauler outputs); (2) political mandates (Groombridge: financial infrastructure is an institutional choice, not geographic necessity); (3) terroir-locked production (D-177 systems: catalog defaults are globally ubiquitous, not locally monopolistic); (4) service economies (Prometheus, Keid: no heuristic path from available signals to the correct identity). The three-field authored layer resolves all four failure modes. Three fields are the minimum that changes how a system reads without redundancy: economic → D-233 block fill, cultural → D-232 template selection, faction → D-214 political archetype. cultural_specialization is a Phase 4 correctness requirement, not Phase 6 enrichment.

  • Raised by: System economic-specialization workshop, 2026-05-31. Burnelli-Sheldon (schema, vocabulary, CI, fallback algorithm), Miri (must-pin list, scale gap, D-177 terroir pins, cultural heritage vocabulary), Paula (faction vocabulary, Compact rules, heritage-divergence cultural scope, D-172 compliance), human checkpoint (dual-axis design, scale-in-vocabulary, faction authored, Groombridge landmark mechanism, deterministic-varied fallback).

  • Dissent: None.

  • Cross-reference: D-233 (re-amended — economic block-fill now sourced from this layer), D-184 (commodity catalog), D-199 (CityGenerationContext read-set — extended), D-232 (architecture-flavor template draw — consumes cultural_specialization), D-214 (PoliticalArchetype — consumes dominant_faction), D-172 (currency zone alignment — dominant_faction = compact validates), D-174 (shadow economy intensity — compact elevates), D-177 (productivity constraints — informs monopolistic pins), D-220 (density), D-197 (prosperity), D-222 (multi-block reservation — Groombridge landmark mechanism), D-178 (rolling economy — not touched; this is t=0 authored layer only)


D-239: Tile derivation contract — coarse→fine refinement chain (resolves Q-101)

  • Date: 2026-06-07

  • Resolves: Q-101

  • Decision: The walkable tile is materialised by a three-carrier refinement chain, each stage a pure deterministic function of (seed, atlas, body-params, position) per D-227: RegionProfile (~1 km) → ChunkContext (64 m) → VoxelColumn (1 m). Each scale boundary is its own derivation with its own failure modes.

    (1) Determinism — no authoring at the derivation layers, ever. L1L5 derivation has zero per-body override hooks. All authorial control lives upstream at the body-parameter / atlas / system-specialization layer; all gating params (RIVER_THRESHOLD — a derived per-body-class value (hydrosphere, tectonic_activity, precipitation_class) → threshold, not the global 200; tectonic_class; GlaciationGrade; precipitation_class) are derived from body params (stellar type / orbit / hydrosphere / lithology / temperature history), never authored. Lore-anchored bodies (Kallast = plains, Velen = coast, Cygni B = volcanic) are honoured by setting params and stand as validation cases — if a body reads wrong, fix its params, never patch the derivation. Validation caveat: derived terrain can only honour a lore body if its params permit it (e.g. tidal flats require a moon param for the D-228 tidal term — verify Velen's params before treating its coast as a contract).

    (2) Climate — district temperature is the primitive; moisture is a separate primitive. A simple scalar district temperature in °C, resolved per 2×2 km district, nullable, derived from: the sun (luminosity + insolation), the planet (the district's latitude + elevation lapse rate + orbital/axial phase = the season term, and day-phase = the diurnal term), and the atmosphere (greenhouse → the base, and heat-retention → the diurnal-swing amplitude; thick air = small day/night swing, thin = large). No atmosphere → temperature is null, and the entire climate/vegetation/weather branch is simply absent (an airless body's surface ice is geology per D-227, not climate). Tiles inherit their district's temperature. Moisture is the second primitive (water availability, from hydrosphere). Everything climatic derives from temperature (+ moisture): precipitation = f(temp, moisture); vegetation/treeline = temperature bands × elevation; long-term / seasonal-minimum temperature → GlaciationGrade; the cheap region seasonal/clock state (Q-105) is literally temperature(time). This formalises D-210's temperature proxy into the keystone scalar. No authored climate inputs anywhere.

    (3) Freeze & snow — a scattered phase transition, transient in the marginal band. Ice/snow is not a hard temp < 0 contour but a band with deterministic, spatially-coherent scatter (seed-noise → a ragged, natural freeze line + microclimate; clustered patches, never per-tile dice), branched by surface type:

    • Fresh / "sweet" water (lakes, rivers): scatter band +5 °C → 10 °C (district mean) — the upper +5 is night-frost (the diurnal swing) reaching the coldest / most-exposed tiles even when the mean is above zero; 10 = frozen across the whole day/night cycle.
    • Salt water (oceans): its own lower, wider band (seawater onset ≈ 2 °C) with a distinct pack-ice spatial pattern (sheets + leads, not a lake skinning over).
    • Snow (land): the land parallel, gated on moisture (cold + wet → snow accumulates; cold + dry → bare frozen ground), same scattered band.
    • Transient: because temperature is clock-bound (season + day/night), in the marginal band ice/snow forms in the cold phase and melts in the warm phase — dawn frost burns off, a stream iced at dawn is crossable by noon. Passability is therefore time-of-day dynamic. (Forward contract to Q-105.)

    (4) Anti-"squaring" (warp). A global position-keyed domain-warp field suppresses grid/seam artifacts — a stateless, hash-based pure function of (seed, body_id, position) (no lookup table / thread-local cache, so order-independent across threads/platforms). It keeps f64 sub-metre precision through to the final voxel coordinate, then quantises by truncation to the integer voxel address (a cast, not a comparison → IEEE-754-deterministic across targets). The ±8 m warp range makes FMA-contraction ULP variance (~1e-15 m) unable to shift the rounded voxel, so no platform guards are needed. The warp is position math, not a structural decision — D-010 integer discipline on all material/morphology decisions is preserved.

    (5) Morphology — 8 families via a strict decision tree over integer RegionProfile inputs: LavaField · FjordWall · CliffCoast · BraidedDelta · DuneStrand · IncisedGorge · MeanderReach · AlluvialPlain (fallback). Adds tectonic_class to RegionProfile. MountainPass is a zone label sharing IncisedGorge geometry. Hard gates (boolean, pre-selection, in tree order): fjord requires GlaciationGrade ≥ 2; LavaField requires tectonic_class = Volcanic; lithology bounds slope/form (see laws).

    (6) Frozen 17-zone MorphologyZone display vocabulary (derived labels per D-228, enum→label map frozen). The canonical freeze point is the Rust MorphologyZone enum; adding/renaming/removing a zone requires a D-record amendment (reviewer-enforced; classifier tuning that merely re-classifies a region is not a vocabulary change and stays free). The 17: open ocean, lake, tidal flat, dune strand, cliff coast, fjord, delta, estuarine, alluvial plain, river bank, meander reach, braided plain, valley floor, mountain pass, alpine, volcanic, wetland. Zones ≠ families: four (tidal flat, estuarine, alpine, wetland) are derived sub-classifications from family + elevation/water-height, not distinct generator families.

    (7) Zone seams — prevent incompatible, allow valid (never patch). The boolean gates are discontinuities, so "continuous inputs → continuous classification" is not the mechanism. The mechanism is decision-tree gate ordering + a build-time compatibility-matrix invariant: incompatible family pairs (e.g. MeanderReach↔Volcanic) cannot be adjacent classifier outputs (a build-time test, never a runtime override). Valid geomorphic seams (cliff↔fjord at the glaciation threshold, lithology faults) are permitted sharp transitions — kept sharp (real geology is sharp), made non-grid/organic by the warp; no modulation feathering. The warp also prevents degenerate slivers at chunk scale.

    (8) Believability laws (binding): drainage monotonicity (respect the D8 thalweg, D-208; tributaries join upstream; mouths at sea level); lithology→landform (Rock → vertical faces; Sand → ≤~32° angle of repose, dunes not cliffs; Gravel → braided channels/fans not single-thread meander; Soil → rolling/floodplain; Wetland → ≤5° flats; Lava → sheets/shield slopes + tubes, immature drainage); glaciation→form (fjord ≥2, U-valleys ≥1, moraines ≥1, cirques ≥2; grade 0 = V-ridges, never glacial U); climate→vegetation (treeline Forest→Scrub→Barren, no skip; riparian Thicket/Scrub 13 tiles along perennial waterways) — all flowing from the temperature primitive.

    (9) Game-feel constraints: every chunk carries ≥1 tactical decision point; cover ≠ concealment; high-ground asymmetric-but-not-dominant; chokepoints narrow enough to matter (river crossings 315 m, gorge floors 28 m); seasonal/tidal state produces real passability changes, not cosmetic — BraidedDelta/MeanderReach ElevationDelta calibrated so channels fall below and levees above the Q-105 high-water threshold.

    (10) Mechanics: no per-tile flow_direction[64×64] in ChunkContext (D8 ≈ 152 m/cell, coarser than a chunk) → one basin-direction + global meander-curve params; features with wavelength > 64 m seed from Region-or-higher, not the chunk seed. RegionProfile is stored in BodyWorldState (D-203, ~6k/body, populated in the D-206 background pass) so the Atlas reads zone labels without triggering voxel derivation; voxel derivation is on-demand + cached, never persisted (D-227). Budget ~2.24.2 ms/chunk (validate per-family in Phase 4 — FjordWall/IncisedGorge are far costlier than the AlluvialPlain fallback).

  • Rationale: D-227/D-228 fixed what a tile is (derive-don't-store voxels; orthogonal derived axes); D-239 fixes how the finished upper cascade (L0L4) becomes actual tiles — the one unbuilt layer. The district-temperature primitive collapses the scattered climate inputs (precipitation, glaciation, season, snow/ice, vegetation) onto one derived scalar + moisture, keeping the whole climate branch deterministic and null-cutting airless bodies cleanly. Prevent-at-source seams + the warp give coherent, organic terrain without a runtime patch. The frozen vocabulary protects authored Atlas/wiki content while letting the classifier evolve.

  • Raised by: tile-derivation-contract workshop (Tyre — refinement chain, warp, determinism; Gestalt — 8 families, game-feel; Troblum — feasibility, warp precision, scale corrections; Miri — believability laws, vocabulary, lore reconciliation), lead-interviewed decisions + an adversarial verification pass, 2026-06-07.

  • Dissent: Tyre's initial cross-family elevation-blend was resolved against (prevent-at-source). Early-integer-truncation of the warp (raised against Gestalt's ElevationDelta ranges and by Tyre) was resolved against in favour of f64-to-voxel.

  • Implementation note (T-1024, 2026-06-07): §1 per-body RIVER_THRESHOLD is now a derived field (derive_river_threshold) on RegionProfile. §2 district temperature is a nullable f32 on RegionProfile sampled from a per-region climate derivation (derive_temperature_c); moisture is a separate integer primitive (derive_moisture_q, 0100). Both populate the RegionProfile carrier during the L4 cascade run. Climate inputs use a hybrid strategy: stellar luminosity and spectral class are sourced from the import pipeline (bodies.axial_tilt_deg, star_systems.spectral_class); greenhouse offsets and diurnal amplitudes are tunable at runtime via server/data/climate_constants.toml (source-canonical TOML, not hardcoded).

  • Implementation note (T-1025/T-1027, 2026-06-08): §6's frozen 17-zone MorphologyZone enum is realised in server/src/simulation/generator.rs (repr(u8), discriminant-pinned). The §5 8-family gated classifier + §7 compatibility-matrix invariants live in derive_morphology_zone (region_profile.rs). 16 of the 17 zones are reachable at RegionProfile scale; BraidedPlain is the exception — distinguishing it from Delta needs a lithology signal (§8 Gravel→braided) that RegionProfile does not carry, so BraidedPlain is deferred to ChunkContext sub-classification. The §7 compatibility invariant is enforced as a classifier-gate-ordering property (a build-time test that a single region cannot yield a forbidden pair), per §7's "build-time test" language; genuine cross-region sharp seams (cliff↔fjord, lithology faults) remain permitted. §2 climate-derived fields (precipitation_class, glaciation_grade, vegetation_class) all derive from the temperature(+moisture) primitive (T-1025).

  • Amendment (T-1082, 2026-06-28): §5's family set grows from 8 to 9 — a WaterBody generator family is added for the water zones OpenOcean / Lake / TidalFlat, which previously fell through to the dry-land AlluvialPlain fallback (the D-245 believability bug: oceans rendered as dry forested land — water=Dry + Forest on the sea). WaterBody lays Water::Deep/Shallow (a Shallow shoal band on the district-anchored coast line coast_anchor_m, Deep beyond; TidalFlat is all-Shallow), a seabed TerrainMaterial (Rock on steep districts / Sand on gentle / Wetland for tidal mud), Vegetation::Barren, the water surface at elevation_m = 0 (the §8 mouths-at-sea-level convention), and seasonal Ice via derive_cover (frozen seas). This refines §6: open ocean / lake / tidal flat are still derived zone labels, but their generator is now WaterBody, not AlluvialPlain — so §6's "tidal flat … not [a] distinct generator famil[y]" is superseded for the dispatch of those three zones. Wetland stays a land zone on AlluvialPlain (a marsh — saturated ground, not open water). Lives in server/src/atlas/voxel.rs (MorphologyFamily::WaterBody + generate_water_body, dispatched by zone_to_family); verified by the T-1083 believability harness (water-renders-wet flips PASS for Arbour + Edict). The §5 8-family decision-tree classifier (derive_morphology_zone) is unchanged — this is a voxel-tier generator family, selected by zone, not a new RegionProfile classifier branch.

  • Amendment (T-1080, 2026-06-28): §2's moisture primitive is now a body ceiling × per-district spatial gradient, not a single body constant. The hydrosphere+atmosphere value (derive_moisture_q) is the wettest a district on the body can be; per-district latitude (equator wet → pole dry), elevation (orographic / rain-shadow), and continentality (100 ocean_fraction_q, coast wet → interior dry) subtract from it. Integer (D-010); coefficients in ClimateConstants / climate_constants.toml [moisture_gradient] (provisional, Q-123 calibrates). This mirrors D-240's latitude-graded temperature model and fixes the T-1080 believability bug (moisture_q = 80 for all 2 048 districts → uniform vegetation/terrain — "nothing to fuzz"). It propagates: precipitation_class, vegetation_class, and the morphology_zone Wetland gate all read moisture_q, so those diversify for free. Lives in district_profile.rs::derive_moisture_q; verified by the T-1083 believability harness (moisture-gradient flips PASS, distinct 1 → ~49 on Arbour). The body-scale ceiling keeps each world's character; the gradient varies it within.

  • Amendment (T-1126, 2026-07-17): §8's climate→vegetation law predates a water class — derive_vegetation was ocean-blind, so district-resolution maps painted Forest across open ocean (T-1123 finding; the voxel tier masked it in-world via the T-1082 WaterBody generator, but the district carrier itself disagreed with its own morphology). VegetationClass gains Marine, appended at discriminant 6 (after RiparianThicket = 5; the load-bearing discriminant-pin test is extended). The verdict is morphology-derived: build_district_profile derives morphology_zone first and passes open_water = (OpenOcean | Lake) into the vegetation call — vegetation and morphology can never disagree by construction, and no fourth ocean threshold exists. Precedence: airless (temperature_c == None) still wins — an airless body has no climate/vegetation branch at all (§2), so its seas read Absent, not Marine. The Ord position of Marine is non-semantic: the §8 Forest→Scrub→Barren density-ladder reading does not extend to it (discriminants are append-only serialisation order). near_perennial_water (the riparian river-proximity signal) is unrelated and unchanged.

  • Amendment (T-1081, 2026-06-28): the voxel tier now carries mid-scale relief (D-243 §2's invented terrain). Before, the family generators set elevation_m from elev_q at a compressed scale plus only ±4 m per-voxel micro-scatter — the walkable surface read flat (the D-245 "03 m, no hills to navigate by" bug). A voxel_relief pass (detail_scatter.rs, the terrain_detail fBm at a 0.131 km sub-district octave band — all finer than the 2 km district, so it never competes with elev_q's district-scale role — rather than the district 440 km band; body-global SeedDomain::VoxelRelief seed, position-keyed; the f64 perturbation truncated to integer metres before assignment — D-010) is added post-dispatch in derive_voxel_column. Two load-bearing choices: (a) the relief envelope is slope_q·3 + elev_q, not slope alone — the coarse heightmap (~4078 km/px) yields slope_q ≈ 0 even on high ground (observed max 13 on Arbour), so gating on slope would invent nothing; folding elevation in makes high terrain read rugged and coastal flats stay gentle. (b) Only the flat families (AlluvialPlain / LavaField / BraidedDelta / DuneStrand / MeanderReach) take it — CliffCoast / FjordWall / IncisedGorge already generate strong internal relief and a position-varying field would warp those features (e.g. drown a gorge's wall-to-floor drop); WaterBody stays at sea level. The relief span (VOXEL_RELIEF_SPAN_M = 100) and the believability voxel-relief threshold (≥ 8 m) are provisional (Q-123 calibrates, like the moisture coefficients). The span is held modest deliberately: relief sits on the compressed elev_q/N base (max ~50 m) and elevation_m clamps at 0 (sea level), so an oversized span clamps away on low ground (drowning the relief, biasing it positive) — a bigger span belongs with the deferred absolute-elevation model. Verified by the T-1083 harness — a new voxel_relief contrast metric (mean within-district elevation range across a district-spanning transect, since a single 64 m sample chunk is narrower than the relief band) flips PASS (≈22 m on Arbour + Edict). Deferred: the absolute elevation span is still the compressed elev_q/N base — a per-body hypsometric relief model (so a body's true max relief sets the ceiling AND gives the relief headroom to grow without clamping) is a later refinement; the mid-scale relief gives navigable hills now without it.

  • Cross-reference: D-227 (derive-don't-store voxel model), D-228 (composite tile axes / cohesion / seasonal state), D-210 (temperature proxy — formalised), D-203 (BodyWorldState cache), D-206 (background analysis pass), D-208 (drainage / D8), D-010 (determinism), D-234 (street/footprint geometry — consumes morphology), D-142 (zone types), D-217 (tile condition), Q-102 (cohesion = the warp), Q-103 (mutator schema — open), Q-105 (seasonal/clock state — temperature/ElevationDelta forward contract)


D-240: Registry orbit/star data is non-canonical — climate derives from a planet_class temperature envelope

  • Decision: A body's registry orbital position and stellar assignment (orbital_period_days, spectral_class, star_type, and any derived distance_au/luminosity) are non-canonical placeholder data — bodies were dropped into orbit and stars taken as they sat in the registry, with no authored physical intent. No derivation layer may consume them as physical inputs. Climate derives only from the authored, intent-bearing fields: planet_class (sets the temperature envelope), atmosphere, hydrosphere, plus the intrinsic latitude/elevation of the generated surface — with per-world variety from the deterministic body seed (D-010). Temperature is a planet_class → (min, max) °C envelope (source-canonical in server/data/climate_constants.toml, tunable): latitude maps the region across the envelope (pole → cold end, equator → warm end); atmosphere / hydrosphere / elevation modulate within the bounds; the seed adds a small bounded nudge for inter-world character. A body can never derive outside its class band by construction. This supersedes the sun-driven (Stefan-Boltzmann luminosity + distance) portion of D-239 §2.
  • Rationale: Deriving physics from arbitrary registry values dresses placeholder data as authoritative — worse than an obvious error, because it looks real. The sun-driven path produced physically impossible results (a temperate/liquid-water world deriving to +356 °C, because a NULL spectral_class defaulted to a G-type Sun on an M-dwarf-dominated Gliese catalog — a 25× luminosity error). The 2026-06-08 audit found the blast radius is exactly one function (derive_temperature_c); every other reference merely plumbs the columns, so once temperature is re-based the orbit/star columns can be dropped from the derivation inputs entirely. Anchoring to planet_class — which already encodes the authored intent (frozen, tropical, hot_arid, …) — keeps every world inside its band and makes the T-1031 believability harness's class-consistency check true by construction.
  • Raised by: Jeroen (2026-06-08), reviewing T-1024 climate output across 25 non-Kallast bodies.
  • Cross-reference: D-239 §2 (temperature primitive — the sun-driven portion is superseded here), D-010 (determinism — seed-based variety), D-227 (derive-don't-store), D-228 (tile axes).

D-242: Corp HQ settlement model — HQs are not cities; separate from the name pool, specialization-keyed, install-baked

  • Date: 2026-06-14
  • Decision: A corporate headquarters is not a city and does not belong in the atlas_city_names flavored name pool.
    • Remove the corp-HQ cross-reference into the city pool. The D-207/D-223 reserved=1 step (populate_atlas_city_names_corps, tooling/economy-db/economy_import/atlas.py) inserts one atlas_city_names row per corp HQ whose name isn't already pooled. With no UNIQUE(body_id, name) it produces duplicate co-named "cities" — e.g. 10 Groombridge rows on GJ380c, one per corp — which every downstream spatial layer (placement, roads, hinterland) then renders as separate settlements scattered across the map. This insert path is removed; the HQ relationship lives with the corp (corporations.headquarters_system, plus a resolved headquarters_body), not in the city pool. This supersedes D-223's "the reserved=1 corp-HQ cross-reference stays" note.
    • HQ placement is a baked, specialization-keyed preference. Each corp's HQ is CityTenant or Standalone, chosen by a source-canonical specialization → HQ-placement map (corp_type is degenerate — only corporation/combine, 158/7 — so the key is corporations.specialization, optionally supply_chain_role):
      • CityTenant (finance, media, retail, services …): the HQ is a tenant of an existing city — a corp→city link, no new settlement. Many corps may tenant one city (supplemental, non-competing HQs realistically cluster — a bank and a newspaper share a downtown).
      • Standalone (extraction/mining, heavy industry, shipyards …): the HQ is its own settlement, emitted into the cascade settlement list with its own body + position, so the road graph and downstream layers treat it as a place.
      • The preference depends only on corp specialization — not on seed or install — so it is authored once and baked into systems.db at import (a difference of corp type, not of run).
    • population and settlement_class are install-baked, not seed-derived. Body-level population is already baked (273/3 240 bodies > 0); the per-settlement spread is carried down into the city/settlement source data, so every settlement starts with a fixed population + settlement_class. Only position remains seed-derived (the D-211 placement pipeline). This supersedes D-223's "population/settlement_class deferred to placement (T-955)" and retires the never-built "assign population from the seed" stepmatch_cities only ever read those fields (all 0/NULL) and nothing assigned them, so faking population in the seed layer buys nothing over baking it.
    • The cascade settlement input = the city name pool (cities, baked pop/class) + standalone-HQ settlements, merged. The Layer-2 road graph (D-211, T-1038) routes over that merged list unchanged; "hubs" are the significant cities, standalone HQs are typically minor nodes.
  • Rationale: Modelling HQs as city rows conflated two different things and made corp-dense worlds nonsensical — ten "Groombridge" dots, ten trunk roads, where there is one city hosting ten tenants. HQs are sometimes their own settlement (a mine or shipyard sits alone) and sometimes a tenant (a bank wants a downtown); that split is intrinsic to the corp's specialization and never varies by run, so it is authored, not derived. Baking population/settlement_class (rather than deriving them from the seed) keeps generation deterministic with only position variable, matches the already-baked body population, and deletes an unbuilt seed step instead of building it. The names pool (D-223) returns to being cities only.
  • Raised by: Jeroen, 2026-06-14 — reviewing the T-1038 road-graph hub model; the corp-HQ-as-city rows surfaced as duplicate map settlements.
  • Implementation: corp-HQ model + specialization map + standalone-HQ settlements, remove the cross-ref insert, resolve headquarters_body (T-1074); per-settlement population/settlement_class baking (T-1075); and the gated road-graph hub refinement — scaled-cap hubs + co-location collapse + hybrid minor-settlement attach + the deferred RailHeadFacing junction pass (which itself needs a D-213 amendment) — (T-1076, blocked by T-1074 + T-1075). All under Phase 4 (epic T-750). The T-1038 road-graph core (MST + A* + MaintenanceAuthority + waypoints + junction detection) ships independently of this record — it routes over whatever settlement list it is handed.
  • Implementation status (T-1074/T-1075, 2026-07-16): done. Both stories landed together, in order.
    • corp_type was already known degenerate (158 corporation / 7 combine) — confirmed the key is corporations.specialization, not corp_type. That free-text specialization column is legacy prose (only 20/165 rows ever populated, by the orphaned tooling/populate-corporations.shretired/deleted, T-1074 point 6) and is left alone (generate_corporations's fuzzy brand-relevance match still reads it). The HQ-placement key is a new, separate column: corporations.corp_specialization.
    • Vocabulary choice — reused AND extended, per this record's own instruction to prefer reuse. corp_specialization reuses the D-237 specialization_vocabulary id-space directly rather than a parallel corp-only taxonomy — every value already carries bulk_class_projected (NonPhysical vs everything else), which is the CityTenant/Standalone signal this record calls for. Backfilling all 165 corps (155 with a wiki page; the categorization pass read every one) found the 27 system-authored values genuinely under-cover corp-shaped identities: ~10% of corps do trade/logistics distribution, hospitality/tourism, general professional/advisory, or licensed multi-site clinical work — moving or selling what someone else made, or delivering certified services, not producing goods — which none of the 27 values name. Four values were added to specialization_vocabulary.toml (all NonPhysicalCityTenant): trade_distribution, hospitality_hub, professional_services, and licensed_clinical_services (the fourth via PR #177 review M1 — Somatic Futures is a 40+-clinic licensed network, one of several certified operators: neither generic consulting nor longevity_monopoly, which is Prometheus, THE sole facility). The vocabulary is now 31 values, shared by two authored axes: system_economy.economic_specialization (D-237, system-scale) and corporations.corp_specialization (this record, corp-scale) — nothing stops a future system from adopting the 4 new values too. Per-corp authoring lives in wiki frontmatter (wiki/corporations/*.md, key corp_specialization) — the natural per-corp source, parsed by _parse_corp_frontmatter/load_wiki_corps (extended) — not a per-corp TOML stanza file (unlike D-237's per-system TOML, 165 corps would make one file unwieldy; one wiki page per corp already exists and is the authoritative source for everything else about it). The corp pages are part of the import_economics meta-stamp source set (PR #177 T1): a frontmatter edit without a regen trips the stamp check like any TOML edit.
    • The placement map is authored explicitly, not computed from bulk_class_projected at import timewiki/economics/corp_hq_placement.toml, one hq_placement stanza per vocabulary value (all 31), defaulting to the NonPhysical rule but reviewable/overridable per value without a code change. Standalone stanzas also carry standalone_economic_role, mapping the 31-value specialization onto the pre-existing 10-value economic_role vocabulary (D-195/D-197) the emitted settlement row needs (match_cities/CompatibilityMatrix index by the 10-value set, not the 31-value one).
    • headquarters_body is recomputed from source on every run — never kept from prior DB state (PR #177 H1/H2). The importer-owned corporations columns (corp_specialization, hq_placement, headquarters_body) are NULL-reset and re-derived each run: corporations is append-only, so a keep-if-set guard would freeze the first run's output forever and silently promote DB state to source (exactly the asset-pipeline golden-rule inversion — the review caught the original one-shot backfill doing precisely that). An authored headquarters_body: frontmatter override wins where present (hard-validated: body must exist and sit in the corp's HQ system; none authored yet); otherwise most_populated_body_in_system (atlas.py, descended from the retired cross-reference function's heuristic) derives it with a four-stage deterministic tiebreak: population DESC → city-bearing body (has atlas_city_names rows) → body-type rank (planet < moon < gas_giant < belt/oort) → body_id. The city-presence tier is what the review's H2 added — the first draft ranked body-type directly and rested on a false premise ("belts can't host settlements"): belts in this world do host them (GJ845-belt: Orkney Ceramics; GJ268-belt: Jeju Lattice; GJ222A-belt: two Standalone HQs), so presence keeps settlements where they are, and the type rank only fires in fully cityless, fully unpopulated systems (GJ 702B — 12 bodies, zero population, zero city rows → planet GJ702Bb instead of the belt the old alphabetical sort picked). The presence set is read at step 7b, before the run's pool rebuild — i.e. the previous run's settled state, a deliberate, documented hysteresis (settlement continuity across regens) whose fixpoint is stable: each run's output reproduces the presence distribution it read.
    • CityTenant is a new schema link, not a repurposed one. corporations.headquarters_city_id (nullable FK → atlas_city_names.id) is the "corp→city link" this record calls for — the OLD atlas_city_names.corp_id column is left in place (additive-safe, no DROP COLUMN) but is now permanently NULL: nothing writes it once the cross-reference insert is gone, and nothing ever read it downstream (confirmed: zero references in city_context_reader.rs/attractor_matching.rs — the Rust consumption side never depended on it).
    • The merge is free. Standalone-HQ settlements are inserted as ordinary atlas_city_names rows (populate_standalone_hq_settlements, Phase B) — same schema, no corp-linkage marker — so read_body_settlementsmatch_cities (the "natural seam") needed zero Rust changes; a Standalone HQ competes for attractors purely on population/settlement_class, exactly like any pooled city (verified live: cascade_for_body("GJ251c") places "The Gate Corporation" alongside "Tributarium"/"Ruhr" through the unmodified pipeline). Two new Rust unit tests lock this invariant in (city_context_reader.rs, attractor_matching.rs).
    • Verified against real, repeated make regen-db runs — each run a full recompute, not a no-op (wording corrected per PR #177 H3; the pre-review text described the one-shot backfill being stably inert): 0 duplicate (body_id, name) groups (was the whole bug); 155/155 wiki corps get corp_specialization/hq_placement; 154/155 headquarters_body re-derived on every run (the 1 gap is a pre-existing broken headquarters system reference on sova-station-works.md, unrelated to this record), with two consecutive runs producing zero differences across all 165 corps' placement columns and tenancy targets; against the prior committed snapshot the recompute changes exactly 2 placementsprometheus-labs/kovalev-freight, GJ702B-beltGJ702Bb (the intended H2 outcome) — and zero tenancy targets (calluna-wellness → (GJ845-belt, Orkney Ceramics) and namsan-collective → (GJ268-belt, Jeju Lattice) preserved via the city-presence tier). 95 Standalone corps → 94 settlements emitted (same 1 sova gap) + 60 CityTenant corps → 58 links (2 unmatched: the GJ 702B pair — now correctly on planet GJ702Bb, which still has zero authored city names, like every body in that system: a wiki content gap [T-1115], logged, not an import error). A poisoned-copy test confirmed no DB state survives the reset: hand-written garbage in all three columns is wiped and re-derived (wiki corp) or reverted to NULL (legacy corp with no authored source — the removed-key case). The 10 legacy DB-only corps seeded by the now-deleted populate-corporations.sh (mvg, adams-ford, dsmc, and the 7 Cygni-B "combines") have no wiki page to author corp_specialization from and are reset to NULL across the board each run — a logged, non-fatal gap (they are load-bearing for 2,468 brand_products rows via generate_brands, which reads corporations directly and doesn't care about wiki-page existence, so their rows are kept, not deleted).
  • Amendment (T-1075, 2026-07-16 — population/class derivation clarified): the phrase above, "the per-settlement spread is carried down into the city/settlement source data," is superseded by derivation-at-import, not hand-authoring: import_economics computes the per-city population spread from the authored bodies.population on every regen-db run, via a documented integer rank-size (Zipf, exponent 1) curve (atlas.py::_zipf_population_spread, constants named + commented — tuning the curve is a deliberate code change, not a data edit). There is no per-city population source file — only bodies.population (already-baked) is authored; the per-settlement split is 100% derived. The spread runs over the corrected, merged pool (post-T-1074: Standalone-HQ settlements are ordinary rows by the time this runs, so they get a real share of their body's population like any city, not a bolted-on afterthought). settlement_class defaults every pooled row to PopulationBudget (D-196); a small authored override TOML (wiki/economics/settlement_name_locked.toml, [[hero]] stanzas keyed on (body_id, name) — not the volatile numeric id) pins 8 hero settlements to NameLocked. EconomicTriggered and OrganicGrowth stay simulation-time (D-196) — out of bake scope; so is the D-196 generation-time consumer that reads the baked population against the >=50k-active/<5k-ghost thresholds to decide skeleton depth (confirmed live: no such consumer exists yet anywhere in server/src/atlas — this record's bake only writes the values a future ticket will read).
  • Amendment (T-1076 item 0, 2026-07-16 — the Standalone/CityTenant boundary made explicit; Jeroen's ruling): the original examples ("extraction/mining, heavy industry, shipyards …" → Standalone) were operationalised at T-1074 as bulk_class_projected ≠ NonPhysical → Standalone — an over-generalisation that made every physical-goods specialization a standalone settlement (95/60 split) and produced a winery and a leatherworks as standalone asteroid-belt settlements. The boundary is now explicit, so it cannot be re-coarsened: Standalone applies ONLY where the HQ is itself an industrial complex / company town — (1) extraction/mining/quarrying, (2) heavy vehicle + shipyard manufacturing, (3) heavy energy (refinery/extraction complexes), (4) gate/orbital infrastructure fabrication. Everything else is CityTenant, explicitly including craft/consumer manufacturing (distilleries, breweries, wineries, ceramics, textiles, furniture), agriculture of every scale (the estate/agribusiness office sits in a market town; the fields are not the HQ), and high-tech fabs. Applied per-value in corp_hq_placement.toml (one-line justification on every flipped stanza): 9 values flipped Standalone→CityTenant (estate_farming, breadbasket, terroir_agriculture, terroir_spirits, terroir_organics, general_industrial, consumer_goods_bazaar, precision_tech, marine_farming); 11 stay Standalone (ore_extraction, company_mining, marble_monopoly, rare_mineral_extraction, lattice_material_source, fuel_production, geothermal_hub, shipbuilding, vehicle_production, gate_fabrication, military_industrial). Corp split moves 95/60 → 11 Standalone / 144 CityTenant; atlas_city_names 423 → 340 rows (329 pool + 11 HQ settlements); tenant links 58 → 129 (15 unmatched: 14 on bodies with no city to tenant + sova-station-works, whose broken HQ-system ref predates this record). The vanished belt settlements (Orkney Ceramics, Jeju Lattice, Groot Karoo Cellars, Kalahari Leatherworks) took their two belt tenancies with them — expected, correct data change under the boundary; the H2 placement hysteresis converged in one transitional run (fixpoint verified: two subsequent regens byte-identical across all 165 corps, 0 duplicate (body_id, name) groups, 0 import errors). Cygni B's NameLocked hero pin moved from the retired "Cygni Combines" HQ settlement to its pure-pool city "Metropolis". Jeroen's rendering note, recorded here so it is not re-litigated as placement: CityTenant HQs often sit on a settlement's edge or in industrial clusters — that is CityTenant rendering/district-placement color for later phases (building placement inside the settlement), not a third placement class.
  • Amendment (PR #178 M1 + T1, 2026-07-16 — five steel-complex corps re-tagged; hub-cap scale note): Miri's content pass verified 19/20 of the item-0 boundary judgments but found five already-authored corps that ARE the steel-complex identity the general_industrial flip comment reserved for its own value — re-tagged in wiki frontmatter (one-line justification on each page): cygni-combinesshipbuilding (the Cygni B yards' hull-structure consortium — the D-237 hero identity, fits directly); sova-station-works, stalownia-kowalski, westphalia-heavy-worksmilitary_industrial (habitat/orbital-module and heavy-equipment works — the heavy_equipment-anchored yard/complex value; note: it now carries civilian heavy works, not only military-administered industry — if that reads wrong later, the clean split is a new heavy_works vocabulary value, not re-coarsening general_industrial); sede-chemical-worksfuel_production (feedstock refining/chemical synthesis — the refinery-complex shape; the closest call, per Miri). shipbuilding and military_industrial previously carried zero corps, so this also closes a content hole. Post-M1 counts: 16 Standalone / 139 CityTenant, 15 HQ settlements emitted (sova-station-works emits nothing — its broken HQ-system ref is T-1054), atlas_city_names 344 rows (329 pool + 15), tenant links 125 (14 unmatched); fixpoint immediate — two consecutive regens identical (every re-tagged corp's HQ body already carried pool cities, so the presence tier saw no transition). The Cygni Combines settlement returns on GJ820Bc; the Cygni B hero pin deliberately stays on "Metropolis" (Miri: the pool city is the canon-clean hero anchor — the combines' yard is a place beside the city, not the city itself). Scale note (T1): the road-graph hub cap (HUB_SPACING_DIAG_PX, T-1076 §1) keys off the working-grid diagonal, a fixed 512×256 for every planet today — revisit that premise when D-243's elastic planetary seam gives bodies genuinely varying grid sizes.
  • Cross-reference: D-207 (corp-HQ cross-ref — the insert path is removed here), D-223 (names-only pool — the corp-HQ-stays + pop/class-deferred notes are superseded here), D-211 (placement — consumes the merged list; only position is seed-derived), D-213 (FoundingOrientation — a RailHeadFacing variant is the gated follow-on), D-237 (corp specialization vocabulary — the HQ-placement key, extended here from 27 to 31 values), D-195/D-197 (the 10-value economic_role vocabulary standalone_economic_role maps onto), D-196 (SettlementClass — the bake target), D-010 (determinism — only position is seed-derived).
  • Dissent: None

D-243: Spatial scale ladder — nested absolute containment with a single elastic planetary seam (resolves Q-110)

  • Date: 2026-06-14

  • Resolves: Q-110.

  • Decision: The world's spatial structure is a fully nested, absolute-metre containment ladder with exactly one elastic seam — the jump to planetary scale. Below the seam every level is a fixed integer multiple of the level below, in real metres, identical on every body; only the planetary seam floats per body. This resolves Q-110's three-way contradiction (region documented as ~1 km but implemented as 8 heightmap cells ≈ 624 km, with chunk_context assuming a third value) by fixing one ladder and one anchoring rule.

    The ladder (side length; the 2-D child count is the square of the linear ratio):

    Level Side Linear ratio Role
    voxel 1 m the tile (D-228/D-220)
    chunk 64 m ×64 voxels stream / derive unit (D-222, D-239)
    block 128 m ×2 chunks generator planning unit (D-222)
    quarter 512 m ×4 blocks settlement footprint cell (D-222)
    district 2,048 m ×4 quarters urban division and local-climate cell (D-222, D-239 §2)
    region 204.8 km (~205 km) ×100 districts top hard block — climate/weather/season lockdown + planetary grid
    (elastic seam)
    planet per body round(2πR / 204.8 km) regions the only floating quantity

    (1) The elastic seam — region ↔ planet. A body holds round(2πR / 204.8 km) regions around the equator (and half that pole-to-pole), R = body_radius_km (D-204) — the single per-body quantity. The fixed 1024×512 heightmap (D-202) is the coarse elevation field, finer than the region grid (≈ 5 heightmap pixels per region on an Earth-sized body) and consumed per district, not per region. Everything below the region is fixed integer math; ChunkPos → … → RegionPos is body-independent.

    (2) Detail-scatter synthesis — the heightmap is the data ceiling. The heightmap carries continental shape only (one elevation sample per ~4078 km). All sub-heightmap detail (region → voxel) is invented deterministically, not stored (D-227): the heightmap is interpolated and a simple, spatially-coherent detail-scatter field (a few octaves of seed+position-keyed value/fractal noise, shaped by local morphology + slope, continuous — never per-tile dice) is composed on top, alongside the D-239 §4 domain warp. It is calibrated to read as plausible terrain at a glance — visible, but never measured against a ground truth that does not exist (the player never traverses planetary scale continuously, which is why the top jump can be elastic). The containment ladder is for addressing/streaming, not for holding terrain features: large-wavelength landforms come from the low-frequency octaves of this field keyed to absolute coordinates, not from a container that size — which is why the ladder stops at the region and needs nothing coarser.

    (3) Region is the climate/weather lockdown scale (refines D-239 §2; feeds Q-105). Weather, the seasonal clock, and the temperature baseline are region-scale phenomena (~hundreds of km), resolved once per region — the cheap shared-dynamism source Q-105 sought; every district and tile inside inherits it. Climate is a three-level stack: region = the climate context (latitude-driven temperature baseline, weather state, season); district (2 km) = local temperature = that baseline + elevation lapse + slope aspect (this is D-239 §2's 2 km climate district, now derived as a modulation of the region rather than from scratch); chunk/voxel = the D-239 §3 freeze/snow scatter on the local temperature. "Region temperature" is the context, not a uniform 205 km slab — intra-region latitude (~1.8°) and elevation land in the district/tile modulation. "Locked down" = defined at this scale, not frozen in time (the weather/season state still ticks; the region is the unit it ticks at).

    (4) Edge fuzz — climate does not change on a line. The region (and district) is where climate is computed, not where it steps. A tile's climate value is a continuous, warp-perturbed blend of the surrounding regions' baselines (bilinear across region centres, displaced by a noise field so the blend boundary is ragged, not a straight gradient); the same applies at district edges for the local modulation. The ~205 km and 2 km grids are therefore invisible in the output — temperature, weather, and season grade smoothly and raggedly across boundaries the way a real frontal gradient does. This is the climate counterpart to D-239 §4's domain warp (same meta-rule: the addressing grid must never be visible in the result), with one distinction from D-239 §7: morphology seams stay sharp-but-organic (hard gates, warp-displaced — real geology is sharp), whereas climate is a continuous scalar field and is feathered/blended, not gated.

    (5) Vocabulary — fixed, and recorded in CLAUDE.md. "Region" now means only the ~205 km top hard block. The old 1 km "RegionProfile" scale is removed — its terrain/climate carrier role moves onto the district (2 km), aligning it with D-239 §2's existing climate district. D-201's tier-4 "Region" (50500 km watershed/political) is renamed Province (its actual content — drainage basins, D-205, territory), an overlay painted across regions, not a containment rung. Region (a fixed metric grid cell) and Province (an irregular lore-bearing boundary) sit at overlapping scales but are different kinds and must stay distinct in the docs. No new words are invented (sector is reserved by perception::VisibilitySector; tract and the rest were rejected).

  • Amendment (2026-07-24, body-map-viewer workshop — gridunit vocabulary added; full record D-255): gridunit (at zoom step) is added to the locked vocabulary — additive, no rung changes. A gridunit is the per-step data-canvas cell of the Atlas stepped zoom ladder (D-255) — not a new spatial rung, a role name for whichever of this ladder's rungs a given zoom step is pinned to. At every fixed rung, gridunit spacing equals one of this ladder's fixed metre values and never floats with viewport size or display resolution — a fixed step's gridunit is a rung above, full stop. The Atlas ladder's fixed rungs run from Region (~205 km, rung 1) down to chunk (64 m, rung 5, the deepest Atlas rung); tile/voxel (1 m) is NOT an Atlas gridunit (Phase-5 in-world content — D-255 / D-166 2026-07-23 amendment). The one exception to fixed-metre snapping is rung 0 (Global), the variable map opener: one gridunit per region, so its gridunit is a whole region (~205 km) but its canvas extent floats with the body's region count — the elastic seam of this record made visible (region count = round(2πR/204.8 km)). Global's gridunit is still not viewport-derived (it is the body's region grid, a fixed body property), so the "never floats with viewport/display" discipline holds; only the body's own region count varies. Separately, the display ratio (screen-px per gridunit — 1×1 ideal, ≥5×5 acceptable) is a free, client-side, viewport-dependent parameter, kept architecturally separate from gridunit spacing: two monitors requesting the same step get the same absolute-metre canvas (same cache entry) and merely display it at different px ratios (a GPU resize, never a re-derivation) — which keeps the derivation cache key free of any presentation parameter.

  • Rationale: The cascade glued absolute voxel/chunk scales to the body-relative heightmap with three contradictory assumptions and no record fixing metres-per-pixel, so nothing downstream could be metre-precise (Q-110). A rigid absolute ladder with a single elastic seam removes the contradiction at the source: the only body-specific function in the whole chain is region↔planet via body_radius_km; everything below is fixed integer math, unblocking the production wiring (T-1046). Every rung earns a job — the region in particular is not mere addressing: it is the natural lockdown scale for weather/season/climate (the Q-105 dynamism source) and the sane planetary grid (~195×98 regions on an Earth-sized body, vs an unusable ~19,500 districts). Confining invented terrain detail to a deterministic scatter layer, and climate variation to an edge-fuzzed continuous blend, honours derive-don't-store (D-227) while keeping both believable — the grid never shows and the planetary seam is never walked.

  • Raised by: Jeroen, 2026-06-14 — driving Q-110 during the cascade-spine sequencing (next: wire the tier into production, T-1046).

  • Implementation: unblocks T-1046 (ChunkPos → … → RegionPos is now defined); the region↔heightmap interpolation + detail-scatter synthesis + climate edge-fuzz is the elastic-seam stage (new ticket). D-201 amended (Region→Province). The canonical ladder is recorded in CLAUDE.md. Refines D-239 §2 (district temperature now modulates a region baseline) and contributes the region-clock answer toward Q-105.

  • Cross-reference: D-201 (spatial hierarchy — tier-4 Region→Province, amended here), D-202 (heightmap 1024×512), D-204 (body_radius_km), D-220/D-222 (locked sub-settlement dims — chunk/block/quarter/district), D-205 (province boundaries — the overlay), D-225/D-227 (derive-on-demand / don't-store), D-239 §2/§3/§4/§7/§10 (climate primitive refined; warp; seams; budget reframes to per-active-derivation), D-255 (body-map-viewer render architecture — gridunit vocabulary + the Global rung-0 elastic-seam view), Q-105 (region seasonal/clock state — answered at the region).

  • Dissent: None


D-244: Asset rendering — 3D objects in-world; 2D limited to textures + flat artwork

  • Date: 2026-06-16
  • Decision: The in-world view renders 3D objects directly. Environment and props come from the Trellis image→3D .glb pipeline (/glb-gen, promoted per D-241); characters come from the Quaternius 3D-mesh pipeline composited at runtime via CharacterVisualDescriptor (D-159D-164: 11 body types, segmented regions, separate head mesh, slot system, shape keys, region tints). The only flattened 2D content is (a) textures (PBR maps on 3D surfaces) and (b) flat 2D artwork — paintings, flags, billboards, signage, screen content — images that live as textures/decals on flat surfaces within the 3D world. There is no per-object sprite layer: game objects are not pre-rendered to 2D sprites for display. This completes the 3D pivot already ratified for the camera (D-148, supersedes D-019) and live 3D characters (D-149), extending it to all objects and the asset pipeline.
  • Rationale: The character system is irreducibly 3D — runtime slot compositing across body × head × hair × clothing × skin-tone × colour-overrides × shape-keys × directions × animation-frames cannot be expressed as pre-rendered sprites without combinatorial explosion. The 3D direction was already implicit in the character architecture (the 2026-03-19 character-asset doc states "no mesh work from the spikes carries over") and the Trellis environment pipeline, but was never ratified — so the retired 2D-sprite assumption drifted on in tooling and docs.
  • What this retires / repurposes: the early-spike 3D→2D-sprite render pipeline (renderer/, the /sprite-gen skill, added in #541) is not the in-world format. It is repurposed as the 2D-artwork generator — producing the flat paintings/flags/billboards/signage assets in (b). The sprite-centric asset catalog (docs/assets/visual/ sprites/tilesets framing, _templates/sprite.md) is re-scoped to a 3D-model + texture + artwork catalog.
  • Root-cause note: T-1049/T-1050 (the 2026-06-12 fable-ous.md audit) re-injected the dead sprite model because the retired spike scaffolding sat in the repo as live ground truth with no decision marking it dead. This record is that marker; T-961/T-1049/T-1050 are held in backlog pending re-scope.
  • Cross-reference: Builds on D-148 (3D camera, supersedes D-019) and D-149 (live 3D characters, not sprites) — D-244 extends both to all objects + the asset pipeline. D-159D-164 (character 3D pipeline), D-241 (asset promotion), D-227 (derive/synthesise, don't store). Reconciles the sprite-era decisions (all amended 2026-06-17): D-043 ("not 3D"/Light2D scoped to overlay layers), D-044 (sprite footprint retired; sim occupancy + hierarchy survive), D-049 (z-stack logical model survives; sprite implementation language retired), D-066 (coordinate model + 2×2 geometry minimum survive; sprite-footprint language legacy); [D-019] is already superseded by D-148. Phase 5 (in-world rendering) builds on this.
  • Dissent: None

D-245: Nature-layer believability acceptance gate

  • Date: 2026-06-28
  • Decision: The cascade's nature layers (terrain, hydrology, climate, vegetation, sub-biome — everything that produces the natural environment, as distinct from the built layers: settlements/roads/buildings) are "done" only when the believability litmus passes at randomly-sampled locations across all habitable bodies and world seeds. The deliverable of the nature half of the cascade is "the world reads as a living, real place anywhere," not "the layers are implemented." This is the current holy-grail objective for the generation cascade's nature half (the counterpart to "walkable exteriors" for the built half, D-166).
    • "Anywhere" = held-out random sampling. The gate draws seeded-random locations across the full surface of multiple habitable bodies and multiple world seeds — never curated spots. A body passes only when (essentially) all land probes pass; water is a separate coherence check. This is what stops "looks alive in the demo, dead everywhere else."
    • The litmus is multi-scale and never-repeating (the Netherlands principle): one macro identity (e.g. "delta") that is locally non-stationary — the sub-biome distribution drifts every km², and no two same-class km² are identical. Macro coherence and local non-repetition are both required.
    • Automated necessary-conditions (the screen, per probe): (1) Coherence — water zones render wet; vegetation is conditioned on moisture/water-distance/slope/aspect (not random); drainage monotonic; no impossible combos. (2) Non-stationarity — same-class km² measurably differ; sub-biome mix drifts; no detectable tile-period repeat. (3) Intra-class variety — a patch shows ≥K distinct micro-features, never 100% one material. (4) Relief/landmark — relief variance above a floor where the macro terrain warrants it. (5) Climate-appropriateness — ecology matches the climate envelope; structured-sparse passes, blank fails.
    • Irreducible human gate — the automated checks are necessary, not sufficient. Final sign-off is a person reading rendered layer-maps of random probes and answering the narrate-the-natural-history litmus. Locked by process (periodic sampled review + recorded sign-off), not by code.
    • Strictness ramp — starts budgeted + advisory (≥X% of land probes pass + human sign-off) and ratchets to strict (every land probe; hard push-gate block) once the enforcer's first baselines calibrate the thresholds — so all of Phase 4 is not blocked on an uncalibrated metric.
  • Rationale: "Implemented" is a false summit. The aliveness probe (T-1083) showed the nature layers can pass every binding law (D-239 §8) and still render a dead, uniform world (Wetland 100%, dry ocean, no gradient). The deliverable that actually matters is believability, and the only way to make a gestalt judgment lockable is a random-sample gate where automated necessary-conditions screen a human sufficiency sign-off. Naming this the acceptance gate turns "feels alive" from an aspiration into a measurable, regressable target.
  • Implementation: Enforced by T-1083 (the believability sampling protocol — automated screen + per-layer map export for the human gate; snapshots baselines and runs in the push gate so nature cannot silently rot). Becomes the definition-of-done for epic T-1079 and the needle-movers under it (T-1080 meso non-stationarity, T-1081 relief, T-1082 water-bodies, T-1084 intra-class micro-mosaic). Seeded by the probe server/src/bin/aliveness_probe.rs. Concrete thresholds are TBD — calibrated from T-1083's first baselines (a calibration Q-record may follow).
  • Cross-reference: D-239 (binding laws — necessary but not sufficient; this is the sufficiency gate above them), D-227 (derive-don't-store — the tier the non-stationary variation lives in), D-243 (the scale ladder the multi-scale litmus spans: macro region / meso district / micro chunk), D-210 (SubBiomeVariant — amended in spirit: sub-biome must be a spatially-varying distribution, not a single tag, T-948), D-166 (generation-before-player — this defines "done" for the nature half). Tickets: T-1079 (epic), T-1080/T-1081/T-1082/T-1084 (findings), T-1083 (enforcer).
  • Dissent: None

D-246: Intra-class micro-habitat mosaic — sub-chunk palette modulation of the D-228 axes

  • Date: 2026-07-02
  • Decision: Within a single terrain class the sub-chunk seed paints a spatially-coherent mosaic of micro-habitats — the third believability lever (after T-1080 meso non-stationarity and T-1081 voxel relief), and the mechanism that resolves the aliveness probe's "Wetland 100%" into clearings, copses, creeks, meadows, bogs and marshes (and the equivalent for every other class). The mosaic is a derive-don't-store (D-227) modulation of the existing D-228 axes — never a new axis. Five load-bearing choices, resolved with Jeroen (2026-07-02, via the /whats-next refinement pass on T-1084):
    1. Mechanism — a new sub-chunk noise band + relief-conditioned selection. A voxel_mosaic() octave band at ≈864 m wavelengths ([64, 32, 16, 8] m — finer than T-1081's voxel_relief [1024…128] m sub-district band, so patches read distinct inside a single 64 m chunk), keyed by a new body-global SeedDomain::VoxelMosaic = 12 (append-only; the pinned-discriminant guard in seed.rs gains a line). The field value indexes the class palette's cumulative weights to pick a micro-habitat, and the pick is causally conditioned, not random — the local voxel_relief micro-thalweg (lowest-relief band → the palette's wet entry: creek/bog/marsh/brook/active-channel; drier rises → meadow/glade/clearing) plus moisture_q. Creeks are lines following the relief gradient, not blobs; blobs are smooth (low-frequency value noise), never per-tile salt-and-pepper. This replaces the current scatter_vegetation() per-voxel seed-bit scatter (voxel.rs:1907) — which is exactly the salt-and-pepper the mosaic exists to kill.
    2. Vocabulary front-loaded — all shipped micro-habitats mapped to D-228 axis values now. Every micro-habitat is a (TerrainMaterial, Vegetation, Water) combination plus derived micro-relief/name. Where no honest existing value exists, a new value is added to an existing axis (never a new axis), and all of them land now rather than incrementally — because each value is a distinct selectable outcome in the weighted palette, so adding one later re-rolls the deterministic realization of every affected voxel. New values (amended into D-228): Vegetation::{Meadow, Deadfall, Lichen}, TerrainMaterial::{Hardpan, Scree}. The table below is the temperate/baseline register; the biome variants (savanna gallery-copse, tundra thermokarst / lichen-heath, boreal muskeg / deadfall, …) fill the same niche spine per the Palette-key rule and are enumerated in T-1084 before first generation — so the table grows past these baseline rows, which is expected (and cheap — see Palette key).
    3. Palette weights live in server/data/mosaic_constants.toml, runtime-loaded like climate_constants.toml (the T-1080 precedent). The TOML is part of the deterministic input surface — weight edits change realizations, so they are expected only during Q-123 calibration and frozen after.
    4. The believability metric is in scope for T-1084. ContrastMetrics (believability.rs) gains a within-patch micro_habitat_distinct field (min distinct axis-triples across sampled same-class patches), and evaluate_criteria gains a provisional ≥ 3 criterion (K), calibrated by Q-123 item 3. Mechanic and metric land together (the T-1081 precedent — it shipped both voxel_relief and contrast.voxel_relief_m).
    5. Family gating mirrors voxel_relief. The flat families (AlluvialPlain, LavaField, BraidedDelta, DuneStrand, MeanderReach) take the full mosaic — vegetation/material and a micro-relief nudge (dune crest/slack, bog lows) and Shallow-water creek carving. The dramatic families (CliffCoast, FjordWall, IncisedGorge) take a non-structural pass — vegetation/material only, no elevation change (a relief nudge would warp their carved geometry) — conditioned on their own generator geometry. WaterBody takes none. The <5 ms/chunk budget (D-239 §10) is verified in the T-1081 believability/budget harness as part of implementation (voxel_mosaic adds ≈4 value_noise calls on top of voxel_relief's 4).
  • Palette key — a lookup table on (surface-class × climate-biome × biosphere-register) (reworked 2026-07-02; the original surface-class-only key was biome-blind). The mosaic selects a weighted micro-habitat palette from three deterministic inputs, no stored tag: (1) surface-class from the family generator's base output (terrain == Wetland|Sand|Rock|Gravel|Lava → that class; terrain == Soil split by dominant vegetation → Forest vs Grassland); (2) climate-biome from the district's temperature_c / moisture_q / precipitation_class, so one Grassland surface reads savanna vs temperate meadow vs cold steppe vs tundraD-210 sub-biome realized as a spatially-varying distribution, and the fix for the biome-blindness a surface-class-only key leaves; (3) biosphere-register from D-247 — native-mirror-wild vs introduced-Earth-managed (the D-228 wild→managed override precedence), which skins a niche (an alien mirror clade vs an Earth crop) without changing its gameplay semantics. A fixed niche spine (canopy / understory / wet-hollow / pioneer / bare) is the authoring scaffold climate + register fill differently — the vocabulary is authored per-niche, not as a free biome×class cross-product. Table size is not a performance or storage constraint (a text lookup stays flat well past ~100k entries and is negligible beside textures/models), so author generously and expressively where a biome earns distinct fills — though ~100k is headroom, not a target; the aim is coherent believability, bounded by the niche spine and taste. The only binding rule is determinism: the full vocabulary is fixed before the mosaic generates its first world (a later addition re-rolls every affected voxel), so T-1084 completes the table before generation, never after.
  • Vocab → D-228 axis table (the load-bearing content — items sharing a triple are separated by derived micro-relief/shape or derived name, per D-228's "names are derived, never stored"; bold = new axis value):
Class (palette key) Micro-habitat TerrainMaterial Vegetation Water Separated by
Wetland clearing Wetland Barren Dry drier hummock (relief +)
Wetland copse Wetland Forest Dry
Wetland creek Wetland Barren Shallow linear (micro-thalweg)
Wetland meadow Wetland Meadow Dry
Wetland bog Wetland Scrub Shallow lowest relief
Wetland marsh Wetland Grass Shallow reed graminoid
Forest dense stand Soil Forest Dry base
Forest glade Soil Grass Dry open gap
Forest deadfall Soil Deadfall Dry
Forest brook Soil Thicket Shallow linear riparian
Forest fern undergrowth Soil Thicket Dry renderer fern texture (cosmetic)
Grassland tussock Soil Grass Dry base
Grassland scrub island Soil Scrub Dry
Grassland wildflower Soil Meadow Dry shares Meadow
Grassland dry wash Gravel Barren Dry→Shallow linear ephemeral channel
Sand dune crest Sand Barren Dry high relief
Sand slack Sand Grass Dry interdune low (relief )
Sand hardpan Hardpan Barren Dry
Sand oasis Sand Forest Shallow
Sand scrub clump Sand Scrub Dry
Rock outcrop Rock Barren Dry protruding (relief +)
Rock scree Scree Barren Dry open slope
Rock talus Scree Barren Dry derived name (cliff foot)
Rock ledge Rock Barren Dry flat step (relief)
Rock lichen Rock Lichen Dry
Gravel bar Gravel Barren Dry base
Gravel active channel Gravel Barren Shallow/Deep flowing water
Gravel vegetated island Gravel Scrub Dry stabilized
Lava fresh sheet Lava Barren Dry base
Lava weathered crust Lava Lichen Dry shares Lichen
Lava tube collapse Lava Barren Dry depression (relief ); reconcile with existing tube_depression, voxel.rs:710
Lava pioneer scrub Lava Scrub Dry
  • New D-228 axis values (append-only discriminants, D-010): Vegetation::Meadow = 7 (forb-rich flowering herbaceous — the wetland meadow and grassland wildflower), Vegetation::Deadfall = 8 (dead woody debris — obstruction + fire fuel, no canopy concealment), Vegetation::Lichen = 9 (crustose/pioneer biological crust — the rock lichen and lava weathered crust), TerrainMaterial::Hardpan = 6 (compacted flat crust — hard fast footing, does not slump like sand), TerrainMaterial::Scree = 7 (loose angular rock debris — unstable footing; scree/talus differ only by derived name). Water is unchanged (Dry/Shallow/Deep suffice). Adding these lights up exhaustive match arms across voxel.rs, from_vegetation_class, the renderer, and any serializer — an intentional compile-time checklist.
  • Rationale: T-1084's finding was that a class can pass every binding law (D-239 §8) and still render as one uniform material ("Wetland 100%"); D-245 makes believability the gate, and intra-class variety is one of its five necessary conditions. Reusing the T-1081 detail_scatter machinery (same enveloped-fBm core, one more octave band, one more SeedDomain) keeps the mechanism inside the derive-don't-store tier with no new architecture. Front-loading the whole vocabulary — rather than growing it as needs appear — is forced by determinism: the palette is a weighted selection the seed indexes, so every added value shifts the realization of already-generated worlds. Expressing micro-habitats as combinations of orthogonal axes (plus derived name/relief) rather than a flat MicroHabitat enum is the same anti-combinatorial-explosion logic as D-228 itself.
  • Implementation: T-1084 (story under epic T-1079). Touchpoints: detail_scatter.rs (voxel_mosaic() + the [64,32,16,8] m band); seed.rs (SeedDomain::VoxelMosaic = 12 + the pinned-discriminant guard); voxel.rs (new per-family-gated mosaic pass after the relief block ~:495; replaces scatter_vegetation() ~:1907; new axis-value match arms); believability.rs (micro_habitat_distinct + criterion); new server/data/mosaic_constants.toml. Budget verified in the T-1081 harness.
  • Amendment (T-1084, 2026-07-05 — v1 implementation scope, PR #172 H2/C4): the first implementation lands a narrower pass than item 5 specifies, recorded here so the record and code agree (the D-239 T-1080/81/82 postscript pattern):
    • Only the Soil-derived vegetated/wet classes take the mosaic in v1 — Wetland, Forest, Grassland (the apply gate in voxel.rs). The material-driven families (Sand, Rock, Gravel, Lava) are gated _ => false, so the material-variant rows of the table above — dune crest/hardpan, scree/talus, dry-wash Gravel, and every Lava row — are authored but not yet constructed on any reachable path, and the two new TerrainMaterial::{Hardpan, Scree} values are consequently unreachable in v1. A material change on those families must respect the family's own lithology and carved geometry (shape-aware §8 reconciliation), so they are the deferred follow-up. Item 5's promise that the flat families take the full (material + micro-relief) mosaic is deferred, not delivered at v1.
    • No elevation change in v1. The item-5 micro-relief nudge (dune crest/slack, bog lows) and the non-structural dramatic-family pass are both deferred — v1 modulates vegetation + water only, never elevation_m. The D-239 §8 lithology law therefore holds trivially: material and shape are left exactly as the family generator set them.
    • For the three classes it does touch, the mosaic OWNS the §8 climate→vegetation law (C4), not merely stays inside it. The no-skip half holds — no palette emits Barren in a Forest / Wetland / Grassland zone. The climate-appropriateness half is enforced by a unified !Barren apply-gate spanning all three classes (C1 + N1): a climatically-barren district (frozen < -50 °C = surface ice / geology per D-239 §2, or hyper-arid moisture_q < 5 — both resolved to VegetationClass::Barren upstream by derive_vegetation) is skipped, so the mosaic never re-grants cover the climate withheld. The guard must span Wetland as well as Grassland: derive_morphology_zone has no temperature gate, so a frozen + wet + flat district still yields Wetland material and would otherwise be painted with wetland copse/bog — the same C1 symptom via the material path (a frozen ice world must read as barren ground, not as vegetated tundra or marsh — D-245 §5). Forest is Barren-free by construction (SurfaceClass::Forest requires dominant_veg == Forest), so the guard is a no-op there. The trade: the family generators' guaranteed riparian Thicket band is softened to a probabilistic wet-habitat clustered near has_active_channel chunks (T-1040-gated) — acceptable at v1.
  • Cross-reference: D-228 (the axes this modulates — amended 2026-07-02 to add the five values), D-227 (derive-don't-store — the tier the mosaic lives in), D-245 (believability gate — this is condition 3, intra-class variety), D-239 §8 (binding laws — the mosaic stays inside them) / §10 (sub-chunk budget), D-243 §2 (the detail-scatter tier T-1081 and this extend), D-247 (native-mirror vs introduced-Earth biosphere register — palette-key input 3; the mosaic is chirality-blind, but its register labels each niche native or introduced), D-210 / T-948 (SubBiomeVariant — now palette-key input 2: the macro biome realized as the finer intra-class distribution, not a single tag). Tickets: T-1084 (this), T-1079 (epic), T-1081 (relief — a conditioning input + the reused machinery), T-1083 (enforcer — measures the mosaic), Q-123 item 3 (calibrates K).
  • Dissent: None

D-248: 3D locomotion presentation — per-leg constant-velocity interpolation keyed to the stance throttle

  • Decision: The 3D client rig turns discrete server tile steps into motion with per-leg constant velocity: on each confirmed step, leg speed = distance / stance interval, the interval read live from InputMapper.MOVE_INTERVAL_MS (Sprint 200 / Walk 400 / Careful 600 / Crouch 800 ms — never copied). Multi-tile deltas (latest-wins snapshots drop intermediate steps) close under a 3× catch-up clamp; ≥ 2.5 m (5 subtiles) snaps all channels (position, yaw, camera, animation hard-cut). Diagonal legs run 1.41× (no √2 on the wire, D-053) — accepted as sim truth so held diagonals arrive on time. Gait clips are cadence-synced (speed_scale = clamp(leg_speed / NATIVE_MPS[clip], 0.61.8) — constant per leg, killing foot-slide); Careful uses Walk_Formal; idle enters after 0.18 s hysteresis (snapshot-jitter guard). Exactly one smoothing layer per channel: position constant-velocity (never eased), yaw eased under per-stance deg/s budgets, camera exponential (rate 6.0 — converges to a constant offset against constant velocity). Purchased UAL tiers (or ual_extended) wire via explicit AnimationLibrary names + "lib/Clip" addressing (the imported default library name is "" — a naive second copy collides). Stepped player camera yaw remains out (not canon; the T-key tilt cycle stays a dev affordance, D-148/D-158).
  • Rationale: resolves the D-054 ↔ D-053 cadence conflict: a fixed 100150 ms tween dashes-then-stands at Walk/Careful/Crouch cadences, and exponential position lerp (the 2D renderer's model) produces a per-step velocity sawtooth foot cadence can never sync to. Constant-velocity legs sized to the throttle make held-key motion seamless and make cadence sync possible at all. Annotates D-054: its "client-side Tween interpolation (100150 ms)" sentence is scoped to the 2D renderer henceforth.
  • Implementation: T-1088 (client/scripts/sandbox/locomotion_rig.gd, locomotion_anim.gd, sandbox_constants.gd — constants are initial values pending the live tuning pass). Strictly interpolate-only: a silently rejected move needs zero client handling by construction (Q-020 annotation) — facing updates, position doesn't, gait keys off render velocity ("bump-to-turn").
  • Cross-reference: D-054, D-053, D-055, D-066, D-149, D-249. Tickets: T-1088.
  • Dissent: None

D-249: 3D facing presentation authority — server feet, client eyes

  • Decision: Which source rotates the 3D character model: moving (incl. the idle-hysteresis window) → the snapshot player_facing octant (the server overwrites Facing from the move delta in the same tick — it is the motion direction); idle → the client-local aim octant, i.e. the same snapped octant that rides the SetFacing wire (D-054: only octants cross the wire, so mouse-responsive idle facing is client-local by construction, and the model never shows an octant the server wasn't told). Facing freezes under input suppression (dialogue_active/free_camera_mode). The idle source is an injected provider — NPCs leave it unset and collapse to pure-wire, one code path. The verified octant→yaw table is yaw = π/2 θ (South 0°, East +90°, West 90°, North 180°), applied on ModelRoot in WorldRoot-local space so octant→yaw composes with the D-148 45° map rotation exactly once (confirms compositor-api-spec §2). Trap: the rig must never call CharacterVisual.set_facing() — its internal table is E/W-mirrored relative to this convention (character_visual.gd:184-193).
  • Rationale: single-source alternatives are strictly worse — pure-snapshot idle facing is ~250300 ms of laggy 45° pops (the 2D client already draws its indicator from client-local aim, entity_renderer.gd:186-193); pure-client facing breaks server-authoritative display while moving. The residual artifact is bounded: idle, the model leads the server's vision cone by ≤ 1 RTT, and the disagreement is exactly the in-flight SetFacing.
  • Cross-reference: D-054, D-151, D-248, Q-084 (presentation half settled here; the walk-vs-aim animation split stays open — the purchased 8-direction walk/jog/crouch clip sets now make a direction-matched-clip solution tractable). Tickets: T-1088.
  • Dissent: None

D-252: Facing is view-only — movement no longer writes Facing; NPC gaze is intent

  • Decision: The Facing component (and the wire's player_facing/entity facing octants) is the view direction only — the vision-cone heading. Accepted moves no longer overwrite Facing (facing_from_delta is removed from the movement path): the player's Facing changes only via explicit SetFacing (the mouse octant, D-054's change-gated send). Movement direction is not a wire concern — the client derives body heading from position deltas (the 3D rig's per-leg velocity, D-248). NPC gaze becomes intent: the NPC path-follow system sets Facing to the step direction explicitly, relocating look-where-you-walk into AI intent — later behaviors (a patrolling guard glancing sideways, an NPC backing away while watching the player) become Facing writes by the AI, no special cases. Implementation correction (2026-07-06): the old coupling was player-only — NPCs never received Facing from movement; their cones sat at spawn direction while walking. Path-follow gaze is therefore a strict improvement, not preservation.
  • Motivation (play evidence, T-1088 live session 2026-07-06): during client path-follows the vision cone flapped to path-forward on every accepted step and stuck there until the mouse crossed an octant boundary — the movement overwrite fought the explicit aim, and the best client-side mitigation (post-step re-assert, ~2-tick delay) still left a ~100 ms flap per step because movement wins within a tick. Splitting the semantics is the correct fix; the walk-one-way-look-another model this enables is exactly the split [Q-084] parked.
  • Consequences: "bump-to-turn" (blocked moves updated facing) is retired — correct under a mouse-view model. The client's post-step reassert_facing mitigation is removed. D-249 is amended by this record: while moving, the body yaw source is the leg-velocity direction (wire facing while moving is now the view, which must never rotate the body); idle behavior is unchanged; the layered head/torso look-at ([T-1088] follow-facing) may run during any movement, not only path-follows. The 2D renderer's player sprite direction follows the view octant in all states (was: move direction while moving) — accepted drift on D-166-frozen code. Amends D-054 (its facing sentence: octant-only wire + explicit sends stand; the movement overwrite is struck).
  • Cross-reference: D-054, D-248, D-249, D-015 (vision cone), Q-084 (walk-vs-aim split — resolved by this record for semantics; camera rotation option stays parked). Tickets: T-1093 (implementation), T-1088.
  • Raised by: Jeroen ("we need to split movement direction and viewing direction in the server protocol", live session 2026-07-06, after the T-1093 mitigation demonstrated the residual flap) with Claude (mitigation evidence, NPC-intent relocation, consequence audit).
  • Dissent: None

D-253: Region transient state model — seasonal/tidal/weather/snow phase functions (resolves Q-105)

  • Date: 2026-07-08

  • Resolves: Q-105.

  • Decision: The region (D-243) is the single scale at which transient (clock-bound) surface state is resolved — "computed once per region per phase, inherited by every district and tile inside" (D-243 §3, the cheap-dynamism source). This record pins that model: what transient state a region carries, how its clock advances and recomputes, how tiles inherit it, and its determinism — but explicitly not the gameplay/rendering that later consumes it.

    (a) The transient bundle — four phase terms + their derived surface scalars. A region carries a memoized RegionPhase (proposed carrier name) built from four clock terms, one per natural clock rate (fastest → slowest), each a pure function of the in-game clock (D-031) and the region's static params:

    • Diurnal — day/night, from the D-031 day-phase clock; amplitude = the atmosphere heat-retention already defined in D-239 §2 (thick air → small swing). This term is the one D-239 §2 already owns for temperature; D-253 folds it into the same region phase model as the other three, rather than leaving it a separate mechanism.
    • Tidal — from the lunar/day clock; amplitude from the body's satellite config; absent (zero) when the body has no moon. Drives the tidal component of water-height.
    • Weather — a coarse, seeded, deterministic precipitation/condition term (a bounded pseudo-sequence keyed on (region, weather-bucket)not a simulated advecting weather system; deliberately minimal to stay "nearly free"). Drives puddles + general conditions.
    • Seasonal — from the year clock, phased continuously by latitude (amplitude → 0 at the equator and sign inverting across it — a continuous function of latitude, not a binary hemisphere flag, so the equatorial seam never steps). The slow temperature term + the seasonal component of water-height + the snow/crop drivers.
    • From these terms the region derives its transient surface scalars (region-level, not per-tile): temperature(time) = the T-1078 / D-240 static baseline + seasonal offset + diurnal offset (this makes D-239 §2's "temperature(time)" concrete — the static baseline T-1078 ships is the mean-phase value); water-height(time) = mean water level + seasonal term + tidal term; snow/ice depth = f(temperature(time), moisture) feeding the existing T-1030 scattered band (D-239 §3) — D-253 supplies only the depth over time, T-1030 owns where the band scatters; weather intensity/category; and the crop-cycle phase (sown → growing → ripe → harvested → fallow) = f(seasonal), the cadence clock for D-228's managed-Vegetation farmland override (D-253 owns the cadence; the settlement/economic layer owns crop type and farmland placement).

    (b) Phase-stepping — recompute on bucket rollover, never per tick, never integrated. Each term declares a clock-bucket size (diurnal ≈ day-phase; tidal ≈ day-phase; weather ≈ a multi-phase block; seasonal ≈ a season step). RegionPhase is memoized with the clock-buckets it was computed for; a query recomputes a term only when its bucket rolls over — the "nearly free" requirement, and D-226's dynamic-state path. Crucially, every scalar is evaluated from the absolute clock value (state = f(clock)), never integrated step-by-step (state += Δ): absolute evaluation is drift-free, reproducible, and hands D-226 its frozen-phase inspection for free (freeze the clock → the whole world's transient state is stable and re-derivable). Default bucket sizes are source-canonical + tunable (the climate_constants.toml precedent), provisional pending calibration.

    (c) Inheritance — region computes the scalar, the tile realizes it locally, edge-fuzzed. Districts and tiles never compute their own phase — they read the region scalars, blended across region neighbours by the same D-243 §4 edge-fuzz the static climate baseline uses (warp-perturbed bilinear across region centres), so a weather front / seasonal gradient / thaw line never steps on the ~205 km grid (and the equatorial seasonal seam is continuous by construction, per (a)). The per-tile realization is a local comparison, not an inherited value: a tile is flooded iff region-water-height(time) > tile.elevation; snowed to a depth capped by the region snow-depth, scattered by the T-1030 band and gated on its own material/moisture; puddled iff the weather term is wet and the tile sits in a micro-relief low (the T-1081 voxel_relief hollows) that diurnal evaporation has not burned off; its farmland shows the crop stage the region phase dictates. So floodplain / tidal-flat / seasonal-river emerge (D-228) — the static world stays static; only the region scalars carry the clock. This directly discharges T-1082's deferred TidalFlat wet/dry alternation (Shallow/Dry = tidal water-height vs local elevation).

    (d) Determinism (D-010). Every term and scalar is integer fixed-point (phase positions as basis-points of their cycle; depths/heights in integer mm/cm; temperature offsets on the existing integer scale) and a seeded pure function of (absolute clock, region params, body seed). No per-tick float accumulation exists anywhere in the model — see (b). Weather's pseudo-sequence is a seeded hash of (region, weather-bucket), so it replays identically. Frozen clock → byte-identical state on every platform and every reload; this is what lets the transient overlay live in the D-227 derive-don't-store tier (the memo is not persisted — a save records only the clock and the state re-derives).

    (e) Scope boundary — this is the STATE MODEL, not its consumers. D-253 defines derivable transient state and the functions that produce it — Phase-4-appropriate, viewable as an Atlas layer like every other cascade output, and completing the forward-contracts left open by T-1030 / T-1078 / T-1082. It does NOT build: weather/flood/snow gameplay (movement, perception, passability effects — D-239 §9 names the passability hook but its wiring is later), the farming/crop simulation, snow/water rendering (Phase 5), or any verb that reads this state. Those are later phases and inherit a fixed contract here. Adding a fifth term or a consumer is a future amendment, not a reinterpretation of this record.

  • Rationale: The model is cheap because the four transient terms line up with the four natural clock rates and reuse the existing temperature primitive — temperature(time) and water-height(time) are just a static baseline plus the same seasonal/diurnal/tidal offsets, so one clock model drives temperature, flooding, tides, snow, puddles, weather, and crops with no per-system simulation. The single load-bearing choice — absolute-clock evaluation rather than step integration — satisfies three requirements at once: determinism (D-010, no drift), cheapness (recompute only on bucket rollover, and per-region not per-tile → O(regions)), and inspectability (D-226 frozen phase). Computing at the region and realizing per-tile by comparison is what keeps the dynamism O(regions) while still producing per-tile flood/snow/puddle/crop extent — the "computed once, inherited" contract D-243 §3 promised.

  • Raised by: Jeroen (Q-105 model constraints — clock + hemisphere bound, moon-gated tides, computed-once-per-region, "nearly free", 2026-05-25) + Tyre (T-1057 triage — the four-term phase-function model, the absolute-clock / memoized-by-bucket / edge-fuzzed-inheritance pins, 2026-07-08). Authored as a proposed design for PR review — the flagged judgment calls (below) are open for the lead/user to adjust.

  • Design choices flagged for review: (1) diurnal promoted to a first-class term — Q-105 enumerated three terms (seasonal/tidal/weather); D-253 adds diurnal as the fourth, since D-239 §2 already owns it for temperature and T-1030's dawn-frost already depends on it (a coherence unification, but it extends Q-105's framing). (2) weather kept deliberately minimal (a seeded coarse precipitation/condition term, no advecting fronts) — the depth is a taste call. (3) recompute-bucket granularities (diurnal/tidal ≈ day-phase, weather ≈ multi-phase, seasonal ≈ season-step) are provisional/tunable, not hard-pinned. (4) memoized-not-persisted reconciles D-228's "region property computed once per phase" with D-227 derive-don't-store (the region caches the bundle keyed by bucket; it is never durable state). (5) "phased by hemisphere" read as continuous-in-latitude (amplitude→0 at equator) rather than a north/south sign flag, so the seam edge-fuzzes cleanly.

  • Implementation: Phase 4+ (epic T-750), the forward contract from T-1030 (transient freeze/snow depth), T-1078 (region climate stack — shipped the static baseline + region-clock structure; D-253 adds the transient terms), and T-1082 (tidal-flat wet/dry). Proposed home: a RegionPhase carrier alongside RegionProfile in BodyWorldState (D-203 / D-239 §10 region storage), memoized by clock-bucket; proposed derivations derive_region_phase(clock, region) + per-tile realization helpers (flood / snow / puddle / crop) — names non-binding, the impl ticket fixes them. Constants in a source-canonical TOML (climate_constants.toml precedent). A new implementation ticket lands under T-750.

  • Cross-reference: D-243 (region = climate/weather/season lockdown scale; §3 computed-once-inherited; §4 edge-fuzz — the inheritance contract), D-228 (the seasonal water-height / snow-cover overlay / crop cycle this model drives — its open water-height sub-question resolved here), D-239 §2 (temperature(time) primitive — made concrete) / §3 (T-1030 scattered freeze-snow — given its clock) / §9 (seasonal passability — a later consumer), D-240 (planet_class temperature envelope — the baseline the seasonal/diurnal terms offset), D-226 (dynamic-state frozen-phase inspection), D-031 (game clock / day phases — the clock source), D-010 (determinism), D-203 (BodyWorldState region cache — the memo home), D-227 (derive-don't-store — the memo is not persisted). Tickets: T-1057 (this triage), T-1030 / T-1078 / T-1082 (the discharged forward-contracts), T-1081 (voxel_relief micro-lows — puddle/flood sites).

  • Dissent: None


D-254: Standalone Atlas companion app — make atlas, dual-connection reader

  • Date: 2026-07-17

  • Decision: The implant Atlas (D-169/D-170's implant/map app) ships as a second, independent Godot entry pointclient/scenes/atlas_standalone.tscn, launched via a new make atlas target — that boots the SAME implant scene tree used in-game but skips the player entirely: no character, no main.tscn, no gameplay HUD. It connects to the simulation server either by attaching to an already-running game (inheriting that world's state read-only) or by spawning its own server process (offering seed selection now; save selection is a recorded, unbuilt hook — saves are Phase 5+). The server enforces read-only server-side via a distinct ConnectionRole on the handshake (Player | Reader, Reader spawns no character and receives no ObserverSnapshot at all) — the Atlas app itself gains no new client capability, it is the existing Atlas UI pointed at a bridge connection the server structurally refuses inputs from. A future market-trading widening (§6) adds a TradingReader role as a strict superset of Reader (never a replacement) rather than inventing a second connection type.

    (1) CONNECTION MODEL — attach vs. spawn, discovery.

    Today's reality, confirmed in code: main.rs binds a TcpListener, prints LISTENING:{port}, then calls listener.accept() exactly once — blocking, no loop. A second TCP client completes its TCP-level handshake (kernel backlog accepts it) but never gets an application-level accept — it hangs forever waiting for HandshakeMessage. Not refused, not replaced — silently starved. This is the actual failure mode the reader connection must design against; zero multi-connection plumbing exists anywhere in server/src/bridge/ today, confirming the ticket's own framing ("almost certainly single-connection").

    Default port 9876 (sim_bridge.gd:26, matches main.rs fallback), overridable via positional addr / --port / (client-side) SR_PORT. SR_PORT is already the env var two Godot scripts read today for "which port do I dial" (visual_capture.gd:99, locomotion_sandbox.gd:67) — the discovery mechanism reuses that existing convention rather than inventing a third (SR_ADDR is a server-side bind override and is not load-bearing for either connection mode below).

    • Attach-mode discovery: fixed default port 9876 + SR_PORT override — the same two-tier scheme the game client already uses to find its own server. Raw TCP connect with a ~500ms timeout (localhost, not WAN — no reason to wait longer). ECONNREFUSED is a real, unambiguous signal ("no server listening") and falls through to spawn-mode. A connection succeeding does not yet mean attach is safe — that gate is the Reader-role handshake in §2, not the TCP connect itself.
    • Spawn-mode lifecycle: reuse the tests/run-visual precedent exactly — --port 0 (OS-assigned), parse LISTENING:{port} from stdout — but without --test-mode: the companion needs the real systems.db world, not Gauntlet test fixtures. Ownership: the companion app owns the child process it spawns, the same pattern server_process.gd already implements (OS.create_process/OS.kill/NOTIFICATION_PREDELETE safety net) — reused directly, not reimplemented. World seed is passed via the companion's own StartupMessage.world_seed post-handshake (not a --seed CLI flag) — this keeps the save/load seam (§5) as the single source of truth for how a spawned world gets populated, rather than splitting seed-selection across a CLI flag and a wire message.
    • Mode selection UX: auto-attach-else-spawn — try attach for ~500ms, fall through silently to spawn on refusal. Zero friction for the common case ("inspect the world I already have running"), and the fallback is never wrong (spawn always works). An explicit Attach/Spawn chooser is deferred — only justified if reader-mode failures turn out confusing enough in practice that users need visibility into why attach didn't happen; not assumed necessary at design time.

    (2) READER CONNECTION CLASS — handshake variant, server-side enforcement.

    Enforcement is server-side at the protocol layer, never client politeness — a hostile or buggy companion client is exactly D-010's adversarial case, and the read-only guarantee has to hold against that, not just against a well-behaved reference client. The one seam that matters: before the server unconditionally spawns a PlayerCharacter (main.rs, today unconditional on every accepted connection).

    Handshake extension: add role: ConnectionRole to StartupMessage — enum Player | Reader (widened by §6 to Player | Reader | TradingReader) — with #[serde(default = "ConnectionRole::player")] for back-compat, rather than a separate pre-startup negotiation message. This follows D-192's existing "no lockstep negotiation" precedent (protocol_version field dropped for the same reason): role is data on the existing message, not a new protocol gate. Critical determinism guard: a Reader's world_seed field is ignored server-side and never re-seeds SimRng — a second StartupMessage touching SimRng after tick 0 would break determinism for whatever Player is already in session (spawn-mode readers get their seed from the world THEY spawned, at genuine tick 0; attach-mode readers must never be able to perturb an already-running world's RNG state via their own handshake).

    Server enforcement — structural, not filtered: Reader role skips the character-spawn path entirely and receives no ObserverSnapshot at all — not a stripped/redacted one, none. This is the load-bearing point: ObserverSnapshot is a per-character observation record (facing, inventory, visible_tiles are all meaningless without a character), so forwarding the Player's own snapshot to a Reader — even filtered — would be a direct D-010 boundary violation (a second observer silently granted the first observer's fog-cleared view). What a Reader can legitimately receive is proven by the existing handler signatures: handle_star_map_request, handle_city_names_request, handle_atlas_request (and this record's new browse-request handlers, §4) all take no observer/character/query parameter whatsoever — just body_id/world_seed/path — which is the independent proof that this data was already install-static/world-public before D-254, not a new carve-out invented for readers.

    Message Player Reader
    Vec<PlayerInput> (inputs) yes no
    ObserverSnapshot (outbound) yes no — not even filtered
    Atlas/StarMap/CityNames/Browse request+response yes yes
    HandshakeMessage yes yes

    Violation handling: a Reader sending Vec<PlayerInput> is syntactically valid (the existing decode_inbound demux parses it fine) but role-disallowed. Log + drop on first offense, mirroring the existing recoverable DeserializationWithDump pattern; escalate to disconnect only on repeated violations — a natural fit for the already-flagged N-consecutive-errors handling in the bridge module, made per-connection once multiple connections exist.

    Multi-connection architecture — scoped honestly as 0-1 Player + 0-N Readers, explicitly NOT general N-player (that is D-009's separate, larger, and currently out-of-scope ambition — this record does not reopen it). BridgeResource (today a single Box<dyn SimBridge>) becomes a collection; the single blocking accept() becomes a non-blocking accept-loop polled per-tick, so a Reader connecting mid-session never stalls the Player. The inbound drain loop routes Vec<PlayerInput> only from the Player-role connection; atlas/starmap/citynames/browse requests are accepted from any connection, but responses need a connection-id tag (today's response buffers have no "whose request was this" notion, because there has only ever been one connection). Outbound ObserverSnapshot sends target the Player connection only — this is a structural enforcement of the boundary above, not merely a convention that could be gotten wrong by a future edit.

    Back-pressure/lifecycle — the sharp existing edge: today BridgeError::Disconnected sets ServerRunning = false and kills the whole server process, because currently one connection's disconnect is the session ending. That behavior must NOT fire on a Reader's disconnect once roles exist — only a Player disconnect should flip ServerRunning; a companion app closing its window must never kill the game it's attached to. Determinism holds by construction as long as reader frames never reach the InputQueue/SimRng path (guaranteed by the enforcement above, not by a separate check). Recommend a lower per-reader inbound frame cap (e.g. 8/tick vs. the existing Player cap of 64/tick) — a reader has no legitimate reason to send that volume of requests per tick, and the cap is cheap insurance against a runaway/misbehaving companion client.

    (3) APP SHELL — how make atlas launches the Atlas standalone.

    Decision: a dedicated entry scene, not a feature flag on main.tscn. client/scenes/atlas_standalone.tscn is a bare root (Node2D or Control) with a script (atlas_standalone.gd) following the exact boot shape client/tests/visual_capture.gd already establishes for minimal Godot entry points (_init() -> _run.call_deferred(), connect, wait for handshake, open UI) — except atlas_standalone.gd is a real scene script (extends Node2D, normal _ready()), not a SceneTree-extending test harness; the SceneTree pattern is for offscreen capture tooling, the standalone app needs a visible window.

    Why not a flag on main.tscn/main.gd: main.gd is saturated with player-only wiring that a "headless" branch would have to route around at every touch point, not bypass cleanly — 18 @onready gameplay HUD nodes (minimap, stance indicator, inventory grid, dialogue box, interaction list, gauntlet HUD…), a SnapshotEventRouter with a dozen player-centric register_always/register handlers (update_zone, play_recognition_chimes, consume_dialogue…), free-camera WASD panning tied to GameState.free_camera_mode, and a _process() loop whose entire second half is input-queue flushing (InputMapper.flush_queue()SimBridge.send_input()). None of that exists to serve the Atlas — it exists to serve a playing character, which a reader connection never has (and, per §2, structurally cannot send inputs for even if it tried). A flag would mean auditing and branching every one of those systems to no-op correctly; a dedicated scene means writing on the order of 100 lines that do only what the Atlas needs, with zero risk of a reader session accidentally exercising player-only code paths (interaction prompts, dialogue, bug report capture) that assume a character exists.

    Boot sequence (atlas_standalone.gd, modeled directly on visual_capture.gd's live-mode wait blocks):

    1. _ready(): run §1's auto-attach-else-spawn discovery (try SR_PORT-or-default-9876 connect, ~500ms timeout; on refusal, spawn a server child via the server_process.gd pattern with --port 0 and parse LISTENING:{port}). Configure SimBridge accordingly (server_path set for spawn, unset + resolved attach port for attach).
    2. Call SimBridge.connect_to_sim() using the Reader handshake variant (§2's role: ConnectionRole = Reader on StartupMessage), not the character-startup path main.gd uses. This is the one place atlas_standalone.gd's connect call diverges from main.gd's.
    3. Poll SimBridge.state until CONNECTED — same ConnectionState enum, same polling shape as visual_capture.gd's live-mode wait, minus the fixed-frame-count settle (a real window can just await the signal instead of budgeting frames for a screenshot).
    4. On connect: HudGroups.open_app("implant/map"). There is no gameplay group ever registered in this scene, so D-170's gameplay/implant mutual-exclusivity degenerates harmlessly to "implant is always the sole active exclusive group" — no HudGroups code changes needed; the invariant it enforces (only one of gameplay/implant visible) is trivially satisfied when gameplay never registers anything.
    5. ImplantRegistry.instantiate_all(self) — the same call hud.gd._ready() makes in the normal game — populates every installed implant app (Atlas + Economics both come along for free; Economics degrades gracefully since it's reachable but not the entry point, and read-only holds for it too automatically, since it rides the same Reader connection).
    6. The Atlas's own KEY_M/KEY_ESCAPE handling (atlas_app.gd) currently calls HudGroups.close_app() on M/Escape from the top-level "reach" screen, which would leave the standalone window showing a blank Control with nothing to fall back to (there is no gameplay layer). Two options, left for the implementation ticket to pick: (a) atlas_standalone.gd intercepts the close and either quits the app or re-opens implant/map instead of demoting to a nonexistent gameplay layer, or (b) atlas_app.gd gains a standalone_mode flag that no-ops the close-to-gameplay branch. (a) is recommended — it does not touch atlas_app.gd at all, keeping the in-game and standalone Atlas byte-identical.

    Automatic extension + lockout flag (added mid-implementation per Jeroen, 2026-07-17): the companion is a generic implant HOST, not an Atlas launcher — every implant app registered with ImplantRegistry is automatically available in the standalone shell, so future Atlas screens and future implant apps (the implant/browser app of §4, wiki/GTTR, economics dashboards, whatever comes) extend the external app with zero companion-side work. The lockout is the exception, not the rule: the implant app manifest (the app.tres resource each app already carries) gains available_in_companion: bool = true — an opt-out flag set false only for apps that structurally cannot work without a playing character/gameplay context. The standalone shell consults the flag at instantiate_all/app-open time (flagged-off apps are not instantiated and not offered in any app-switching surface); the in-game implant ignores the flag entirely — it exists only for the companion host. Apps needing finer granularity may gate individual screens on a standalone-mode query, but v1 needs only the app-level flag (both current apps — Atlas and Economics — are read-only-safe and stay true). Naming note: Jeroen's suggested name was availableInAtlasApp; recorded here as available_in_companion (snake_case per GDScript convention, and "companion" avoids colliding with client/ui/implant/apps/atlas/ — the Atlas is itself one of the hosted apps, not the host).

    Window title/branding: atlas_standalone.tscn sets its own window title via DisplayServer.window_set_title() in _ready() (e.g. "The Settled Reach — Atlas"), since project.godot's shared config/name would otherwise make the standalone window read identically to the main game window in the taskbar/alt-tab — a second-monitor companion needs to be visually distinguishable at a glance. This is the only project-level Godot config touched; no run/main_scene override, no export preset changes in this ticket.

    Dev launch (un-exported): make atlas runs $(GODOT) --path client client/scenes/atlas_standalone.tscn — Godot accepts an explicit scene path as a positional argument, overriding run/main_scene for that invocation only (the same mechanism godot --path client -s res://tests/visual_capture.gd already uses to run a non-default entry script). No project.godot edit needed; run/main_scene stays main_menu.tscn for the normal game. Since discovery (§1) is auto-attach-else-spawn at runtime, make atlas itself stays a single simple target — it does not need make game's explicit background-cargo run + sleep + launch + make stop choreography, because atlas_standalone.gd owns its own spawn decision and child-process lifecycle internally (§1/§2). make atlas is just: build client, launch it.

    Exportable later: because this is a genuine second scene (not a runtime-detected mode), it is also a legitimate Godot export preset target down the line — godot --export-release "Atlas" build/atlas/... with atlas_standalone.tscn as that preset's main scene. Nothing in this design blocks that; it is out of scope for this ticket (no export preset is added now) but the architecture does not need to change to support it later. This directly serves purpose (2) in the epic: "remains available as a LEGITIMATE player-facing pattern post-release." One caveat inherited from §2/§6, flagged here because it bears on export/distribution specifically: the default bind (127.0.0.1:9876) is loopback-only, and loopback is the entire security boundary the read-only guarantee currently leans on. A same-machine export is safe as designed. A LAN companion (a genuinely different second monitor — a different physical machine on the same network) is a different, larger feature: it requires the non-default-bind + real-auth work §6 already flags as a prerequisite for TradingReader, and arguably for Reader too once "same machine" stops holding. Not built now; recorded so nobody exports this to a non-loopback bind by default.

    (4) DATA BROWSER — "scan ALL database data."

    Browse surface. A new implant app, implant/browser (or folded into the Atlas as a new top-level screen reachable from "reach" — the implementation ticket picks the exact navigation entry point; recorded here as its own app since the entity set is broader than geography and doesn't naturally nest under the Atlas's reach→system→planet→regional drill-down), composed entirely from the existing D-169 component library (ImplantPanel/ImplantHeader/ImplantDataRow/ImplantTextBlock/ImplantSeparator) — no new UI primitives needed, this is exactly the list+detail pattern the library was built for. Two screen shapes, reused per entity kind:

    • Index screen — a scrollable ImplantDataRow list (name + one or two summary columns), filterable/searchable by name, one per entity kind.
    • Detail screen — an ImplantPanel of ImplantDataRows (and ImplantTextBlock for free text / descriptions) showing every column the wire response carries for that one entity, nav.push()-reachable from the index row.

    v1 entity scope (deliberately narrow, honest about phase). systems-schema.sql's table set spans registry data (systems, bodies, stations, corporations, commodities, trait templates) and cascade-derived atlas geometry (atlas_cities, atlas_roads, atlas_rivers, atlas_province_boundaries…) that is Phase-4-in-progress and per-body-optional (populated only once a body's generation cascade has run — the same AtlasLayerStatus::Ready-vs-Pending gating the Atlas's regional screen already handles). v1 ships registry-tier screens only — tables that exist, are fully populated, and are stable regardless of cascade progress:

    1. Star systems (star_systems + system_economy/system_factions/system_culture folded into one detail screen — small tables, natural 1:1 join)
    2. Bodies (bodies, filterable by system — the existing SystemScreen's body list is the UI precedent)
    3. Stations (stations)
    4. Corporations (corporations + corp_presence/corp_financial_state folded in)
    5. Commodities (commodities + production_chains/chain_inputs)
    6. Trait catalog (trait_templates) — Jeroen's brief names this explicitly

    Deliberately excluded from v1, left for a follow-up ticket once Phase 4 cascade tables stabilize: atlas_cities/atlas_roads/atlas_railroads/atlas_pois/atlas_rivers/atlas_oceans/atlas_mountain_ranges/atlas_province_boundaries (cascade-derived, per-body, partially populated mid-Phase-4 — a browser screen over a table that's empty for most bodies today is not a useful v1 screen) and corp_lifecycle_events/system_history/historical_events (event-log tables, better served by a future timeline/log UI shape than list+detail). The six-entity v1 list above is the full set of "always fully populated, one row = one interesting thing" registry tables; everything else waits.

    Data path — wire-only, extending the existing proxy pattern (no local SQLite read). Two options exist in principle: (a) the client opens server/data/systems.db directly (it already ships in the client build — instant, complete, works even with no server running), or (b) every browser screen is a wire request/response pair through the bridge, exactly like StarMapRequest/CityNamesRequest today. This record picks (b), unambiguously, for two independent reasons:

    • Pragmatic: Godot has no built-in SQLite. client/addons/ holds exactly two addons today (gdUnit4, messagepack) — no SQLite driver exists anywhere in the client. Reading systems.db locally would mean adding a third-party GDExtension (e.g. godot-sqlite) as a new dependency. D-020 explicitly rejected GDExtension for the core client-server bridge specifically to avoid "gdext pre-1.0 API churn, Godot version ABI breakage, FFI thread safety" — introducing a GDExtension now, for a companion-app convenience, reopens exactly the risk category D-020 spent effort closing. This is not a hard architectural violation (D-020 scoped its GDExtension rejection to the simulation bridge, not "any GDExtension ever") but it is the wrong trade for a feature whose entire value proposition is "lightweight."
    • Architectural: the codebase already made this call, recently, on purpose. T-949 migrated the star map — 100% static, authored, non-per-body data — off a direct client-side FileAccess read of star_map_data.json and onto a wire request, specifically because "the client never reads game data files directly" (D-010 boundary framing). That decision already resolved the "but this data is static, why not read it locally" question a companion-app data browser would otherwise re-litigate — T-949 answered it for star-map data, and there is no principled reason star_systems/bodies/corporations are different in kind. One data-access rule for the whole client (server-authoritative reads, always through the bridge) is simpler to reason about and extend than "static tables read locally, dynamic tables read over the wire, judgment call per table" — especially since today's "fully static" table can grow a cascade-dynamic column later (corp_financial_state already looks time-varying).

    So: static registry data is NOT read locally — it goes through the SAME wire path as everything else, because the server is already the sole owner of systems.db access and that ownership is a feature (single source of truth, single enforcement point for D-010 boundaries), not a latency cost worth working around. The "instant, complete, offline-capable" properties Jeroen's brief names as motivations are achieved a different way: attach-mode's "instant" comes from a fast local TCP round-trip (sub-millisecond on loopback — the ~1-5ms serialization cost D-020 already accepted is not the bottleneck for a data browser that isn't rendering 60fps), and "complete" comes from reading the same open handle the running server already has, with no second file-format copy to keep in sync.

    Server-side extension (the actual new work). One new proxy, following atlas_data_proxy.rs's established shape: per-entity-kind request types (mirroring StarMapRequest's "thin, one dataset" shape), not a generic SQL-ish query surface — a generic query API is a much bigger security/complexity surface for a v1 feature that only needs six fixed table shapes, and is explicitly rejected for that reason. Each handler is a rusqlite read against systems.db using the exact CityContextReader::open()-style pattern already proven server-side — the server already has this dependency and this pattern; this ticket is "write five more read functions," not "introduce a new capability."

    D-010 boundary note — the "no character" framing, reinforced by §2. A reader connection has no character (§2: it receives no ObserverSnapshot at all), so there is no per-character knowledge/fog to bound against — this is a SIMPLER boundary case than the normal player observation, not a harder one. What the Reader class is allowed to see is bounded by connection class, not character knowledge state, and §2 already proved the six v1 entities pass that bar independently (their handlers take no observer/character parameter — they were install-static/world-public before this record, not a carve-out invented for readers). The one thing explicitly ruled OUT of v1 scope: browsing a specific save's diverged dynamic state (an economy snapshot that has drifted from the shared baseline via play, one corp's post-game-start financial trajectory) is information a Reader attached to someone else's playthrough should not casually have. v1's six entities are registry-tier (identical across all saves, cascade-independent), so this doesn't bite yet — it becomes live the moment a market-state screen is added (§6) or a cascade-tier table (the excluded list above) is browsed against an attach-mode connection to someone else's running game. Flagged here so whichever follow-up ticket adds those screens re-reads this paragraph first.

    (5) SAVE/LOAD SEAM — recorded hook, not built.

    Saves are Phase 5+ (per the cascade); meta.schema_version (T-888) already carries the lineage-migration seam on the DB side, but no save file format or save/load UI exists yet anywhere in the client. This record fixes WHERE the Atlas's save/load interaction slots in, once it exists, without building any of it:

    • Attach-mode has no save/load UI at all — it inherits whatever world the attached game session is running, save/load included; the Atlas is a read-only window onto a live session, and "loading a different save" from inside an attached reader is a contradiction (that's just attaching elsewhere, not loading). No hook needed here.
    • Spawn-mode v1 (this ticket's actual scope) offers seed selection only at launch — the standalone app's own minimal startup screen (part of atlas_standalone.tscn, shown before the HudGroups.open_app("implant/map") call in the boot sequence above) asks for a world seed the same way character_creation.tscn/GameState.world_seed does today for a normal new game, then spawns a Reader-role server against that seed via §1's StartupMessage.world_seed (not a CLI flag — §1 already fixed this as the single source of truth for spawn-mode seeding).
    • Spawn-mode's future save picker slots into that SAME pre-Atlas startup screen, as a second choice alongside "new seed": once a save file format exists, the startup screen gains a "load existing save" option that spawns the server and immediately issues whatever the (then-existing) LoadGame flow is — the exact wire action main.gd's _dispatch_pending_load() already sends today (InputMapper.Action.LOAD_GAMESimBridge.send_input()), reused verbatim. The Reader-role server applies the load exactly as a normal server does, then simply never accepts player inputs afterward (§2's enforcement doesn't care how the world was populated — it gates on connection role, not on world provenance). No new save/load mechanism is invented for the Atlas — it is a consumer of whatever Phase 5+ builds, hooked in at exactly one point (the pre-launch startup screen), recorded now so future work knows the seam exists and where.

    (6) FUTURE TRADING — what changes when the app gains write verbs.

    Designing the seam now, not implementing it.

    Per-verb allowlist via a widened role, not a new connection type. The ConnectionRole enum from §2 extends to Player | Reader | TradingReader. TradingReader is strictly additive to Reader — everything a Reader gets, plus a narrow, explicitly-enumerated PlayerAction allowlist for trade verbs — never a replacement. This keeps Player ⊇ TradingReader ⊇ Reader a strict superset relationship, so widening later only adds match arms at the same enforcement point (§2's role-gated input handling) and never touches the Reader path at all — the base read-only guarantee this whole record establishes is structurally unaffected by trading being added later.

    Idempotency/ordering. Trade commands travel through the existing tick-stamped PlayerInput{tick, action} envelope (not a bespoke unstamped request), so ordering against the Player's own concurrent actions falls out of the existing InputQueue ordering for free — no new sequencing mechanism needed. Unlike movement (visibly-wrong-but-harmless if accidentally duplicated), a duplicated trade command is a real bug class (a double-sell). Recommend a client-generated idempotency token + a short server-side dedup window — cheap and bounded for a localhost, single-user, low-frequency command class. Rejected alternative: relying on TCP's delivery guarantee alone — that only catches transport-level duplication, not the actual threat (a user double-clicking through a UI hiccup and generating two distinct, both-valid application-level messages).

    Identity/auth — the assumption that must stay visible. Same machine, same user, no auth — loopback-only IS the security boundary (the server already effectively enforces this via the 127.0.0.1:9876 default bind). This reasoning breaks the instant SR_ADDR or any non-default bind lets a TradingReader connect from a different machine — which is exactly D-009's actual multiplayer future, or even this record's own §3 export-later note about a genuinely-remote second-monitor companion. The moment loopback-only stops holding, real auth (at minimum a session-minted token) is required before TradingReader widens beyond it. This assumption is recorded here explicitly so it is visible to whoever eventually picks up a LAN-companion or remote-trading idea, rather than being silently inherited as "it already works, why would auth be needed."

  • Rationale: Three independent product goals (Jeroen's brief) converge on one architecture cleanly: a dev data-inspection surface (purpose 1), a legitimate post-release second-monitor pattern (purpose 2), and an attach-or-spawn reader with a save seam (purpose 3) all want the SAME thing underneath — an implant UI that can run without a player. Building that once (dedicated entry scene + ConnectionRole-gated reader connection + wire-only data access) serves all three simultaneously; there is no version of this where the dev tool and the shipped companion app are different pieces of software. The wire-only data path is the one design choice that could have gone either way and didn't — it is deliberately consistent with T-949's precedent rather than reopening it, and it avoids a new GDExtension dependency for a "lightweight" feature. The app-shell choice (dedicated scene over a main.tscn flag) keeps blast radius smallest: the standalone Atlas cannot regress player-only code paths because it never touches them. The Reader/TradingReader superset relationship (§2/§6) means the read-only guarantee this record exists to make is never at risk from the later trading feature — it can only be extended, never weakened, by construction.

  • Implementation: New ticket tree under T-1128 (epic) — proposed tree delivered in the T-1129 design-pass report, not filed here (tree ownership: team lead). Client: client/scenes/atlas_standalone.tscn + atlas_standalone.gd, a new implant/browser app (or Atlas-nested screen) under client/ui/implant/apps/, Makefile atlas target. Server: ConnectionRole on StartupMessage, the accept-loop + BridgeResource multi-connection change, per-connection response tagging, and the Player-only ServerRunning/snapshot-targeting fixes (§2) in server/src/bridge/; a new BrowseRequest/BrowseResponse proxy in server/src/atlas/ (sibling to atlas_data_proxy.rs, §4). No systems-schema.sql changes required — v1's six entity screens read existing tables as-is.

  • Cross-reference: D-010 (client-server boundary — the wire-only data-path rationale; "no character" reader framing; the adversarial-client enforcement stance), D-009 (multiplayer design-for-it baseline — this record's 0-1 Player + 0-N Reader model is explicitly NOT that larger ambition), D-020 (subprocess/IPC over GDExtension — why local SQLite is rejected; SimBridge/bridge trait extension point), D-169 (implant component library — the data browser is composed entirely from existing components), D-170 (HudGroups — the standalone scene's degenerate single-group case), D-192 (no lockstep negotiation precedent — why ConnectionRole is a StartupMessage field, not a new pre-handshake message). T-949 (star map wire-migration precedent this record extends rather than re-litigates), T-888 (schema_version save lineage — the seam §5 hooks into once it exists). Tickets: T-1128 (epic), T-1129 (this design pass).

  • Raised by: Jeroen (2026-07-17, brief: standalone Atlas via make atlas, read-only reader against the running game or its own spawned server, save/load interaction seam, future trading). Designed by Tyre (architecture lead, §3–§5, integration, record author) + Oscar (§1, §2, §6 — connection model, reader protocol, trading seam).

  • Dissent: None recorded at design time. Two judgment calls flagged for confirmation rather than dissent, both revisable by the implementation ticket without touching the rest of this record: (a) §4's choice to make the data browser a separate implant/browser app rather than a new top-level screen nested inside the existing Atlas — the six v1 entity kinds don't share the Atlas's geographic drill-down shape, so a separate app was chosen for navigational clarity (the Atlas stays "the map," the browser is "the database") but this is a naming/IA call, not architecture; (b) §3's atlas_app.gd-unmodified close-handling option (a) vs. a standalone_mode flag option (b) — recommended but not forced.


D-255: Body Map Viewer — stepped Atlas render architecture (supersedes the T-1143 continuous-ladder mechanism)

  • Date: 2026-07-24

  • Decision: The Atlas map handler is rebuilt on three locked premises: (1) content determination is server-side — the CPU/Rust server answers "what is at this world coordinate at this zoom step" as a per-step data canvas; the client never invents geometry (GPU is presentation only). (2) The client is a map-art function — it colorizes, styles, and annotates the server's canvas via a render-to-texture terrain layer (texel-exact, drawn at the display ratio) plus an unscaled screen-space sibling layer for vector annotations. (3) Zoom is stepped — discrete gridunit-spacing levels, one server-canvas fetch per crossed step boundary, cursor-anchored, with edge-scroll pan and a hard full-zoom-out reset to the canonical Global (rung-0) body-surface frame. This dissolves the T-1143 error class (the client compensating for a zoom-scaled _canvas.scale_zs/_zs_stroke/_zs_ring_radius, the line-rasterizer floor) at the root: there is no zoom-scaled canvas anymore, so per-call compensation cannot occur.

    (a) The step ladder — six levels: the Global map opener + five fixed metre rungs. Global (rung 0, body-surface view) → Region (204.8 km, rung 1) → District (2,048 m, rung 2) → Quarter (512 m, rung 3) → Block (128 m, rung 4) → Chunk (64 m, rung 5, deepest) — the "Option D" ladder, every fixed rung measured. Rung 0 (Global) is the map opener and is variable-extent, not a fixed metre rung (Jeroen, post-briefing correction): it is the whole body surface at one gridunit per region, so its canvas is the body's region grid — variable per body (~19K gridunits on an Earth-class body; the D-243 elastic seam made visible, region count floats per body_radius_km), and it is the sole always-kept tier. Region (rung 1) is the largest fixed-size rung — viewport-sized like every fixed rung below it, evictable, not the canonical tier. Every fixed rung's gridunit spacing is one of D-243's absolute-metre rungs, never viewport-derived. Tile/voxel (1 m) is dropped from the Atlas ladder (a 10-px-per-tile full-screen view is ~192×108 m of ground — in-world viewport content, Phase-5's scope, not an Atlas map); voxel is reserved for Phase-5 in-world rendering. Deepest bottom-out rule: 1 screen px per 64 m gridunit, no magnification margin — the old "10 px per tile" margin does not carry over (it existed only because a 1 m unit is sub-readable at 1×1; a 64 m gridunit is already a legible map feature, so chunk uses the plain fixed-canvas budget with no display-ratio-sized exception). The display ratio (screen-px per gridunit, 1×1 ideal → ≥5×5 acceptable) is a free client-side presentation parameter, decoupled from spacing (see the D-243 gridunit amendment).

    (b) Canvas policy — rung 0 canonical/always-kept, every fixed rung viewport-sized. Rung 0 (Global) is the sole canonical, always-keep tier — the body-surface region-grid canvas, D-226(d)-legal by construction (at one gridunit per region it is coarser than the region grid, never a sub-region metre-resolution whole-body derivation). Every fixed rung — Region through Chunk — is viewport-sized and evictable, bounded to a fixed canvas-pixel budget (not a per-monitor echo), which keeps the deep ladder legal under D-226(d) per request by construction. The D-226(d) prohibition is stated as a per-request / per-derivation constraint, not aggregate-storage (D-226 2026-07-23 amendment); the client-cache accumulation path is closed by a per-body deep-rung retention cap (below), not by improbability.

    (c) Wire — a tagged-envelope carrier, executing D-225's deferred migration. The step-canvas payload cannot ride the legacy district_window windowed carrier (measured 21×–563× over the ~30 KB windowed-payload reference across the three canvas sizes — a cell-count gap of two-to-three orders of magnitude no encoding closes), so it rides a new tagged carrier: a StepCanvasRequest inbound variant carrying a required marker field (step_canvas: bool, extending the existing star_map/city_names/browse discriminated-shape ShapeProbe pattern) plus a dedicated StepCanvasResponse outbound message (not a field on AtlasLayerResponse, per the D-226 §2 windowed-family ceiling, which is re-scoped to govern the legacy carrier only). One flat tagged response carries all fields together: dense classification fields ship PNG-per-field (the measured smallest-and-fastest encoding); sparse feature lists (courses, cliffs) stay MessagePack-native. Both the Global opener (rung 0) and every fixed rung ride this one carrier (rung 0's variable extent is a field value, not a different message shape); the legacy district_window survives byte-unchanged for its existing consumer until that consumer is replaced. This discharges D-225's 2026-06-12 tagged-envelope deferral.

    (d) Cache — three tiers, determinism makes every geometry tier a pure cache. Cheapest-first: client in-memory LRU → client disk-backed FileAccess store (self-cleaning TTL for sim-state entries) → server (D-203-shaped resident global tier + TTL(detail, time, distance) for finer rungs). Determinism (D-227) makes every geometry tier a pure cache, never a source of truth (evict → recompute, byte-identical, always valid). Staleness and storage are distinct eviction axes (D-227 amendment): geometry never goes stale (storage-evicted only); sim-state planes (D-253 frozen/flooded) carry a real short staleness TTL. The server global (rung-0) tier costs ~8.85 MB PNG-encoded (27.61 MB raw, SI decimal) across the entire real ~267-body population (measured: 4,825,615 total region-grid cells, avg ~18,073/body; Earth-class reference 195×97 = 18,915 — this supersedes an earlier ~174 MB figure that mis-priced a fixed 4K-class Region-spacing canvas per body, ~440× too many cells), and derives in ~1621 ms per body single-thread (populated lazily once per body on first Atlas-open via the D-206 background queue; the ~4.0 s all-267-summed figure is a sanity ceiling never paid synchronously). At ~8.85 MB the keep-always tier is trivially process-resident. Two client-cache hardening requirements: (i) a per-body deep-rung retention cap (max resident chunk/block-spacing tile count or disk quota per body, independent of the rung-0 retention floor) — the structural ceiling closing the D-226(d) accumulation gap against a systematic exhaustive pan (most plausibly the D-226 item-(4) AtlasAgentInterface QA sweep); (ii) every persistent cache entry carries a schema/version tag, mismatch = cache miss + re-fetch, never decode (the one place D-192's co-ship guarantee does not reach — handled in the D-227 amendment).

    (e) Determinism boundary — what the client may interpolate. The client may interpolate only within a closed, server-supplied input set — two arrived textures (step-cross morph/tween, cosmetic only), a finite list of wire-carried control points (spline-fit river/road curves through server-given stations), or texture-to-viewport resize (display-ratio scaling). It may never invent a sample outside that set (client-side upsampling of terrain detail — the T-1143 error class) nor smooth across a step/rung-truncation/cliff boundary as if continuous. This generalizes the T-1170 course-invention discipline: derivation stays CPU/Rust server-side, GPU is presentation only.

    (f) Seed-chaining — a cache-accelerated pure function. A finer step may consume a coarser step's resolved output as an optimization (reading a resident coarser canvas) with a derive-fresh fallback — staying inside derive-don't-store because the coarser value is itself re-derivable to identical bytes (full ruling in the D-227 amendment). What is consumed is the coarser rung's continuous primitive baseline, never its resolved categorical classification; a byte-identical cache-hit==cache-miss determinism test is the correctness gate; the benched costs are the cache-cold worst-case ceiling.

  • Rationale: The shipped model compensated for a zoom-scaled client canvas at every draw call — an "invisible until it's wrong" error class (T-1143's Lendel failure) rooted in the client re-drawing derived geometry inside a _canvas.scale node. Relocating content determination to the server, making the client a pure map-art function, and making zoom stepped eliminates that class at the root: sizes are texel-exact by construction, the render is auditable (the data canvas is inspectable server-side, decoupled from draw), and the one hard part feared going in — reconciling derived geometry with a zoom-scaled canvas — stops existing because there is no zoom-scaled canvas. Every pre-workshop cost gate came back GO with no extrapolation (hydrology 0.70.8 s all 273 bodies; row-chunked derive flat ~190220 ns/cell from 330K to 8.3M cells; PNG-per-field the smallest-and-fastest encoding; texture upload 0.034.6 ms; deepest chunk canvas ~1.7 s parallel). The tagged-envelope migration is the one genuine chunk of new work — challenging but doable, executing D-225's planned deferral rather than inventing one; at the code level Dudley's read of bridge/mod.rs puts it at "doable" (one Inbound variant + one SimBridge method on a pattern proven five times), with the harder work in the client rebuild.

  • Raised by: Jeroen (the design outline: server-determines-content, client-draws-art, stepped zoom, lazy/late compute, the tile-drop and Global/Region rung-identity corrections, seed-chaining ruling — body-map-viewer workshop, 2026-07-23/24). Designed by Tyre (governance/architecture lead, record author) + Dudley (server derivation, wire/serving, hydrology, cache costs) + Araminta (named-feature encoding, payload schema, lakes) + Stig (client component, cache store, measurement ⑥) + Troblum (adversarial pass — D-226(d) accumulation, unit note, cache version tag).

  • Implementation: Phase 4 (epic T-750). Implementation chain (measurement-informed): server step-canvas serving via the tagged envelope (the migration), the client two-layer component (RTT terrain + unscaled screen-space annotations) replacing the _canvas.scale model, the three-tier cache, the two lake tickets (morphology-sourcing from HydrologyResult; basin-outlet→D8 wiring, pre-cleared by T-1170 Ruling 7b). Ticket reconciliations: T-1176 (this design discussion) closes as delivered; T-1158 (viewer decomposition) cancelled — the canonical-frame machinery it would extract changes shape under stepped zoom; T-1175 (nature polish) re-scoped onto the new annotation layer (c1 = CPU-first, its measurement-⑥ blocker discharged); T-1157 (visual-capture goldens) re-scoped as the stepped mechanism's verification story; T-1174 (batch-vs-window derive divergence) kept, priority raised (the envelope depends on batch/window derive agreeing); T-1152/T-1153 stay done, their continuous-zoom + coverage-walk + Region-tile-mosaic code and test suites retire with the _canvas.scale model. Measurement appendix, per-agent positions, and the full deprecation sweep: docs/workshops/body-map-viewer/.

  • Cross-reference: D-166 (development cascade — the 2026-07-23 corollary repoint; this is the stepped successor to the continuous ladder), D-226 (live-pause harness — the windowed-family ceiling re-scoped, item-(d) per-request + floor-partial-restore, the T-1143 rulings this supersedes; item-(4) AtlasAgentInterface the cache cap protects), D-227 (derive-don't-store — the four cache/derive amendments: TTL-split, version-tag, seed-chaining, lakes; the discipline every cache tier obeys), D-243 (spatial scale ladder — the rungs gridunit snaps to, the Global rung-0 elastic-seam view, the gridunit vocabulary), D-225 (layer-stream proxy — the tagged-envelope deferral this discharges), D-192 (no version handshake — persistent-cache boundary), D-253 (region transient state — the sim-state planes the map-time split carries), D-010 (determinism — server-owns-derivation, client-art-function; the cache-accelerated-pure-function and byte-identical-paths tests), D-169/D-170 (implant UI — the map component lives in the implant Atlas app, occludes gameplay via HudGroups), D-239 §6 (the frozen 17-zone MorphologyZone vocabulary the lake fix reuses, never widens). Tickets: T-1176 (design), T-1177/T-1178/T-1179/T-1180 (measurements ①–⑤), the reconciled T-1152/T-1153/T-1157/T-1158/T-1174/T-1175 above.

  • Dissent: None.

  • Amendment (2026-07-25, T-1192 / PR #205 — rung-0 integer viewport-fit display ratio): Premise (2)'s "texel-exact, drawn at the display ratio" is clarified for rung 0: the Global opener's display ratio is viewport-fitted per body — the largest integer screen-px per gridunit that fits the legend-reserved viewport on both axes, floored at 1×1 (an over-viewport canvas draws at native ratio and crops/pans like every fixed rung) — rather than a fixed tunable. Texel-exactness is preserved by construction (every gridunit an exact integer pixel square); the letterbox remainder centres the canvas; fixed rungs keep their tuned constant display ratios. Non-integer resting-state scaling remains unsanctioned — a PR #205 review finding (Tyre) removed an implemented fractional-fit branch whose comments cited this record for an exception it does not contain; the D-227 2026-07-23 corollary's "only sanctioned display-time scaling" (the transient between-step magnification) is joined only by this integer viewport fit, nothing else.


D-256: Canonical sampling convention — one absolute-metre derive core; the batch layer is a survey raster

  • Date: 2026-07-24

  • Decision: Resolves T-1174. The "batch vs window derive paths sample different world positions for the same DistrictPos" divergence is ruled a namespace collision, not a sampling-offset bug: batch DistrictPos was an index on a 64×32 heightmap pseudo-grid (8×8 working-pixel blocks, pole-anchored, ~1/64 circumference per cell on a real body, sampled at block centre), while window DistrictPos is the true D-243 2,048 m grid (equator/lon-0 anchored, sampled at cell origin) — one (i32, i32) type carrying two coordinate systems. Underneath it the investigation surfaced two latent same-position divergences: the batch path computed three inconsistent latitudes for one cell (row-index linear, pixel_to_world_m implied, and the noise-field anchor), and keyed its region-climate baseline on pseudo-grid indices — which collapse the entire body onto region (0,0)'s baseline (a live residue of the Q-110 failure D-243 was written to kill). The ruling, six parts:

    (a) Canonical convention. derive(seed, absolute world metres) — the derive_at_metres / derive_orbital_at_metres family is the only code that answers "what is at this world coordinate". DistrictPos canonically means the true D-243 2,048 m grid, origin-corner quantization (dp * DISTRICT_M, equator/lon-0 anchor), per the existing wrapper-contract tests; fractional positions stay legal. This is the convention every position-pinning test, every bench suite, and the D-255 step-canvas costing already assume — no test anywhere pinned the batch position.

    (b) The survey raster. The batch 64×32 pseudo-grid is re-scoped as a survey raster — the coarse planning pass for L2/L3 (settlement placement inputs, believability, overlays, skeleton dispatch context). Its key becomes a real newtype (SurveyCellPos, not a type alias) so the compiler rejects cross-namespace passing — the collision's root cause is two semantically different (i32,i32) grids sharing a bare tuple type. Vocabulary guard: "survey" is a role name for a coarse planning raster, not a spatial rung (mirroring D-255's gridunit-is-not-a-rung language); the D-243 ladder gains no level. The only bridge between namespaces is one explicit, testable function: survey cell → its centre world-metres (the pixel-index-midpoint centre 8rx+3.5 is kept — it is the geometrically correct midpoint of the 8-point bilinear sample lattice, not an error).

    (c) One derive core. derive_district_profile becomes a thin wrapper over the shared metres-addressable core at the survey-cell-centre world position — one derive core, two position sets. This auto-fixes both latent divergences (one inverse mapping computed once is definitionally self-consistent; region keying inherits the core's true-metre floor-divide, giving latitude-graded climate instead of the body-uniform artifact). Two preservation requirements are binding: (1) basin_direction — an inert pass-through field (proven: nothing in the derivation reads it) — is preserved via post-call field override with the true L1 D8 value; the window path keeps its documented North fallback. (2) The riparian verdict must come from near_perennial_water_at (on-demand course invention) exactly as today — a naive wrapper passing the empty course slice would silently regress every riverside cell to near_perennial_water = false and flip vegetation_class; the fix is an extracted private core parameterized on the pre-computed riparian boolean, leaving derive_at_metres's public signature and byte-behavior untouched (window goldens must stay green unchanged).

    (d) Exact-position judgments. Terrain judgments attached to point features (the quarter-skeleton context's morphology_zone, today a survey-map lookup keyed off the settlement position) move to an exact-position derive at the feature's world metres, server-side, resolved where the terrain cache is reachable — a survey cell spans ~hundreds of km and its centre is not the settlement's terrain. This closes the annotation-vs-canvas disagreement class (D-255's Lendel failure class) before T-1181/T-1182 draw batch-judged annotations onto window-derived canvases.

    (e) Fencing for D-255. Step canvases (all six rungs) derive exclusively via the canonical family; survey products are never canvas sources. The survey raster is a planning input — D-227-legal exactly like every cache tier (evict → recompute → byte-identical), never a second source of terrain truth.

    (f) Deferrals, tripwires discharged. The collapsed LayerRegionOutput/region_grid rebuild defers to T-1181 rung-0 (verified: its sole production reader is the build_region_grid body-view overlay; no consumer reads it against DistrictProfile climate, so no new disagreement enters this ruling's blast radius — the overlay is visibly stale until the rung-0 Global canvas replaces it, accepted and noted). Voxel-carrier rewiring defers to Phase 5 (the carrier inherits self-consistent profiles from (c) for free, per the D-166 same-derivation amendment).

    Known costs (accepted): batch-layer values change (latitude fix + true-region climate) → believability goldens regen and map overlays legitimately diff against screenshot baselines. Settlement and road positions do not move — placement positions source from Layer-1 attractor search, untouched; the T-1075 install bake is keyed by DB row identity, orthogonal on both axes. D-245 believability gates need no threshold changes — every gate rewards variance, which this fix strictly increases (the same direction as the T-1080 moisture amendment, one layer up).

  • Rationale: T-1181's acceptance gate (cache-hit == cache-miss, byte-exact, every rung) and D-255(f)'s cache-accelerated pure function require a sample's identity to be (seed, position) and nothing else; two paths resolving different positions for the same nominal cell break that identity at the root. The evidence for the canonical choice was uniform (all position-pinning tests, all five bench suites, the D-255 measurements, and the Phase-5 streaming amendment all sit on the absolute-metre family), and the unification deletes reconciliation code rather than adding it — one inverse mapping replaces three latitude conventions and a collapsed climate key. The newtype is the structural fix that makes the class un-reintroducible; a rename alone would relabel the footgun without disarming it.

  • Raised by: Found by Dudley (T-1168 integration testing, the layer_proxy design note). Ruling drafted from a four-reader code sweep; stress-tested by Tyre (APPROVE-WITH-CHANGES: basin preservation binding, exact-position judgments pulled into scope, newtype over alias, region-consumer tripwire, vocabulary guard) and Dudley (FEASIBLE-WITH-CHANGES: inert-basin proof, riparian empty-slice regression + extracted-core shape, fallout inventory, T-1075 orthogonality, sample-lattice centre ruling).

  • Implementation: T-1174 (single PR: core extraction + wrapper + SurveyCellPos newtype + skeleton exact-position judgment + strengthened cross-path agreement test + golden regen). Successor obligations riding existing tickets: T-1181 (rung-0 replaces the collapsed region layer), Phase 5 (voxel carrier). RegionPos shares the bare-tuple hazard — noted, out of scope here.

  • Cross-reference: D-255 (the dependency that raised this — envelope needs derive agreement; the Lendel failure class (d) closes), D-243 (the true grid, the Q-110 residue this clears, vocabulary lock honoured), D-227 (derive-don't-store — the survey raster as planning cache; the seed-chaining purity this repairs), D-239 (§8/§10 basin preservation, §2 climate-as-district-tier-field made body-honest, frozen vocabulary untouched), D-245 (believability — the body-uniform climate artifact this removes), D-166 (Phase-5 same-derivation amendment this makes satisfiable). Tickets: T-1174 (this ruling), T-1181 (gate consumer), T-1168/T-1170 (where the quirk surfaced and the invariants this strengthens).

  • Dissent: None.


D-257: Environment props share the character toon shading treatment; minimal-PBR carve-out for glazing

  • Date: 2026-07-25
  • Decision: Environment props and furniture (the D-235 ObjectTag vocabulary's rendered assets — walls, roofs, facades, street furniture, and freestanding furniture/prop .glbs produced via /glb-gen) render with the same toon shader family already used for characters (toon.gdshader / toon_masked.gdshader, per client/assets/characters/shaders/), not a distinct PBR material response. The glb-gen postprocess convention of forcing roughness=1.0/specular=0.0 ("for toon compatibility," postprocess_glb.py::setup_materials()) is confirmed as the correct default for this vocabulary's bulk.
    • Named carve-out — glass and polished/mirror metal. The ratified glazing tokens — glass_curtain_wall (D-235 wall axis) and industrial_glazing (D-235 facade axis) — plus any deliberately reflective/polished-metal prop (chrome fixture, mirror, glossy display case), get a minimal-PBR material layered over the toon base: roughness in the 0.050.2 range with non-zero specular/metallic, not a full PBR stack (no environment-map reflections, no fresnel-driven rim lighting). The toon shadow-band lighting model still governs the base surface response; only a controlled specular/transparency term is added.
    • This binds all future environment-shader and glb-gen-postprocess work; it is not a per-asset style suggestion.
  • Rationale: Consistency compounds — a station built from one shader family reads as one coherent object; mixing a toon character against a PBR-lit room breaks the "one world" read the instant the player looks at their own hands next to a table (D-043/D-044 visual-hierarchy principles: entities and objects share one legible material language, distinguished by outline weight and saturation tier, not by rendering technology). The postprocess default already assumes this — setup_materials()'s "for toon compatibility" comment was written against the one Trellis pipeline that produces both character and environment .glbs, so sharing the treatment requires no new postprocess fork. The glazing carve-out is functionally forced, not a taste call: a flat-lit roughness=1.0 glass pane reads as a frosted grey panel, which actively misleads the player about sightlines in a game whose core mechanic is occlusion-based perception (D-033's asymmetric-information model depends on the player correctly judging what they can and cannot see through). The carve-out is narrow and named — it closes only the case where toon-only actively lies to the player about transparency, and does not reopen "should props be PBR" generally.
  • Raised by: Araminta (T-1052, delegated art-direction ruling), reviewed by Tyre (PR #211 — flagged the original carve-out's use of retired pre-amendment example tokens precision_glass/smart_facade, corrected here to the ratified D-235 tokens).
  • Implementation: docs/assets/visual/palette.md §2 (practical guidance, cites this record as authority). Applies to all environment-prop material setup from Phase 4 asset production onward; no existing shipped assets to migrate as of this record's date.
  • Cross-reference: D-235 (ratified ObjectTag vocabulary — glass_curtain_wall/industrial_glazing are its tokens, not this record's invention), D-244 (3D objects in-world; establishes the shared Trellis/glb-gen pipeline this record's postprocess argument depends on), D-043/D-044 (visual-hierarchy/"functional warmth" rationale — entity/object/structure share one material language), D-033 (the occlusion/asymmetric-information rationale for the glazing carve-out).
  • Dissent: None.

110 decisions (D-001 through D-256, excluding gaps). Last updated: 2026-07-24 (D-256 — canonical sampling convention: one absolute-metre derive core (derive_at_metres family), batch pseudo-grid re-scoped as SurveyCellPos survey raster (real newtype), thin-wrapper unification fixing the three-latitude inconsistency and the region-(0,0) climate collapse, exact-position feature judgments, step-canvas fencing; discharges the T-1174 blocker on D-255's T-1181 envelope).