Files
settled-reach/docs/workshops/tile-derivation-contract/tyre-round1.md
T
jpmschweitzerandClaude Opus 4.8 fa8b6ebf08 docs(governance): file D-239 tile derivation contract, resolve Q-101
Resolves Q-101 — how a coarse Layer-1 cell + seed becomes coherent ~1m
voxel geometry across the scale jumps. The tile-derivation-contract
workshop output:

- three-carrier refinement chain RegionProfile -> ChunkContext -> VoxelColumn,
  pure deterministic, no authoring at the derivation layers
- district-temperature climate primitive (2x2 km, C, nullable) + separate
  moisture; everything climatic derives from temperature(+moisture)
- scattered, transient freeze/snow model (freshwater +5..-10, sea ice own
  band, snow moisture-gated; forms cold phase / melts warm phase)
- stateless f64-to-voxel domain warp (anti-squaring)
- 8 morphology families over a frozen 17-zone vocabulary, gated decision tree
- seams prevented at source (gate ordering + build-time matrix); valid
  geomorphic seams kept sharp + warped

Includes workshop brief, round-1 positions, and workshop-outcomes.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:14:55 +02:00

16 KiB
Raw Blame History

title, workshop, participant, round, date
title workshop participant round date
Tyre Round 1 — RegionHint API, anti-squaring, determinism/perf tile-derivation-contract Tyre 1 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 07)
  • 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):

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,   // 0255 integer
    pub erosion_grade:     u8,   // 0255 integer
    pub dune_orientation:  u8,   // compass octant (07), 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.

fn classify_water_body(
    body_params: &BodyParams,    // has sea_level, radius
    elevation_grid: &Grid<u16>, // 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

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<RegionProfile>,
    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:

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

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) ~13 ms family dependent; Gestalt/Troblum to validate
Material + vegetation + water derivation (4,096 tiles) ~0.5 ms integer table lookups
Total ~2.24.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.