--- title: "Tyre Round 1 — RegionHint API, anti-squaring, determinism/perf" workshop: tile-derivation-contract participant: Tyre round: 1 date: 2026-06-07 --- # Tyre Round 1: The Derivation API Contract ## Framing the problem correctly The 78 km → 1 km → 64 m → 1 m span is not a single derivation problem — it is three distinct problems with different failure modes, and conflating them is how you get squaring. My position up front: **each scale boundary needs a different coherence mechanism**, and the `RegionHint` I sketched earlier needs to be split into three typed carriers, one per boundary. The overall structure is: ``` Layer 1 cell (78 km) → RegionProfile (region-scale morphology zone + parameters) RegionProfile → ChunkContext (64 m, resolved from heightmap position) ChunkContext + seed → VoxelColumn (1 m, the actual derive call) ``` The `RegionHint` name survives but becomes specifically the `RegionProfile` — the thing the region-level pass fills once and hands to all chunks within it. --- ## Scale boundary 1: 78 km cell → ~1 km region (RegionProfile) This boundary is **classification**, not interpolation. A region does not gradually become a fjord coast — it is or it isn't, based on contextual inputs. The squaring risk here is not visual grid artifacts; it is abrupt zone transitions at region edges. ### What the 78 km cell hands down The Layer 1 outputs already available per D-208 / D-209 / D-227: - `elevation` (f32 heightmap, but read as integer-scaled u16 per D-010) - `flow_accumulation` (D8 integer count) - `flow_direction` (D8 integer 0–7) - `sub_biome` (D-210 / D-228 — climate + vegetation zone) - `river_network` membership (bool) What the region pass adds (runs once per region, stored as `RegionProfile`): ```rust pub struct RegionProfile { // Classification inputs — all integer or enum pub morphology_zone: MorphologyZone, // fjord / meander / delta / etc. pub lithology: Lithology, // resolved from body parameters + elevation strata pub glaciation: GlaciationGrade, // 0..4 integer grade pub slope_class: SlopeClass, // Flat / Gentle / Steep / Cliff (computed from 3×3 elev kernel) pub drainage_class: DrainageClass, // None / Ephemeral / Perennial / Major pub sea_level_margin: i32, // elevation - sea_level, integer metres // Morphology algorithm selector — derived from all above pub morphology_family: MorphologyFamily, // Parameters handed to ChunkContext pub relief_amplitude: u16, // max voxel-height variation within region, integer metres pub meander_intensity: u8, // 0–255 integer pub erosion_grade: u8, // 0–255 integer pub dune_orientation: u8, // compass octant (0–7), prevailing wind } ``` `MorphologyZone` is the D-228 "region-level morphology zone" field, resolved here. `MorphologyFamily` is the algorithm selector — that is Gestalt's domain to enumerate; I just need to know what I am selecting between. ### Lithology: resolving the ocean/lake mask gap Q-101 flags the D-223 ocean-polygon removal as an "unowned dependency". My resolution: **sea-level threshold + flood-fill from heightmap edge, all integer**. ```rust fn classify_water_body( body_params: &BodyParams, // has sea_level, radius elevation_grid: &Grid, // integer-scaled metres pos: (u16, u16), ) -> WaterBodyClass { // cells below sea_level threshold that are connected to the heightmap // edge (flood-fill) → ocean; isolated depressions below sea_level → lake } ``` This is a single flood-fill pass — O(n) over the Layer-1 grid (512×256 = 131k cells), produces a bitmask. Deterministic, integer-only, no authored polygon dependency. Lithology strata are body-class parameters (tectonic activity, age) → integer lookup table per `(body_class, elevation_band)`. No f32 in classification. ### Region boundary blending (anti-squaring at this boundary) The morphology zone must not flip discretely at region edges. Mechanism: **dual classification with a blending weight**, resolved at the `ChunkContext` level. Each region classifies to a primary and a secondary `MorphologyFamily`, with an integer weight `primary_weight: u8` (255 = fully primary, 128 = equal mix). Chunks near region boundaries inherit both families and blend their parameter tables. The boundary width is one chunk (64 m) on each side — chosen to be invisible at normal view distances and narrow enough not to corrupt chunk interiors. --- ## Scale boundary 2: ~1 km region → 64 m chunk (ChunkContext) This is the **critical** boundary for squaring. Adjacent chunks from different regions must tile seamlessly. The failure mode here is visible grid lines where chunk borders coincide with region zone changes. ### ChunkContext struct ```rust pub struct ChunkContext { // Position identity pub chunk_pos: ChunkPos, // integer chunk grid coordinates pub chunk_seed: SeedChain, // SeedChain::derive(Block, chunk_id) // Inherited from RegionProfile (primary) pub primary: RegionProfile, // Boundary blending — present only for boundary chunks pub secondary: Option, pub blend_weight: u8, // 255 = fully primary (no blend) // Heightmap slice — integer, resolved at chunk resolution pub elevation_min: u16, pub elevation_max: u16, pub elevation_grid: [u16; 64*64], // 4096 u16 values, bilinear from Layer 1 → chunk res // Flow data at chunk resolution pub flow_direction: [u8; 64*64], // D8 octant per chunk-scale cell pub river_cells: u64, // bitmask of 8×8 macro-cells with river presence } ``` `ChunkContext` is **computed once per chunk** and cached per D-227 (same eviction tier as the derived tile data itself). Computing it requires reading the four surrounding `RegionProfile` records plus the heightmap tile. Cost: integer arithmetic over 64×64 arrays — well under 1 ms. ### Anti-squaring: domain warping at the chunk scale The principal technique for seam-free transitions at chunk boundaries is **position-domain warping using a global continuous noise field** — matching the D-228 "global, position-keyed continuous noise field — never per-chunk" directive for the cohesion matrix at the material layer. The same principle applies at the morphology level: the warping offsets are derived from a **global field** keyed on (body_id, world_seed), evaluated at any (x, y) position. Any chunk can independently compute the warp offset for any position within or adjacent to itself — producing identical values for the shared boundary. This is the structural guarantee of seam-freedom: warp values are global functions, not per-chunk state. Concretely: ```rust fn warp_offset(seed: u64, body_id: u64, x: f64, y: f64) -> (f64, f64) { // Two independent value-noise fields (no coherent noise dependency) // Using integer-grid hash + bilinear interpolation to keep it f64-confined // Returns sub-voxel displacement in (dx, dy), magnitude capped at ~8m } ``` The warp magnitude (~8 m maximum) is chosen to be larger than one tile (1 m) but smaller than a chunk (64 m). This means: - Chunk-grid lines are visibly displaced by up to 8 m in both directions - No straight-line grid artifact can persist for more than 8 m - Adjacent chunks compute the warp for their shared edge independently and get identical values (pure function of world coordinates + seed) The warp is applied in **world-space coordinates before the morphology algorithm runs** — the algorithm sees warped positions and produces terrain that has no knowledge of the chunk grid. The chunk boundary is invisible because neither algorithm has it. ### Relation to the D-227 chunk cache The domain-warp field does not need to be cached separately. Each chunk computes warp offsets only for its own tiles (the 64×64 grid) plus a 16-tile overlap margin on each side (for the blend zone). This overlap is cheap — 96×96 evaluations vs 64×64. The warp function itself is O(1) per position; the cost scales with chunk area, not with neighbor count. --- ## Scale boundary 3: 64 m chunk → 1 m voxel (the derive call) This is where D-227's `subtile(x,y,z) = derive(seed, atlas, position)` lives. ### Refined function signature ```rust pub fn derive_voxel( ctx: &ChunkContext, // pre-computed chunk context (cached) pos: VoxelPos, // integer (x, y, z) — 1 m resolution ) -> TileAxes { // Returns the D-228 composite: // TerrainMaterial, FloorMaterial, Vegetation, Water, elevation } pub struct TileAxes { pub terrain_material: TerrainMaterial, // u8 enum pub floor_material: FloorMaterial, // u8 enum pub vegetation: VegetationKind, // u8 enum pub water: WaterDepth, // u8 enum pub elevation: u16, // integer metres } ``` The `pos` parameter carries world-space coordinates, not chunk-local coordinates. The algorithm: 1. Apply `warp_offset(ctx.chunk_seed.seed(), body_id, pos.x, pos.y)` — warp the position. 2. If `ctx.blend_weight < 255`: resolve `TileAxes` twice (primary + secondary `RegionProfile`), then integer-blend the parameters before selecting materials. The blend uses the same warp offset to prevent the blend seam from aligning with the chunk edge. 3. Call the `MorphologyFamily`-specific derivation function with the `ChunkContext` parameters. 4. Derive `TerrainMaterial` from `lithology + elevation + slope`. 5. Derive `Vegetation` from `sub_biome + terrain_material + elevation`. 6. Derive `Water` from regional water-height vs `elevation` (the D-228 cheap seasonal/tidal water model). 7. Return `TileAxes`. Step 3 is Gestalt's domain (the morphology algorithm implementations). The contract from my side: each family receives `ChunkContext` + warped position and returns an `ElevationDelta: i16` (delta from the base heightmap elevation, signed, integer metres). That delta feeds into step 4 onward. ### Structural decisions (D-010 integer discipline) All structural decisions — which material, which morphology zone, which elevation band — are **integer-only**. f64 is confined to the warp offset computation and any within-voxel interpolation. This is not ceremonial: D-227 makes it save-critical. A single f32 comparison that flips on a different hardware FPU would desync every mutator reference in a save file. Specific constraints I am placing: - `ElevationDelta` is `i16` (integer metres). No sub-voxel elevation in the structural layer. Sub-voxel geometry is a render concern (shape-from-material, D-228: "sand slumps to angle of repose, rock breaks to vertical face"). - Material selection is a series of integer comparisons / lookup table reads. - The warp offset is f64 during computation but is immediately truncated to an `i16` (integer metre displacement) before any structural decision uses it. The sub-metre remainder is discarded — only the rendered visual uses sub-voxel precision, and that is a client concern. --- ## Performance contract Target: <5 ms per chunk, per D-227. Budget breakdown (64×64 = 4,096 tiles per chunk): | Work item | Cost estimate | Basis | |-----------|--------------|-------| | `ChunkContext` construction (once per chunk) | ~0.2 ms | 4×4 RegionProfile lookups + 64×64 integer array ops | | Warp offset computation (4,096 + margin positions) | ~0.5 ms | ~5,000 hash + bilinear evaluations | | Morphology algorithm (4,096 tiles, varies by family) | ~1–3 ms | family dependent; Gestalt/Troblum to validate | | Material + vegetation + water derivation (4,096 tiles) | ~0.5 ms | integer table lookups | | **Total** | **~2.2–4.2 ms** | headroom for complex families | The chunk cache means this cost is paid once per chunk per play session (or per Atlas view request). Adjacent chunks share no mutable state; they are embarrassingly parallel under Rayon (D-208 precedent). The `ChunkContext` is the critical cache object — it amortizes the `RegionProfile` lookups across all 4,096 tile derivations in the chunk. Evicting a chunk from cache means recomputing its `ChunkContext` plus all tile derivations; the `RegionProfile` cache above it is longer-lived (one per ~1 km region vs one per 64 m chunk). --- ## What I need from teammates **From Gestalt:** - The ≥6 morphology family names and their context selectors, so I can populate `MorphologyFamily` as a typed enum with explicit `#[repr(u8)]` discriminants (stability requirement — same as `SeedDomain`). - The `ElevationDelta` range each family can produce. I need to know if any family wants sub-voxel precision (answer: no — see D-010 argument above) or more than ±255 m variation within a chunk (answer: physically implausible for a 64 m chunk). - Any context inputs the family selector needs beyond what `RegionProfile` already carries. My position: `RegionProfile` should be sufficient; if a family needs a per-tile input not in `RegionProfile`, that is a signal the classification belongs at the `ChunkContext` level, not the voxel level. **From Troblum:** - Stress-test the warp budget specifically. 5,000 hash + bilinear evaluations in 0.5 ms is ~90 ns/evaluation — plausible on modern hardware but worth verifying. The warp can be precomputed into a lookup grid within the `ChunkContext` if that is faster, at the cost of ~12 KB of additional cache memory per chunk. - Validate the `ChunkContext` construction cost. The 64×64 integer bilinear interpolation from Layer-1 resolution to chunk resolution is ~8× upsampling; that should be fast but Troblum should confirm. **From Miri:** - Which morphology zones need sub-region variation within a single 64 m chunk? For example: a meander reach with an oxbow lake needs the lake to appear within a chunk that the region classifies as "meander reach". Does that require a secondary zone classification at the chunk level, or does the morphology algorithm handle it internally? - The D-228 "named features matter for the wiki/atlas" list — which ones require the `RegionProfile.morphology_zone` to be queryable at Atlas-generation time? That determines whether `RegionProfile` needs to be persisted to the layer cache or can be recomputed on Atlas queries. --- ## Open issues I am flagging for Round 2 1. **River flow direction at the voxel level.** Q-101 notes "river flow direction derives from the D8 network at query time." The D8 network is Layer-1 resolution (512×256). At 1 m voxel resolution, a river cell needs flow direction at meter scale. The meander algorithm (Gestalt's domain) presumably drives local course variation — but does the D8 direction act as a basin-scale constraint or a cell-level input? This affects whether `flow_direction` in `ChunkContext` is one value per chunk or one value per 8×8 macro-cell within the chunk. 2. **`RegionProfile` persistence vs recompute.** The Atlas viewer (D-225) currently caches `Layer1Output` (rivers, basins, attractors). `RegionProfile` is a finer grain. For Phase 4 Atlas use, we may want to compute `RegionProfile` lazily on Atlas request rather than precomputing for all regions — the number of regions per body is on the order of 78km/1km ≈ ~6,000 per body. That is cheap to recompute on demand but expensive to precompute at atlas-load time for all bodies. 3. **`MorphologyFamily` transition zones and pathological adjacencies.** What happens when a fjord coast is adjacent to a meander delta? The blend mechanism handles it at the chunk level but the `RegionProfile` classifier needs to produce a sensible `secondary` morphology zone for the boundary chunks. This requires the morphology-zone vocabulary (Gestalt's Round 1 output) before Round 2 can finalize. 4. **Body-class modulation of `RIVER_THRESHOLD`.** Q-101 flags this. My position: `RIVER_THRESHOLD` should be a body parameter computed from `(hydrosphere, tectonic_activity, precipitation_class)` using an integer lookup table. This gives Nigel the handle without touching the D8 algorithm itself. The lookup table lives in `body_params.rs` alongside other per-body scalar parameters.