Files
UnrealPrototyping/Docs/Terrain.md
T
2026-09-25 17:02:24 +03:00

138 KiB
Raw Blame History

Terrain

The world's heightmap generator: what it is, what it will be, and what happens to what exists. Off the ladder, like everything about L_World, so nothing here blocks a step and no gameplay code may reach into it.

The current working brief is Terrain-Next.md: what the generator produces today, what still looks wrong and in what order to fix it. Read it if you are picking the work up; read this one for why anything is the way it is. Settled work moves from there to here.

This document reconciles a procedural terrain specification (tectonics → faults → lithology → stream-power erosion, a Go core, an editor bridge into a Landscape edit layer) with the pipeline already in the repository. The two agree on the goal and disagree on almost every number, so the point of this document is to settle the disagreements once, in writing, before any of it is built. Where the incoming spec is called "the spec" below, its resolution is marked with the project's own notation: [DECIDED] settled with the reason, [PROPOSED] the recommended shape not yet built on, Qn an open question collected at the end.

It is not in Spec/. The spec set is the gameplay specification, C++-shaped and governed by the twelve cross-cutting rules; this is an offline tool that never runs in a game. Of those rules only the twelfth, determinism from a seed, applies, and it applies completely. The level dressing — the material, the sun, the fog, the sea plane, the pack's misleading layer names — stays where it is documented, in ../RawContent/World/README.md.

Why change anything

The user's verdict on the current world was "still somewhat rough", and the Worklog's open item says the same three things: sculpting does not survive a rerun, the terrain is invisible in the editor viewport, and nothing grows on it. None of those is the reason to rewrite the generator. The reason is narrower and the spec names it:

Particle erosion does not make drainage. Droplets carve the path each droplet happens to take. They produce gullies, rills, scree and plausible-looking wear, and the current pipeline does all of that well, but they do not produce a network: no branching hierarchy, no valley whose width matches the area it drains, no divide that sits where the two basins either side of it put it. Stream power does, because it solves for drainage area first and then erodes proportionally to it. That is the single change that moves the terrain from "noise that has been weathered" to "terrain that has a history", and everything else in the spec — plates, faults, lithology — exists to give that simulation something to chew on.

So the order is inverted, and that inversion is the whole reconciliation: today the noise is the terrain and erosion decorates it; afterwards the noise is an uplift field and the simulation produces the terrain.

What exists today, and what becomes of it

File What it is Fate
RawContent/World/World.json The manifest: size, quad, elevation range, sea level, source, erosion settings, layer rules Kept and extended. Its erosion block is replaced by a pipeline block. It stays the single contract
Scripts/Authoring/world_manifest.py Reads the manifest, derives Z scale, Z offset, the height encoding Kept. create_world.py still needs it. The Go core reads the same file and must derive the same numbers
Scripts/Authoring/heightmap_noise.py Value noise, fBm, domain warp, Worley crests, blur; the continent generator Retired. Its shapes are reimplemented in Go, its tuning is carried across (below)
Scripts/Authoring/heightmap_erosion.py Particle hydraulic erosion, thermal weathering, strata hardness, curvature Demoted and reimplemented. Particle erosion survives as a detail pass only; thermal and strata survive whole
Scripts/Authoring/heightmap_io.py 8/16-bit greyscale PNG, raw .r16, resample, centred crop — no PIL, numpy only Retired. Go's image/png and a small resample cover it. The DEM ingest rules must be ported exactly
Scripts/Authoring/generate_heightmap.py The driver: source → erosion → spawn pad → encode → derive layers → write PNGs Retired, replaced by the Go CLI. Its layer derivation is ported unchanged in behaviour
Scripts/Authoring/create_world.py Imports the PNGs, dresses the level from Rocky Meadows' demo maps Kept. Gains the edit-layer path
Scripts/Authoring/dump_level.py Dumps any level's actors to JSON; how the dressing numbers were read Kept, untouched
Source/SaltyEditor/Authoring/LandscapeAuthoringLibrary.* CreateLandscapeFromHeightmap, the editor's Import button callable from a script Kept, gains ReimportHeightmapIntoLayer and a guard on the component layout

Choosing Go retires about 780 lines of working numpy. That is the real price of the decision and it is worth stating plainly: those lines are not just shapes, they are five rounds of tuning, and every lesson in the Worklog's "Did not work" section is encoded in a constant somewhere in them. They are listed here so the Go port carries them rather than rediscovering them:

  • Octave gain. Eight octaves at gain 0.5 makes every octave as steep as the last and puts a third of the land above 50°. Gains stay at 0.42–0.45 and no octave is finer than about 50 m.
  • Cellular crest lines at 30 % of mountain height turn ranges into a honeycomb of polygon walls. 12 %, through a stronger warp.
  • The droplet slope gate must sit well above the median lowland slope (0.25 rise over run, about 14°), or the meadows come out brushed with rills.
  • A droplet's cut is capped per step (a fifth of a cell height), because droplets step in batches, share cells, and a crowd in one cell runs away to infinity without the cap.
  • Cuts go through a 3×3 brush; deposits land on the droplet's own cell. Spread deposits through the brush and a pit's rim rises faster than its floor, so the pit never fills and every droplet feeds a mound.
  • Thermal weathering sheds half the largest excess, not half the mean, or it converges far slower.
  • Measure before tuning. A stage-by-stage slope histogram attributed the rill damage to the coarse pass in one run. No knob is turned on an impression.

The canvas [DECIDED]

The spec and the manifest disagree here and the spec is closer to right, but neither number survives contact with what the engine's importer actually does.

Today The spec Resolved
Vertices a side 4081 7113 7141
Quad 350 cm 200 cm 200 cm
Side 14.28 km, 204 km² 14.22 km, 202 km² 14.28 km, 204 km²
Components 16×16 of 255 quads, 1 section 28×28 of 127 quads, 2×2 sections 28×28 of 255 quads, 1 section
Component count 256 784 784
Elevation −460…2800 m ±1024 m (Z scale 400) −512…1536 m (Z scale 400)
Height precision 5.0 cm ~3 cm 3.125 cm

Why not 7113. D-45 is not a preference, it is a description of the importer: it picks the largest section size that divides the quad count exactly, preferring one section per component. 7112 = 127 × 56 and is not divisible by 255, so a 7113 heightmap handed to CreateLandscapeFromHeightmap as it stands produces 56×56 = 3136 components, not the spec's 784 — the same trap that made a 4033 import take forty minutes instead of two. The spec's 28×28 layout is only reachable by specifying section size and section count explicitly instead of deriving them, which is a change to the C++ for no gain over a resolution that divides correctly.

Why 7141. 7140 = 255 × 28, so the importer's own rule gives exactly 28×28 components of 255 quads, one section each: the spec's component grid, reached without touching the importer path. At 200 cm the side is 14 280 m, identical to today's, so the manifest's side length, the sea plane, the spawn pad and every number create_world.py places are unchanged. Cells go from 3.5 m to 2.0 m and the sample count from 16.7 M to 51.0 M, a factor of 3.06.

Why −512…1536 m. The manifest's contract is an elevation range in metres, from which the Z scale follows; that contract is better than the spec's (a raw Z scale) and it stays. A span of 2048 m is Z scale 400, so the spec's canvas is expressible in the manifest's own terms with no loss. It puts the ceiling at 1536 m instead of 2800 m, which is the spec's deliberate judgement: 2800 m peaks in a 14 km-wide region is a Himalayan gradient, and the spec's 800–1500 m target relief is what a fluvial landscape of this size actually looks like. The sea floor gets 512 m, more than today's 460 m. Taken with the trade understood: the mountains get lower and the valleys get better. It supersedes the 2600 m crests the noise was tuned for on 2026-09-16, which is the one piece of that tuning the port deliberately does not carry.

Streaming. streaming_grid_components stays a manifest key; 784 components at one per proxy is 784 packages, three times today's. Recommended value 2, giving 14×14 = 196 proxies of 1020 m each. This interacts with the Worklog's open item 2 (nothing visible in the editor viewport, most likely because every proxy is spatially loaded and none is loaded in the editor); that item is fixed independently and first, since a generator whose output cannot be looked at cannot be iterated on.

The pipeline

Fields live on a context and are named. Every pass reads and writes named fields and nothing else. Two resolutions: the geology grid at 1786² ((7141 − 1) / 4 + 1, 8.0 m cells — the spec asked for 2048² at ~7 m, and an exact factor of four buys an integer upsample with no resample artefacts), and the detail grid at 7141².

# Pass Grid Reads Writes Status
0 Template geology a painted map and its legend class, landMask, uplift, K New (D-53). Replaces 1-4 when present
1 Plates geology — uplift, boundaries New
2 Continent geology — landMask, baseLevel From today's continent falloff
3 Faults geology boundaries uplift, faults, warp New
4 Lithology geology — K New (plan view)
5 Base relief geology uplift, warp height From today's noise, amplitude cut hard
6 Fluvial geology height, uplift, K, baseLevel height, flowAccum, flowDir New — the point of the exercise
7 Thermal (coarse) geology height height Today's, unchanged
7b Coast geology height, landMask, flowAccum height, landMask, exposure New (D-51)
7c Craters geology height, class height New (D-54). An impact is an event, not a rate: a closed basin cannot survive the flood
8 Upsample → detail all all Built (D-53). Per tile, sea flattened first
9 Detail noise detail height, slope height Built. Its own short noise period
10 Strata detail — hardness Built. Feeds pass 11 rather than standing alone
11 Particle detail height, hardness height, wear, deposit Built. Ported; spawning is a hash of world position
12 Thermal (fine) detail height height Built, unchanged
13 Spawn pad detail height height Skipped on a planet: there is no single centre
14 Derive detail everything weightmaps, flow/wear/deposit/curvature, meta.json Part built: flow, wear, deposit and a hillshade per tile; the weightmaps wait for something that imports them

Notes where this departs from the spec, each for a reason:

Pass 0 replaces passes 1 to 4 rather than feeding them (D-53). A painted template is a statement about where the ranges are, which is exactly what plates, faults and lithology exist to invent. When one is present the continent mask, the percentile range band, the normalised swell and the percentile lithology split are all skipped - every one of them is a global operation over the grid it is given, and a region is not a world, so two regions taking percentiles of their own extents would disagree about the same rock. What survives is the initial relief, rebuilt on world coordinates, and a long-wavelength swell modulating the painted rate, which is not decoration: D-49's arithmetic says a uniform rate over a wide area has no divides at all, so without it a painted plain comes out table-flat with the priority-flood's traversal order drawn across it.

Continent and sea (pass 2) are not in the spec at all. The spec builds an inland region with an outlet on one edge and leaves the boundary condition as an open question. This world has a coast, a sea plane (World_Sea_Proto), a sea_level_m manifest key and statistics reported as "% above sea level". Keeping the continent is also the better simulation choice: sea level is a fixed base level on every cell the land mask calls ocean, which is a far better-posed boundary for a stream-power solve than one fixed edge, and it removes the artificial drainage divide that a single-outlet map has along three of its sides. [DECIDED]: the world keeps its coastline, and the spec's §9 boundary question is closed in favour of the continent.

Lithology and strata are both kept, because they are orthogonal. The spec's lithology is a plan-view field of rock types multiplying K by 0.5× / 1× / 3×; the existing strata model is vertical banding of hardness with a slow tilt, which is what puts shelves and ledges on a cliff face. One varies with where you are, the other with how deep you have cut. Lithology enters the fluvial solve at geology resolution; strata scales the particle pass at detail resolution, exactly as today.

Uplift replaces the range mask. Today heightmap_noise.generate_metres builds ranges from an elongated, warped, percentile-thresholded band, thresholded so ranges and foothills cover about two fifths of the map whatever the seed. That percentile trick is the thing that makes the result seed-independent and it is kept — but it now shapes the uplift rate field that the fluvial pass integrates, not the height directly. The spec's own constraint (20–40 % of the map at low uplift, so there are basins to build in) is the same statement from the other end, and both are enforced by thresholding on percentile rather than on value.

Base relief amplitude is cut hard. The spec says 50–150 m × normalised uplift and it means it: the fluvial pass is what produces relief, and starting it from 2600 m ridged crests means it spends its whole run tearing them down. Today's noise makes the mountains; after this it only breaks the symmetry.

Particle erosion keeps every brake it has. Demoted from "carves the valleys" to "detail only", which is the spec's §4.8, and explicitly forbidden from reshaping what the fluvial pass produced: the slope gate, the per-step cut cap, the load cap, the 3×3 cut brush and the own-cell deposit all carry over unchanged, and the droplet count scales with cell count (about 9 M at 7141² to hold today's density at 4081²).

The fluvial pass, in detail

dh/dt = U − K · A^m · S^n, with m = 0.5, n = 1, K from the lithology field around a base of 2e-5 to 1e-4 /yr with A in m². Braun & Willett's 2013 implicit formulation: compute D8 receivers by steepest descent, build the stack, update up the stack. It is O(n) per step and unconditionally stable in dt, which is why it is the right solver and a naive explicit one is not.

  • Pits. Priority-flood (Barnes) to fill or route depressions, because a D8 receiver graph with a pit in it has no path to base level and the implicit update has nothing to solve against. It runs every step. fill_every survives as a knob and its value is 1.

    This corrects the paragraph that stood here, which set it to 50 to protect the time budget. That was wrong, and measurably so: uplift reaches 5 mm/yr, which at dt 1500 is 7.5 m a step, so fifty steps is up to 375 m of differential uplift between floods — far more than enough to close basins that then sit unrouted while everything upstream of them stops eroding. Measured at 512² over 3000 steps, against an expected exponent of −0.5:

    fill_every 1 5 10 25 50
    exponent −0.500 −0.277 −0.121 −0.061 −0.100
    R² 0.992 0.861 0.318 0.120 0.371
    run 93 s 38 s 32 s 28 s 27 s

    Even every fifth step is already broken, and the saving is not worth having. The budget is paid for elsewhere — see the time budget below. The Cordonnier/Barnes lake-flow variant, which routes through depressions instead of refilling them, remains the fallback if the flood ever has to get cheaper.

  • Hillslope diffusion D = 0.01–0.05 m²/yr after each step, which is what rounds the divides and stops the channel heads from being needles.

  • Time. dt = 1000–2000 yr, 1000 steps, 1.5 Myr, not the 5000 steps and 5–15 Myr the incoming spec asks for. That figure is generic advice; at this K and this scale the knickpoint celerity K·A^m puts the response time of a trunk channel near 45 000 yr, so 1.5 Myr is already tens of response times. Measured at 512², the exponent is −0.499 at 500 steps and does not move afterwards. Finer grids need more, because headwaters carry small drainage areas and so respond slowest: at 1024² the exponent is −0.640 at 500 steps and −0.584 at 1000, still converging while R² sits at 0.99. Stopping early is the knob for a "young", high-relief look, and it is a real one — the landscape is straight-line graded long before it is finished.

  • Parallelism. The stack update is sequential along a flow path but independent between basins. Partition by basin, not by row, and reduce in basin-id order so the result does not depend on scheduling.

The parameter ranges and the canvas are not jointly consistent, and this must be checked every run. Steady state puts channel slope at S = U / (K · A^m). At the spec's aggressive corner — U = 5 mm/yr, K = 2e-5 — a cell draining 1 km² sits at 25 % slope, and integrating that up a 7 km profile overshoots the 1536 m ceiling badly. At the gentle corner it undershoots into a plain. U/K is effectively the single relief knob and the ceiling is a hard clip in the 16-bit encoding, so the generator reports the fraction of the map it clipped, as generate_heightmap.py does today, and a run that clips more than a fraction of a percent is a failed run, not a rounded one.

The Go core [DECIDED]

Go, per the user's decision, in Tools/Terrain/, module path salty/terrain, never published. The binary is built to Tools/Terrain/bin/ and is gitignored; Scripts/build-terrain.sh builds it beside build.sh. Go 1.25.0 is already on the development machine, which has 16 cores, so the toolchain costs nothing and math/rand/v2 is available — but Go is not in the repository and not in the engine's toolchain, so a machine that regenerates the world needs its own install, and build-terrain.sh says so when go is missing.

Tools/Terrain/
  go.mod
  cmd/terrain/main.go        the CLI
  field/                     Field, HeightField, resample, upsample, PNG in and out
  pass/                      one file per pass, each implementing Pass
  pipeline/                  Context, Pipeline, the manifest reader
  stats/                     slope-area, hypsometry, drainage density
type Field struct { W, H int; CellM float64; Data []float32 }

type Pass interface {
    Name() string
    Apply(ctx *Context) error
}

type Context struct {
    Seed   int64
    Fields map[string]*Field   // iterated only through an explicitly sorted key list
    Params Params
    Log    func(string, ...any)
}

Determinism is the one cross-cutting rule that applies here, and Go fights it in three specific ways. Map iteration order is randomised by design, so no pass may iterate Fields (or any map) directly to produce output; goroutine completion order is nondeterministic, so every parallel reduction must be into a pre-allocated indexed slot rather than a channel drain; and math/rand's global source is shared. Each pass takes its own rand.New(rand.NewPCG(seed, passIndex)), works on integer coordinates, and its result must be byte-identical whatever GOMAXPROCS is. That last sentence is a test: run the pipeline at GOMAXPROCS=1 and at GOMAXPROCS=N and compare the output hashes.

The CLI keeps today's flags, so the README, the muscle memory and the two documented commands survive:

Tools/Terrain/bin/terrain generate                                   # the manifest as it stands
Tools/Terrain/bin/terrain generate --seed 12                         # another continent
Tools/Terrain/bin/terrain generate --source-file RawContent/World/Sources/dem.png --source-elevation 0 2400
Tools/Terrain/bin/terrain generate --stage fluvial --size 1024       # one pass at a small size, for iterating

Output goes where create_world.py already looks, RawContent/World/Heightmaps/, with the same file names. The spec's out/<preset>/<seed>/ layout serves a preset gallery that does not exist; it is deferred with the gallery.

The DEM escape hatch is already built and is better specified than the spec's §8. source.kind = "file" reads 16-bit PNG or raw little-endian .r16, widens 8-bit, optionally flips, centre-crops to a square, converts with the file's own elevation range, resamples (box when shrinking, bilinear otherwise), optionally smooths, and re-encodes into the world's range while reporting what it clipped. That behaviour is ported to Go as-is, including --source-file and --source-elevation. A file source skips passes 1 through 8 and enters the pipeline at the detail grid, as "enabled": false does today.

The manifest

One file, not two. The spec's UTerrainPreset data asset and its JSON preset are a second and third place for the same numbers to live, for a gallery of presets that does not exist and one world that does; the project's own rule against abstraction for a single implementation applies. World.json keeps level, vertices_per_side, quad_cm, elevation_m, sea_level_m, spawn_pad_m, streaming_grid_components, source and layers unchanged in meaning, and the erosion block is replaced by pipeline:

"pipeline": {
  "geology_factor": 4,
  "plates":    { "count": 6, "velocity_cm_yr": [1, 5], "convergent_mm_yr": [2, 5], "band_km": [2, 4],
                 "divergent_mm_yr": [-2, -1], "rift_km": [3, 6], "intraplate_mm_yr": 0.2,
                 "low_uplift_fraction": [0.2, 0.4] },
  "faults":    { "major": [3, 6], "minor": [10, 30], "length_km": [2, 15], "spacing_km": [1, 4],
                 "throw_major_m": [100, 400], "throw_minor_m": [20, 80], "strike_slip_m": [200, 800] },
  "lithology": { "types": 3, "k_multipliers": [0.5, 1.0, 3.0] },
  "coast":     { "shelf_km": [0.6, 3.0], "steep_coast_m": 300, "slope_km": 1.6, "surf_reach_m": 110,
                 "platform_grade": 0.02, "cut_fraction": 0.85, "deposit_reach_m": 350, "drift_m": 300,
                 "river_m3_per_km2": 1.2e5 },
  "relief":    { "octaves": 7, "gain": 0.45, "base_frequency_m": 4000, "amplitude_m": [50, 150],
                 "crest_weight": 0.12 },
  "fluvial":   { "k": 5e-5, "m": 0.5, "n": 1.0, "dt_yr": 1500, "steps": 1000,
                 "diffusion_m2_yr": 0.02, "fill_every": 1 },
  "thermal":   { "coarse_passes": 12, "fine_passes": 24, "talus_deg": 35 },
  "strata":    { "period_m": 160, "contrast": 0.6 },
  "detail":    { "octaves": 4, "amplitude_m": [2, 8] },
  "particle":  { "droplets": 9000000, "lifetime": 40, "scale": 0.5, "min_erode_slope": 0.25,
                 "max_change": 0.2, "inertia": 0.1, "capacity": 2.0, "max_load": 2.0, "erode_rate": 0.2,
                 "deposit_rate": 0.2, "evaporation": 0.02, "gravity": 4.0 }
}

Every key has a default in the Go source, as heightmap_erosion.DEFAULTS holds them today, so the manifest carries only what differs. create_world.py and world_manifest.py do not read pipeline at all and do not need to.

The bridge

Two changes to ULandscapeAuthoringLibrary, one of which was already the Worklog's open item 1.

ReimportHeightmapIntoLayer [PROPOSED]. The spec's §5.1 is the project's existing open item: edit layers, Generated at the bottom owned by the tool and rewritten whole on every apply, Sculpt above it never touched. Today create_world.py empties the level and builds a fresh landscape, so any hand sculpting dies with the next rerun, which is why nobody sculpts. The new entry point takes an existing ALandscape, a layer name, a heightmap file and the weightmaps, and writes only into that layer — FScopedSetLandscapeEditingLayer around FLandscapeEditDataInterface::SetHeightData, or ULandscapeEditorObject::ImportHeightmap per layer; the exact API is confirmed against 5.8 before it is written, not guessed. create_world.py then reimports instead of rebuilding whenever the landscape already exists with the right resolution, and falls back to CreateLandscapeFromHeightmap when it does not. Check first whether edit-layer data survives ChangeGridSize, since the world-partition split happens after creation. Header changes mean a full rebuild of SaltyEditor with the editor closed.

A guard on the component layout. Not needed at 7141, which the importer's own rule resolves correctly, but the resolution is now something a manifest edit can break silently and expensively. CreateLandscapeFromHeightmap logs the layout it chose and refuses a resolution that yields more than 1024 components rather than spending forty minutes proving the point.

Deferred, with triggers, in the project's sense — not undecided, decided against for now:

Deferred Until
The Editor Utility Widget: preset gallery, thumbnails, preview render target, watched folder There is a second preset. One world does not need a gallery; two shell commands are the interface
UTerrainPreset data assets The same trigger. World.json is the preset
Water plugin river splines from meta.json A water body is wanted in the world. The polylines are exported meanwhile, so the day it happens is a script
Build-zone volumes in the level Something places a settlement. The polygons are exported meanwhile
Porting a pass to a compute shader A pass is measured too slow, not predicted to be
A GPU or C++ path for the fluvial solve The same trigger

Validation

The project already measures rather than eyeballs — the slope histogram settled the noise tuning and attributed the rill damage. The spec adds two standard checks that cost nothing and say more, and both go in meta.json beside the existing statistics:

  • Slope–area. log slope against log drainage area over channel cells should be a straight line with negative slope. Curvature or scatter means K, m, n or the run length is wrong. This is the direct test of whether the fluvial pass did what it is there for, and it is the reason for the rewrite, so it is the proof that closes the work.
  • Hypsometry. Cumulative area against normalised elevation should be S-shaped. Convex means too young or too much uplift; concave means over-eroded.
  • Kept from today: the slope histogram (the standing target is 80 % of the land under 15°), the fraction above sea level, the fraction clipped by the elevation range, and the per-layer coverage percentages.

Drainage density and a straight slope–area plot are what "it reads as real geology" means operationally. Not a screenshot.

Build order

The spec's order, adjusted for what is already proven. Steps 1 and 2 of the spec's own list are effectively done: the loop is proven end to end, every day, at full resolution.

  1. Fix the viewport first. Worklog open item 2. A generator whose output cannot be seen in the editor cannot be iterated on, and this is a World Partition setting, not work.
  2. Edit layers. ReimportHeightmapIntoLayer, Generated and Sculpt, create_world.py reimporting. Done against the current PNGs, so it is proven before the generator moves. Worklog open item 1.
  3. The Go skeleton. Done, 2026-09-17. Field, the manifest reader, 16-bit PNG out, the thumbnail, the noise toolkit and the continent. The determinism test runs at five values of GOMAXPROCS.
  4. The fluvial solver. Done, 2026-09-17. D8 receivers, stack, implicit update, priority-flood, diffusion. The slope–area plot is straight at the expected gradient and the analytic steady-state test passes exactly. See what was built, below.
  5. Plates, faults, lithology feeding uplift and K. Scale to the geology grid at 1786².
  6. Thermal, upsample, detail noise, strata, particle at 7141². Profile; no GPU work before a measurement.
  7. Derived outputs: weightmaps by today's rules, the four derivative maps, rivers, build zones, statistics.
  8. The canvas move: manifest to 7141 at 200 cm, elevation −512…1536, streaming grid 2. Full rebuild, timed, with the component layout logged.

Steps 1 and 2 are worth doing whatever happens to the rest, which is why they are first.

The time budget [DECIDED]

A full run holds today's bar of about five minutes. That is a design constraint, not an aspiration: the generator is a thing you rerun while judging a change, and a pipeline you stop rerunning is a pipeline you stop tuning. The budget on the development machine's 16 cores, to be replaced by measurements as each pass lands:

Stage Grid Budget Measured
Plates, continent, faults, lithology, base relief 1786² 5 s 1 s (continent and relief only)
Fluvial, 1000 steps, flooding every step 1786² 120 s 256 s
Thermal, coarse 1786² 5 s —
Coast: shelf, surf, sediment 1786² 5 s 0.08 s
Upsample and detail noise → 7141² 15 s —
Particle, 9 M droplets × 40 steps 7141² 90 s —
Thermal, fine, 24 passes 7141² 20 s —
Weightmaps, derivative maps, statistics 7141² 15 s —
PNG encode and write, one 16-bit and seven 8-bit 7141² 30 s —

The fluvial pass is over its budget by a factor of two and the five-minute bar is at risk. 256 s measured against 120 s budgeted, with the rest of the pipeline unbuilt and notionally another 175 s. It is not the flood's log n — that is already gone, see what was built — it is simply 3.2 M cells × 1000 steps, most of it in the two genuinely sequential parts (the stack walk and the flood's cursor). Three ways out, in the order they should be tried, and this is a decision for build-order step 6, when there is something to measure against:

  1. geology_factor 8 instead of 4, a 894² geology grid at 16 m cells: four times cheaper, about 64 s, and the upsample has to invent more of the fine drainage.
  2. Parallelise the stack update by basin. Disjoint basins are independent; only the walk within one is sequential. On this continent the trunk basins are large and few, so the gain is real but bounded.
  3. Spend the time. Seven minutes instead of five, with --size carrying the iteration loop anyway.

PNG writing is not free at this size either: eight maps of 51 M samples is over 100 MB through zlib, and heightmap_io.py compresses at level 6 today. The generated maps are rebuilt from a seed, never archived, so the Go writer uses level 1 for the 8-bit derivative maps and keeps a higher level only for the height, which the editor imports once.

Relief is resolution-dependent, and by a lot. The same seed and the same uplift field give 1020 m of land relief at 512², and 2605 m at 1786² — well past D-48's 1536 m ceiling. Finer grids resolve smaller drainage areas near the divides, and S = (U/K)^(1/n)·A^(-m/n) makes small A steep, so the headwaters keep getting taller as the grid gets finer. The practical consequence is a trap: U/K tuned at --size 512 will overshoot at full resolution. The iteration loop is for judging the shape; the elevation budget has to be confirmed at the real geology grid, and the clip warning is what confirms it.

--stage and --size exist so nobody waits for a full run to judge one pass; the iteration loop is --stage fluvial --size 1024 against the slope–area plot, and the full run is what you do when it looks right.

Settled

Everything the reconciliation left open was answered on 2026-09-17. Recorded here so the reasoning stays with the document; the decisions themselves are D-47 and D-48 in Decisions.md.

Was Settled
Continent and sea, or an inland region? The continent stays. Sea level is the base level on every ocean cell; the spec's §9 boundary question closes with it
1536 m peaks or 2800 m? −512…1536 m, Z scale 400. The mountains get lower and the valleys get better
7141, 5101 or 4081? 7141 at 200 cm, 2.0 m cells, 784 components
Go on the machine? Go 1.25.0, 16 cores, already installed. No toolchain cost
How long may a run take? About five minutes, as today. steps is the knob; fill_every turned out not to be one (see what was built)

Nothing is open. What remains is measurement, and the first thing that could reopen any of this is the slope–area plot at build-order step 4 coming out curved.


What was built, and where it differs

2026-09-19. The ocean was thirty metres deep (D-64).

Raised as "it is just a landmass and no oceans really", against the exported heightmap rather than against the preview, and the complaint is exact. Measured on Bake_020, the whole 100 km planet at 1000 steps:

below 0 m 63.7% of the planet, above 36.3%
  -520 m  11.71%   the painted abyss
  -500 m   5.06%
  -480 .. -60 m    about 0.25% a 20 m bin, the whole continental slope
   -40 m   2.72%
   -20 m  26.80%   <- the shelf
     0 m  23.56%   land and shallows together

The legend paints ocean and deep at 512 m over 55.9 % of the planet. Seventeen per cent of it gets there. Forty per cent of the world is water between nought and thirty metres, which at the manifest's −1024…2048 m encoding is 1 % of the 16-bit ramp away from sea level — so in planet_height.png the shelf and the land are the same grey, every landmass wears a halo the width of its shelf, and the halos join the continents into blobs. Stretched to its own data range the picture is one white shape on black. There are no oceans in it.

The number came from the other canvas

coast.Build takes a BreakM, the depth at the shelf break, and both call sites computed it as -m.Pipeline.Continent.SeaFloorM.Hi(). The default is −30 m, and the comment beside it says exactly why:

The canvas is 14.28 km a side and the sea is a third of it, so a real shelf — 75 km out to a break at 130 m — does not fit and is not what these numbers are.

It is not what those numbers are, and the painted path took them anyway. continent is the square canvas's synthetic-mask generator; D-53 decomposed the planet away from it, and this was the piece left holding on. AbyssM was already per-cell from the painting (D-60) and the break never was, so the pass was joining a painted 512 m ocean to a 30 m bench and only the bench was ever visible.

What made it invisible is arithmetic nobody had done. The derived margin reaches shelf_km.hi() + slope_km = 4.6 km from every shore. The first template has 1069 km of shoreline against a 3111 km² sea, so 4.6 km of margin on that shoreline is 4917 km² — more than the whole ocean. Every strait on the planet is narrower than twice the reach, so on every one of them the painting is never consulted and the sea is whatever BreakM says. At 30 m that is a pond. The pass was not wrong anywhere; its profile is monotone and correct at any break depth, which is why every profile test passed and why the defect had to be found in a histogram instead.

What changed

pipeline.coast.break_m is a key. Manifest.ShelfBreakM() returns it when set and the old continent.sea_floor_m reading when not, so the square canvas keeps the behaviour it had — verified, it still prints break at 30 m. fillPlanetDefaults gives a planet 130 m, which is a real shelf break and is also the depth the first template's own shelf class is painted at, the same number arrived at from the author's end. 512 m over the 4.6 km margin is a 6.3° continental slope, so the margin's width was never the problem and is unchanged.

Two more copies of the same read were in tiles.go, both of them commented as "the shelf break": restoreSeaFloor blends interpolation into nearest-neighbour over a band at the break, so at 30 m every tile would have staircased the whole of the new shelf at the geology cell; and the tile hillshade clamps the water at the break, so at 30 m it would have flattened the shelf to one tone. Both now read ShelfBreakM().

And the bake says what its sea floor is, because nothing did:

        sea floor: shelf 0.6..3.0 km to a break at 130 m, then 1.6 km of slope to the painted depth,
        so the painting owns the water past 4.6 km offshore and nothing nearer

The range, which is the other half of the picture

elevation_m is −1024…2048 and the data is −521…+340: the world uses 28 % of the 16-bit ramp and its land uses 7 %. The clip fraction is the check on this and it only ever catches a range too narrow — too wide clips nothing, reports nothing, and spends its contrast and most of its resolution on elevations no cell has. A bake now prints the ramp it used and, under half, the range it should have had. The range itself is the author's and Planet.json is unchanged.

Measured

The full-planet re-bake was killed by memory pressure before it finished, so what is measured here is the mechanism at unit scale, on a straight coast with 9.6 km of sea painted at 512 m (TestTheDerivedMarginDoesNotSwallowThePaintedOcean):

break  30 m: 33% of the sea shallower than 50 m,  30 m at the break, 512 m in open water
break 130 m:  8% of the sea shallower than 50 m, 130 m at the break, 512 m in open water

A four-fold cut in the shallow bench, and the painting still reached in both. On the real planet the effect is larger, because 9.6 km of sea is wide against a 4.6 km margin and the template's straits are not. The other half is pinned too: a sea painted at 20 m still comes out 20 m deep, because the break can never be deeper than the water it is a break in. The whole-planet numbers are still owed.

2026-09-18. A planet has a shore (D-60).

internal/coast ran on a flat grid, so a planet bake laid the painted sea floor and stopped. The land met the painted ocean depth in one cell: no shelf, no surf-cut platform, no beach, no sediment budget and no exposure anywhere on the world. Every bake printed the fact in its own log.

What wraps

Four primitives, three of them one loop each: boxBlur's running sum, fetch's ray march, shelfWidth's inland march, and the gradient of the signed distance field that both marches take their direction from. The distance transform already wrapped. The interior of the pass did not change at all.

The test for it is not "does the seam look right" - it is that the same island in two places is the same island. TestTheWholePassIsRotationInvariantOnACylinder builds a world, builds it again rotated half a turn, and requires the heightmap and every line of the accounting to follow the ground rather than the grid. That has teeth, and the teeth were checked: forcing the ray march flat gives a 0.224 m discrepancy, forcing the box blur flat gives 7.6e-5 m, and the tolerance is set under the smaller of the two rather than at a comfortable round number. With everything wrapped the two worlds are bit-identical.

The abyss becomes a field

A painted planet's sea classes carry their own depth_m - 20 m of surf, 120 of shelf, 512 of open ocean - so the ocean is already laid at several depths before this pass runs. A derived continental slope bottoming out at one global AbyssM would have stepped to the painting wherever the two disagreed, which is a cliff at the shelf break in every strait.

Input.Abyss is that depth per cell, and it fixes the shallow case properly rather than by clamping: the break cannot be deeper than the water it is a break in, so a strait painted at 20 m against a 30 m break is shelf all the way across instead of a trench with a rim.

The memory, which was the other half of the job

  • Geometry.Ref holds an index into Waterline, not a cell index. There are tens of millions of cells and a few hundred thousand waterline cells, so every per-shore quantity is now a couple of megabytes where one indexed by cell is hundreds. The sediment supply was a []float64 over the whole grid: 608 MB for an array only ever read at the shore.
  • Measure holds one distance transform at a time, not two. It needed one observation to reorder: a sea cell's stretch of shore is the stretch its nearest land cell already belongs to, so the second pass reads the answer out of Ref rather than out of the first pass's feature index. The waterline is taken straight off the mask - it is a local question - which is what frees the ordering. That is 600 MB at planet scale.
  • boxMean's coverage is separable. Blurring a field of ones is the obvious divisor and it was the one in use; but the blur is a row pass and a column pass, so the coverage factorises as cx(x)·cy(y) exactly, for any pass count. Two vectors instead of a field and a second blur.
  • The before-and-after snapshot is the change map. Taken into it, subtracted in place at the end.

And a latent NaN that the last of those exposed

deposit computes math.Pow(1-exposure, ShelterBias) with a fractional bias, so an exposure over 1 is NaN - and one NaN spreads through the drift kernel into the entire sediment budget and comes out as a laid volume of NaN with no other symptom at all.

Exposure is a smoothed field, so it is 0..1 only to within the rounding of however it was smoothed. The old divisor was a float32 field computed by the same code path as the numerator; the new one is a float64 product. The ratio went over 1 by five parts in a hundred thousand and 1720 cells of a 200 x 40 test came out NaN. It is clamped at the point of use now - relying on a smoother a hundred lines away to bound its output is not an invariant, it is a coincidence that held.

Measured

coast: 971 km of shoreline, 65% sea, shelf 60% of it; surf planed 3.5 km2 and cut 34.18 Mm3,
       6 river mouths delivered 1.15 Mm3, 35.32 Mm3 laid (0% unplaced) as 4.14 km2 of new beach
       over the whole cylinder in 7.9s

Seven point nine seconds for 76 million cells, once, at the end of a two-hour bake - which is what "the pass is tens of nanoseconds a cell and cutting it up would truncate the fetch across every strait" always meant.

And the seam, which is the point of the whole exercise. The step in the sea floor between the last column and the first:

mean worst
before 9.126 m 523.1 m
after 0.318 m 16.4 m
an ordinary interior column, for comparison 0.017 - 0.285 m 0.75 - 4.2 m

The seam is now an ordinary column. The residual worst case sits on a row where the painting disagrees with itself across the meridian - deep on one edge against crater on the other - and the profile through it is a smooth ramp rather than a step. That is the template's own 9.4 % wrap disagreement and it is an author's to fix, not the generator's.

The pass is now the memory peak of a bake, at about 8.3 GB of working set against the solve's 3.6. That is the live fields it genuinely needs - the distance field, the reference, the exposure, the change map, the supply and the two drift buffers - plus the collector's headroom, and it is worth knowing before running one on a smaller machine.

2026-09-18. A planet can be judged (D-59).

Terrain.md has said since D-53 that "statistics pool across regions rather than being computed per region and averaged". It was a rule with no implementation. Every function in internal/stats took a grid and sorted a copy of every land cell in it, so a planet bake printed its elevation range and nothing else - no slope distribution, no per-uplift-class breakdown, no drainage density, no slope-area fit.

That was not an inconvenience. Twice in one session the tool could not answer a question about its own output: the lowland slope distribution that settled "are the lowlands hilly" (D-57) and the scarp measurement that settled "do faults survive the solve" (D-58) were both taken by hand, in Python, off a PNG.

The structure, and why it is the whole answer

A fixed-bin histogram is O(1) a value with no allocation and its quantile error is bounded by the bin width. Neither of those is why it is here. It is here because it adds: summing two regions' bins and taking the quantile of the sum gives exactly what one pass over both would have given.

Nothing else does. A median of medians is not a median. An area-weighted mean of means is right for a mean and wrong for every quantile. Keeping every value is the thing that could not be afforded in the first place. So the histogram is not an optimisation of the old design, it is the one structure that makes a decomposed planet measurable at all - and the rest follows from it.

Each region builds an Accumulator while its own grid is still alive, because the composited planet has no uplift field and no flow topology left to recover them from; both are per-region and both are thrown away when the region's land is written into the cylinder. They merge in region order rather than completion order: the bins are integer counts and would not care, but the running sums are floats, float addition is not associative, and a run whose hypsometric integral depended on which landmass finished first would be a rule 12 failure hiding in the sixteenth decimal.

Extent and ground are measured in different places

A region is a rectangle cut out of the cylinder with an ocean margin around it, and two neighbouring regions' margins overlap. Pooling "how many cells" across them therefore counts the same water more than once and reports a land fraction that means nothing.

So the two halves are split. Add runs per region and reads only the land that region owns - which Partition.Cut already marks, so the pieces are disjoint by construction. AddExtent runs once, on the composited cylinder, for the cell count, the land count, the clip count and the extremes the 16-bit encoding has to hold.

The consequence showed up the first time a partial bake ran. bake --only 19,7,4 solves three small islands and leaves the rest of the planet at sea level, so the drainage density came out at 0.01 /km: three islands' worth of channels divided by a planet's worth of land. It divides by the land actually walked now, which puts it at 0.18, and the summary says so in as many words rather than leaving the two numbers side by side:

  field -543..340 m; land 0..9 m (relief 9 m), 36% land, 0.00% clipped
  PARTIAL: the line above is the whole world; everything below is the 3% of its land that
           was actually solved (842682 of 27455176 cells). The two are not comparable.

And local relief stops being quadratic

localRelief was max minus min over a square window, looped. At a 500 m window on 8 m cells that is a 63-cell radius, so 28 M land cells is 1.1e11 comparisons - not a slow diagnostic, one nobody would see the end of. field.SlidingMin joins the SlidingMax the coast mask already had, field.LocalRelief is the pair subtracted, and the cost stops depending on the radius at all.

Measured, on a 3000² grid - about what region 12, the largest landmass on the 100 km template, actually is:

BenchmarkRegionSizedAccumulate-16    1085392500 ns/op    9.000 Mcells

1.09 s for 9 M cells, about 120 ns a cell, so the whole planet's 28 M land cells are a few seconds once at the end of a two-hour bake.

One more thing that fell out

generate and bake compute their statistics with the same code now, because an Accumulator does not care whether the grid it is given is one region of a planet or the whole square canvas. That was not true before and nobody had noticed it was not - one of the two printed no numbers to compare.

2026-09-18. The seed re-rolls what the painting does not fix (D-58).

A painting is a composition: where the continents are, where the ranges run, which coast was drawn on purpose. It was never meant to be the whole world, and the question was how much of one painting could become many worlds.

Half of it already could, and measuring that first was worth more than any of the code below. Seed 7 against seed 9342 on the same painting, through terrain plan:

differs
map_uplift.png 13.6 % - the massif fabric moved: different hill masses, different forelands
map_class.png 2.1 % - the coastline jitter re-rolled, and nothing else
map_erodibility.png 2.1 % - and only because the coastline moved

The swell, the initial relief, the crest lines, the upland fabric and the coast mask are all seeded and all re-roll. That third row is the whole finding: Legend.Erodibilities() is a lookup by class index, so a painted planet had one flat erodibility inside every painted colour, and map_erodibility.png was a recolour of map_class.png. There was nothing for a seed to move and nothing to make one flank of a range read differently from the next.

Faults were worse: they did not exist on the painted path at all.

Why neither could be ported

Both live in uplift.Build, and both are written against the assumption the painted path exists to break.

lithology ends with f.Percentile() of the grid it was handed. On a decomposed planet that is two regions measuring their own extents and putting the same physical hillside in different rock, with a wall along the boundary between them. It is exactly the mistake D-55 caught in the massif threshold, in a second place. The cut is now a quantile of the planet, from the same fixed probe, which is why buildMassifCDF became the generic measureFabric.

buildFaults draws a trace centre as two calls to Float() read as fractions of that grid, so the same fault lands somewhere different in every region. The set is now drawn once for the planet, in metres east of the seam and metres south of the top painted row, and a region filters it to the traces that reach into its own frame. A fault crossing a region boundary is therefore one fault, and two decompositions of the same planet produce the same escarpment.

What an author says

{ "name": "highland", "uplift_mm_yr": 0.25,
  "faults": { "per_1000km2": 25, "throw_m": [120, 400], "length_km": [6, 18] } }

A density over the class rather than a count, because a class covers whatever was painted with it. A total throw over the run rather than a rate, because that is the height of the scarp the fault would build if nothing eroded it, which is a number somebody can picture. And on the class, so an author says this range is faulted and that plain is not - which is the control they asked for and is also true of the world: faults belong to orogens. The influence is not restricted to the class, because a range-front fault runs along the edge of a range by definition and its scarp faces the lowland.

lithology_mix, 0 to 1, is the other half: how much of the planet's rock field shows through on this class. An ice cap has no bedrock province showing through it, so it sets 0.

Four recorded defects fixed rather than carried

Terrain-Next 4.A2 lists four things wrong with the procedural faults and 4.A3 a fifth. Writing the painted implementation fresh against that list was cheaper than porting and then repairing:

  • The trace is a walk with a perturbed heading, not one 8-point parabola. A distance field built from eight long straight segments has visibly polygonal contours; short steps with a wandering heading do not.
  • The throw tapers to nothing over the last sixth at each tip instead of stopping where the last segment ends, which is what cut abruptly across a summit.
  • A fault over twelve kilometres is drawn as two or three overlapping en-echelon segments, which is how long faults step.
  • Nothing is clamped to a fraction of a global rate. if r > convergent*1.6 flattens exactly the strongest throws into plateaus; the bound here is the repose ceiling or whatever the author's own numbers asked for, whichever is higher, and the count of cells it binds is reported rather than hidden.
  • The strike comes from a grain field, sampled as a vector through atan2. One global angle reads as corduroy across a whole map (4.A3); a value lattice read directly as an angle would be worse, jumping a whole turn along its own wrap and putting a hard seam through the set along a contour nobody can see.

And one defect found by arithmetic

The escarpment weight is an exponential and the pass cuts it off at three gentle lengths so that each fault only has to visit its own box. At three lengths the raw exponential is still five per cent of its peak - 0.013 mm/yr on a 400 m throw, which is a fifth of a lowland's entire uplift rate. As a hard cut that is a step in the uplift field along a line six kilometres from every fault, and the solve would have carved it into a perfectly straight scarp nobody placed.

So the floor is subtracted and the rest renormalised: the weight reaches zero exactly at the reach, smoothly, and the box becomes an optimisation with no signature. Hoisting the per-segment boxes out of the cell loop at the same time took the pass from 5.2 s to 1.5 s on a 6.3 M cell region, against a solve of twenty minutes.

And one defect the arithmetic missed: the profile itself (D-62)

The floor fix above is about the far end of the profile. The near end was worse, and it survived because every test in internal/uplift asks whether the rate field is asymmetric, agrees between frames and tapers at the tips - all of which the old one did - and none of them asks what shape it is.

Raised from a hillshade: "each fault line makes a rough line of mountains that just doesn't look realistic", and asked as a manifest question. It was not one. per_1000km2, throw_m and length_km say how many faults, how long and how much; nothing anywhere said what a fault's cross-section is. That was faultSteepM = 200 and faultGentleM = 2000 with a step between them: the whole throw on the upthrown side of the trace and the whole throw negated on the other, one 8 m cell apart. Measured on the designed field, a 400 m throw gives two throws across one cell, 89 degrees, inside an upthrown flank that reached zero six hundred metres out.

Both halves of that are unsolvable, and for the same reason the painted path exists at all.

A step in the rate is a painted cliff. "Paint the uplift, never the height" is a claim about what a solve can undo, and a discontinuity in the rate field is precisely what it cannot: the surface has nowhere to put the difference but into a scarp at the angle of repose. The trace facets at any throw, so turning throw_m down only lowers the same artefact.

And six hundred metres is narrower than one hillslope. Bake_013 measures a drainage density of 0.45 channels per kilometre, so a divide sits about 1.1 km from its channel. Nothing can dissect a block six hundred metres wide - there is no drainage area at that width for stream power to work with, and hillslope diffusion only smooths what is already there - so the uplift profile is printed onto the surface instead of being eroded into a landform. That is the artefact precisely: in a hillshade every fault in that bake is a smooth ruled ridge with no drainage on it at all, running through terrain dissected everywhere else, because it is the one part of the map erosion never touched.

The profile now is an odd saturating ramp across the trace times a flank envelope:

faultShape(d) = d/sqrt(R² + d²) · (1 - (|d|/W)²)²      R = 900 m, W = 6000 m footwall / 4000 m hanging wall
  • Zero on the trace, which is also the honest reading: a rate difference across a line says one side rises relative to the other, and at the line the two average to the regional rate. The old profile asserted +throw and −throw at the same point.
  • R is about one hillslope length, which makes the mountain front the sharpest thing this landscape can express without being a cliff nobody solved for.
  • W is several hillslope lengths, so a drainage network fits on the block and cuts it into spurs and valleys — which is what a range front is and what an extruded cross-section never will be.
  • Algebraic, not transcendental. It is evaluated at every cell of every fault's box, a few hundred million times on a planet; d/sqrt(R²+d²) buys what tanh does and (1-u²)² what an exponential does. The envelope reaches zero with zero gradient at its own width, so there is nothing to subtract and no step at the box edge — the cut-off is the support of the function rather than a truncation of it, and the reach is unchanged so the box is too.

tipTaper was the other half. It ramped over the last sixth at each end and held exactly 1 over the middle two thirds, which dies out at the tips - what it was written for - and leaves the cross-section extruded unchanged along most of every trace. An extrusion has no along-strike structure, so erosion has no reason to head a valley in one place rather than another. It is a bell now (q(2-q) on q = 4a(1-a)), zero at both tips, one in the middle, nowhere flat, and still carrying half the throw over four fifths of the trace.

And throw_m now means what the word means: the whole step across the fault, footwall crest less hanging wall trough. The old profile put a full throw on each side and so built two. The normaliser is measured off the profile at init rather than written down, so changing a width above cannot silently change what a legend's number means.

Measured on a 400 m throw:

before after
steepest cell-to-cell step in the rate field 89.0° 17.4°
step across the fault 800 m over one 8 m cell 400 m over 2.66 km, 8.5° mean
footwall standing above half its crest 600 m 3.7 km

And solved, which is the only test that counts. The same synthetic landscape - 1500² at 8 m, 1000 steps, a 0.045 mm/yr foreland, sea at both ends, one curving 12 km trace with a 333 m throw - under the old profile, the new one, and no fault at all:

relief
no fault 45 m
old profile 43 m — a ruler-straight cliff on the trace with a dead flat apron below it
new profile 97 m — a dissected range front with its own drainage network

The old profile adds less relief than no fault at all, which is the measurement catching up with the picture: it spends its throw on a 600 m welt and a 6 km trough, and the trough is deeper than the welt is tall. TestAFaultIsSolvableRatherThanPrinted in internal/uplift is the three properties as assertions - continuous through the trace, zero on it, and a footwall at least three hillslope lengths wide.

What widening a fault broke, and how it is bounded (D-63)

Everything above was verified on region 8, which two traces reach. That is the wrong region to verify a width change on, and the next bake said so: Bake_018, region 11, came out with its southern two thirds a corduroy of parallel ribs - "streaking horizontally like someone just cut the mountains apart with a knife". Two mechanisms, both of them D-62's.

Faults stack, and a fault set is sub-parallel by construction. FaultDelta accumulates with +=, which was harmless while a fault reached six hundred metres because two of them almost never met. At six kilometres they meet constantly - and traces inside one cell of the orientation grain share a strike by design, as does every belt fault along one margin, so where they meet they are all pushing the same way. Region 11 is 22.2 km across, fault_grain_km is 45, and thirteen traces reach it at strikes spanning fourteen degrees. Sampled on a 200 m grid:

faulted ground with two or more faults on it 75 %
summed uplift over the largest single contribution median 1.77x, p90 2.87, max 4.46
points asking for more than the repose ceiling on their own 13 %
cells the hard clamp fired on 160 289, 4.1 % of the region (0.15 % planet-wide before D-62)

That last row is the one that shows: a hard clamp does not soften anything, it makes plateaus at exactly the repose-limited rate.

The knee is per cell, and it is the largest single contribution there. A bound keyed to the largest throw in the set would not bite at all - the biggest throw on this template is 744 m while the biggest single contribution anywhere in region 11 is 209 m, because a fault's own tip taper and flank falloff have already had most of it by the time it reaches anywhere. So softStack takes the summed anomaly and the peak single anomaly at each cell and bends one over the other:

|sum| <= peak   ->  sum                                      the identity
|sum| >  peak   ->  peak + 0.6*peak * tanh((|sum|-peak) / (0.6*peak))

Below the knee a lone fault passes through untouched, so the section above still holds exactly: the step across one fault is the throw its author asked for. Above it the excess bends onto an asymptote of 1.6 times the knee, so a faulted belt still stands higher than an unfaulted one - which is the point of a belt

  • but five parallel faults cannot deliver five throws. It is odd in sum, so a stack of hanging walls is bounded on the same terms and a fault set cannot dig a hole either.

Two properties keep it honest. It is continuous: peak is a max of continuous functions and the join has gradient 1 on both sides, so bending the stack cannot put back the step D-62 took out. And it is frame-independent - sum and peak at a cell depend only on the faults within reach of it, and a fault too far away to enter a frame's box contributes nothing to either - which is what keeps TestTwoFramesAgreeAboutTheSameFaults true. For the same reason it runs unconditionally whenever anything was rasterised, including when only one trace reached the frame: skipping it there would make a cell's value depend on which frame it was asked about, which is the one thing FaultDelta may not do.

And the initial relief was reading the finished rate

The other half, and the one that explains the ribs rather than the height. painted.go scales the symmetry-breaking noise by rate/maxClassRate, and rate there was the finished rate with the fault delta in it and no upper bound. On unfaulted foreland that ratio is about 0.18 and the stamped relief about 39 m. On D-62's six-kilometre footwalls it reached 1.12 and about 166 m - on a landmass whose entire relief is 221 m.

A thousand steps cannot erase initial relief the size of the landscape. So the ridged fBm stopped being a symmetry-breaker and became the terrain: the ribs measure 250-300 m off the hillshade, which is octave five of a 4.2 km ridged fBm (4200/16 = 262 m). It reads the pre-fault rate now, bounded at one. The initial relief exists to give the solve something to bite on; how much noise sits on a hillside is not a fault's decision, and TestTheInitialReliefIgnoresFaults asserts the height field is bit-identical with the fault set and without it.

Measured

Region 11 re-baked at the same seed, the same region and the same thousand steps - Bake_018 against Bake_D63, so the only difference is the code:

before after
max elevation 221.4 m 115.6 m
cells at the repose ceiling 160 289 (4.1 %) 2 865 (0.07 %)
median land slope 6.26° 1.79°
slope-area exponent (theory −0.500) −0.938 −0.599
drainage density 0.58 /km 0.60 /km
stacking factor, median / p90 1.77 / 2.87 1.50 / 1.60

and the corduroy is gone from the hillshade - the same 3.2 km patch that was a comb of parallel ribs is irregular dissected hill country. The wall time went from 1m34s to 12m56s, which is the right direction rather than the wrong one: the old run was cheap because unbounded negative stacking had driven whole aprons to zero uplift, and dead flat ground costs one hillslope sub-step instead of twenty-four.

What is left on the flanks is a fainter version of the same ribbing, which is Terrain-Next 4.B3, predates D-62 and is visible in Bake_013 too. It is diagnosed there now rather than fixed.

Re-rolling, in practice

--seed is on plan, bake and tiles, through fs.Visit rather than a sentinel value - a seed is an arbitrary int64 and every sentinel is one somebody could legitimately want. It is on tiles as well and not as a convenience: the detail passes hash the seed into every droplet, so a tile run has to be told the seed its heightmap was baked under, and CheckBake refuses the mismatch. The studio has the seed and a Re-roll button beside the wavelengths.

Measured after: seed 7 against 9342 now moves 13.8 % of the uplift map and 24.9 % of the erodibility map, with a different fault set on each.

Does any of it survive the solve

A fault is applied as a rate precisely so that erosion cannot remove it, and that is a claim about a thousand steps of stream power rather than about a weight function - so it was measured on a real bake rather than argued. Region 15, the faulted highland landmass, 21.6 x 18.7 km, 3.8 M land cells, 1000 steps, 12 minutes (Bake_008). Five traces fall inside it; for each, the mean land elevation 600 m either side of the trace:

throw over the run scarp across the trace
399 m +24.2 m
336 m +5.3 m
297 m +50.2 m
193 m +8.5 m
139 m +2.7 m

Five of five face the side the fault raises, and every one of them is a small fraction of its own throw, which is what it should be: the rivers cut the scarp down about as fast as the rate difference builds it, and what is left standing is the balance. A painted 400 m step would have been gone entirely.

Read that table with D-62 in mind. It is the mean elevation 600 m either side of the trace, and 600 m is exactly where the old upthrown flank reached zero - so the measurement straddles the welt rather than spanning it, and the small numbers it reports are a fault mostly measuring itself. What the hillshade showed, which no number here asked for, is that the thing surviving was a smooth ruled ridge. The measurement was right that little of the throw survives; it could not say that what survives has the wrong shape.

TestAFaultLeavesAScarpAfterTheSolve in internal/check is that in miniature - one straight trace, a 400 grid, 400 steps, nine seconds - because the tests up in internal/uplift all check the rate field and none of them says the solve leaves anything behind.

2026-09-18. A second painting, for everything that is not geology (D-57).

Two requests, one shape. Say which coastlines to leave alone and which to roughen, and give the engine a layer for forests, settlements and roads. The first sounds like a coast setting and the second like an export format, but both are the same sentence: an author needs somewhere to say things about a place that are not an uplift rate.

The template cannot be that place. Every colour on it is geology — the solve reads a rate and an erodibility off each one and answers for what it makes — so a city colour would have to invent an uplift rate for a town, and a leave this shore alone colour would have to replace the water that is already painted there. So there is a second image, the same size, registered to the first, painted in the same studio, with a legend of marks rather than classes: internal/overlay.

Blank is alpha, not a colour. An overlay is a transparent sheet with strokes on it — that is what every image editor produces and what the studio writes — and reserving a background colour would spend one of the author's colours on nothing and break the moment they exported with a white matte behind it. There is a second way to be blank and it is the more interesting one: an opaque pixel further than match_distance from every mark is dropped and counted, where the class legend snaps every pixel to its nearest class no matter how far. That inversion is deliberate. On a template every pixel must become something, so nearest is the only total answer and the distance is only a warning. On an overlay most of the sheet is nothing, so a pixel that matches nothing has an obvious right answer — and taking the nearest mark instead is exactly how a JPEG halo round a road becomes road.

The one thing any pass reads

coast_jitter, and nothing else. D-56 roughens the painted waterline because a drawn shore is a smooth curve and a real coast is fractal. That argument is true of a shore nobody thought about and false of one traced off a map on purpose, which is the complaint. So the amplitude stops being a number and becomes a field: 0 inside a mark pins that stretch exactly as painted while the rest of the world is still roughened, and above 1 chews it harder, so a fjord coast is a brush stroke rather than a global setting nobody can localise.

The part that needed thinking about is what an unmarked cell does. It is uninstructed — the slice carries -1 there, not 1 — and it takes its instruction from the nearest cell on the far side of the waterline, which dt.Transform has already found for the class-inheritance rule one loop earlier. Without that, a mark is only effective on the side of the line the author's hand happened to be on: a stroke drawn over the land would leave the water beside it free to march inland, and the coastline would move anyway. With it, painting either side is enough and painting over the line — which is what a brush does — is enough twice over.

Verified exactly rather than by eye. An overlay painted coast_jitter: 0 over every pixel produces a map_class.png byte for byte identical to a run with coast_jitter_px at 0; the same overlay with marks on two stretches of one continent differs in 45 548 px of 2 800 800.

Everything else is inert, and travels

No pass reads a forest, a city or a road. Two bakes with and without them are the same terrain to the bit. What the marks do is come out the other end, in two shapes because two different things want them:

  • An 8-bit index raster beside every detail tile. Planet_x11_y07_overlay.png, one mark a detail cell, 0 for nothing. Indexed rather than one mask per mark, and that is not a size optimisation: marks cannot overlap, because the overlay is one painting and a pixel is one colour, so 254 of them fit in the file a single boolean mask would have taken.
  • Features in world metres, in overlay.json. An area gives a centre, an area, a radius and an extent per connected blob — enough to place a settlement. A path is thinned to its centreline and gives an ordered polyline and a length, because the thing built from a road on the other side is a spline and not a ribbon-shaped polygon. The centreline is the component's geodesic diameter: breadth-first from any cell to the furthest, then from there again keeping parents, and the walk back is the path — smoothed once and simplified with Douglas–Peucker at half a pixel, because a breadth-first walk leaves a D8 staircase.

The cylinder is handled where it bites rather than everywhere. A blob across the seam is one feature, and its centre is a circular mean of the longitudes: a plain mean would put the centre of a coastal town on the opposite side of the planet, which is the one failure mode a wrapped map has and the one nobody notices until a village turns up in the ocean. Its extent is measured relative to that centre, so it is the short way round. And a tile samples the overlay through world metres rather than through a tile-local index — the overlay is 12.9 m a pixel, the geology is 8 and a detail cell is 2, and the common frame is the only place the three agree. That is rule 1 of the tiling plan applied to a raster instead of to a noise.

The limit is stated rather than hidden: one stroke is one path. A fork reports its two longest arms as a single line and drops the third, because the geodesic diameter of a Y is a line through two of its arms. The remedy is an author's — paint each run as its own stroke — and terrain plan prints the piece count per mark, which is where a fork shows.

And the class table stopped lying about steepness

This is the other half of the same session's complaint — the lowlands still come out hilly — and it turned out to be a reporting defect rather than a physics one.

terrain plan printed the divide angle: S = U/(K·A^m) with A one cell squared, which for n = 1 is exactly what the rate fixes (D-49). It is correct and it is the steepest ground the rate can make, because A is smallest at the top of a catchment and almost none of a map is divide. An author reads it as the landscape, and sets every rate they own two or three times too hot.

Measured, on a 600² grid of 8 m cells with one coast, the manifest's own constants and 1000 steps:

U mm/yr divide median P90 over 3° over 8° max elevation
0.012 1.7° 0.58° 1.00° 4 % 2 % 32 m
0.045 6.4° 2.12° 2.85° 8 % 1 % 38 m
0.080 11.3° 3.72° 4.85° 75 % 1 % 54 m
0.250 32.0° 11.13° 14.03° 99 % 83 % 143 m

In tangent the median/divide ratio is 0.34, 0.33, 0.33 and 0.32 — flat enough over a factor of twenty in rate to quote as one number. The P90 ratio drifts 0.59 → 0.40 as the ground steepens, because the top of the slope distribution is where the repose clamp eventually binds; 0.45 is the middle of it and it is the weaker of the two. Both are fractions of the tangent, because the steady-state law is about slope.

So the table prints both columns and takes reads as from the median. highland at 0.25 mm/yr now reads as hill country at 11.7° rather than alpine at 32°; lowland's massif floor reads as a 0.6° plain rather than a 1.7° one. It is D-55's defect one level up — there a class was one rate and therefore one landscape, here the one number an author steers by was the one place in that landscape they would almost never stand.

And the preview lies about scale, which is where "the lowlands are hilly" actually came from

The complaint was raised against the real world rather than against a table, so the real world was baked to settle it: region 12, the 45.9 × 19.8 km central lowland, 9.0 M land cells, 1000 steps, 27 minutes. (Bake_007.)

region 12  done    0..47 m, 0.000% clipped, 0.09 mm/yr peak  [1611 s]
slope over the land:  p50 0.61°   p75 0.75°   p90 1.22°   p99 4.51°   max 7.7°
                      12.2 % over 1°,  4.4 % over 3°,  0.4 % over 5°,  none over 8°

Forty-seven metres of relief over forty-six kilometres, a median hillslope of six tenths of a degree, and nothing anywhere on it steeper than eight. That is not hill country by any measure, and it was never hill country. map_slope.png agrees: the continent is black with three faint massifs on it.

What made it look like hill country is preview.png. The hypsometric ramp's top is palette.land_top_percentile, a percentile of the world being drawn, so the ramp's whole span — green, tan, bare rock, white — was stretched over the 32 m between sea level and this continent's 99.5th percentile. Its 40 m hills therefore came out with snow caps, and the dendritic network cut into a plain at a fraction of a degree came out as visible relief texture everywhere. Redrawn against a fixed 400 m ceiling, the same heightmap is a flat green plain with four pale hill masses on it.

That is the same defect as the divide angle, one more level out: a relative picture is honest only when the reader is told it is relative. So the palette gains land_top_m, an absolute ceiling in metres, and WritePreview returns the ceiling it used so every run prints which one the colours meant:

preview the hypsometric ramp tops out at 32 m - the 99.5% percentile of *this* world's land, so rock and
        snow mean "the highest ground here" and nothing about scale. Set palette.land_top_m for an
        absolute ramp

The percentile stays the default, and deliberately: an absolute ramp over a world with no mountains is a flat green shape with nothing legible on it, and "is there drainage here" is a question the contrast has to answer. What was missing was never the option, it was the sentence.

What the lowlands are doing, then

At the massif floor a painted lowland is already a plain: 0.012 mm/yr comes out at a median of 0.58° in a controlled run and 0.61° on the real continent, with 4 % of it over 3° in both. What makes a painted lowland read as hill country in the numbers is its massif share — fraction 0.16 opens the ramp at the 76th percentile of the planet, so about a quarter of the class is off the floor — and the rate those raised parts climb to. Both are the author's, and the studio now shows the honest angle for each as they are typed.

2026-09-17. The coastline stops being a drawn line, and the template gets a tool (D-56).

coast_jitter_px had sat in the manifest since D-53, documented and defaulted at 1.5, and nothing anywhere read it — three grep hits, all of them in manifest.go. So every painted shore reached the solve exactly as it had been drawn, which is why the coasts read as brush strokes: an author draws a shore as a smooth curve because that is what a hand and a bezier tool produce, and a real coast has bays inside bays inside bays.

It is a mask on the signed distance, not a warp of the painting, and that was measured rather than reasoned. Displacing the point each cell asks the painting about was built first and it cannot cut a bay — a smooth warp of a smooth boundary is another smooth boundary, just wigglier, and the amplitude that would fold it back on itself drags every inland class boundary the same distance. What works is thresholding the signed distance to the waterline: add fractal noise to how far a cell is from the shore, ask again which side of zero it is on, and land juts out where the noise is positive while the sea reaches in where it is negative, at every scale the octaves cover, with nothing away from the shore moving at all.

Two things fall out of doing it that way. A cell that changes sides needs a class, and dt.Transform already returns the nearest seed and which cell it was, so new land grows out of the land beside it and sea eaten out of a shore becomes the surf that was lying against it rather than deep ocean. And small islands have to survive: an islet thirty pixels across under a four-hundred-pixel wavelength sees very nearly a constant, so it sits still or vanishes whole. The amplitude is capped per cell at two thirds of the widest land within reach — a sliding maximum of the land distance — and without that guard 2 of 12 test islets are erased. field.SlidingMax exists for it: a monotonic deque, O(1) a cell whatever the radius, because the naive window localRelief uses is seven billion comparisons at planet scale.

The mask exposed a classifier defect and made it load-bearing in the same step. A lossy codec blends across every boundary, and on this template the blend of surf (221,238,238) and lowland (153,204,102) is (186,219,174) — whose distance to desert (238,221,153) is 53.8 against 77.9 to either of the colours it was actually mixed from. So every temperate coast carried a one-pixel ribbon of spurious desert, 1607 pixels of it, invisible for as long as it was one pixel wide. The mask made those strays the nearest land to a stretch of open water and handed their class to everything it turned into shore: an eleven-pixel band of desert appeared along a green continent, and the mask was blamed for it first.

The fix had to be spatial, and the wrong one is instructive. The colorimetric rule — notice the pixel lies on the segment between two class colours, give it to the nearer — was built, measured and thrown away, because it cannot work in general and this legend is the proof: shelf (153,204,221) sits 10 units from the line between ocean and surf, so a real shelf pixel with codec noise on it and a genuine ocean/surf blend are the same point in colour space. That rule reclassified 943 000 painted shelf pixels. What distinguishes a stray is where it is, so Despeckle is a 5×5 majority: a one-pixel ribbon holds five of twenty-five against ten and ten, and a two-pixel band — something an author drew — already holds ten and is left alone. Three by three cannot see the case at all, which is why the window is five. It moves 0.068 % of the map and takes the stray desert from 1607 to 0.

And the studio. terrain studio serves a painting tool on loopback. The point of it is that the two halves of a painted world used to live in different programs — the shapes in an image editor that knows nothing about uplift rates, the meanings in a JSON legend that cannot show you where they land — so the brushes are the legend's classes: picking highland is picking 0.25 mm/yr, and the panel says that is 32 degrees at a divide and reads as alpine while you are painting it. plan is a button, seven seconds round trip, run against the painting in the browser rather than the file on disk.

Three limits are deliberate. It paints hard-edged exact colours — an antialiased brush would manufacture the very blend the despeckle pass exists to remove. The canvas wraps at the seam, because the world does and because the first template disagrees with itself on 9.4 % of its rows. And it saves by patching the text of the legend and the manifest rather than re-marshalling them, which is the same argument the palette writer already makes one file over: a legend is mostly commentary, MarshalIndent over a map[string]any returns it alphabetised with every comment moved away from the thing it explained, and a file a person wrote has to still diff after a tool touches it.

2026-09-18, the canvas stops being a 2D canvas (D-61). The template is 7738 × 3761 — twenty-nine million cells, a hundred and sixteen megabytes of pixels — and the first renderer put all of it through the 2D canvas on every pointer event: a full-width putImageData band to push the stroke back into an offscreen copy, then a imageSmoothingQuality: "high" downsample of the whole image, once per repetition across the seam. Both costs are linear in the size of the world and neither is a function of what the stroke touched, which is the shape of the bug: measured on the same machine and the same GPU, a 24-pixel brush and a 400-pixel one both cost about 105 ms an event at the zoom that shows the whole world. A mouse polling at a kilohertz asked for that a thousand times a second.

So the world is a GPU texture. A stroke uploads the rectangle it touched and nothing else, read in place out of the ImageData with no intermediate copy — which is what WebGL2's UNPACK_ROW_LENGTH is for, and the reason this needs WebGL2 rather than WebGL1 at all, along with a 7738-wide non-power-of-two texture that still wraps and still mips. Drawing is one textured quad. The seam comes free with REPEAT, which also fixes something the tiling loop could not: every repetition was a separate drawImage with texture coordinates of its own, so the derivative at the wrap was wrong and the seam blurred whenever the world was minified. Measured after: 6.0 to 6.3 ms, flat, in every case — fit zoom with a 400-pixel brush, 1:1, 4×, and a pan — which is the floor of the measurement rather than a number about the renderer. The old path's worst case was a 1.4-second p90 at 1:1, which is the canvas read-back stall that getImageData/putImageData on a GPU-backed canvas costs.

Three things follow that are not speed. The mip chain is what makes minification both correct and free, and regenerating it off a twenty-nine megapixel base is 4.6 ms, so it is rebuilt at most ten times a second while the brush is down, never while the world is magnified past 1:1 because nothing reads it there, and once for certain when the brush comes up. The two offscreen canvases are gone: full and ovFull are the only copies of either sheet, and a sheet is encoded to PNG when it is pushed rather than kept mirrored in a canvas the whole time, which is two hundred and thirty megabytes not held. And the page now survives losing the GPU context — a shader compile in the engine next door is enough to cause one — by uploading both sheets again, where before it left a black rectangle with an unsaved painting behind it and no way back but a reload.

What the author sees is a brush ring at the cursor in the colour about to be painted, because 400 pixels is 400 pixels at any zoom and the slider cannot say how much of this view that is; eased zoom that holds the point under the cursor, because at these scales one notch of the wheel is a factor of two and a jump has nothing for the eye to follow; f to fit the world and 1 for one cell to one pixel, which were both a lot of scrolling before; space to drag; and rendering at the screen's own pixels rather than at CSS pixels, which on a scaled display had been a smaller image stretched up — a soft coastline in a tool whose whole job is where the coast is. A shortcut typed into a number field is now a character and not a shortcut, which o in particular needed: it had been swapping the sheet under a half-written number.

And ctrl+z takes back a stroke. The same constraint decides its shape: a sheet is 116 MB, so a stack of snapshots is not a stack. The unit is one stroke — brush down, drag, brush up — because that is what a hand means by taking something back, not the last frame of it. What a step keeps is the pixels the stroke covered, by copy-on-write over a 256-pixel tile grid: a tile is copied out the first time a stroke writes into it, which makes the bookkeeping one Map lookup per stamp rather than rectangle algebra, and makes the cost of a step a function of what was painted rather than of the size of the world. A dab is one tile and 256 KB; a 60-pixel drag is two and 512 KB; a 400-pixel brush dragged 800 pixels is 42 tiles and 9.1 MB. The cap is 192 MB, and it bounds both stacks, because a new stroke empties redo and a step moves between the two rather than being copied into it.

Two properties are worth stating because they are what make it trustworthy rather than merely present. keepTiles is called from the top of stamp, which is the only writer, so there is no path by which a pixel changes that undo has not already recorded. And undo and redo are one function in opposite directions: applying a step swaps what it holds against what is on the sheet now, which is at once the undo and the construction of the record that redoes it, so neither direction needs a copy the other does not already hold.

One bug, recorded because the test caught it and reading would not have. A tile column is only meaningful inside [0, W). 7738 is not a multiple of 256, so the last column is 58 wide and the grid does not line up with itself across the seam — cutting a stamp's unwrapped rectangle into tiles and wrapping the indices afterwards gave columns 30 and 0 for a brush at x=2 that had also written into 29, and those pixels were gone for good. It passed every check on the pixels it was asked about and failed only a whole-sheet hash. The rectangle is wrapped into runs before it is cut into tiles now, which is the same order pushRect already uses one function above.

Both sheets are on screen at all times — the annotation layer is dimmed while the brush is on the classes, never hidden — so a step shows whichever tab it belongs to and no tab is switched under anybody. A map view is dropped, because it is the one thing covering what just changed.

2026-09-17, after the second whole planet. A class becomes two rates and a fraction (D-55).

The first painted planet's landmasses came out uniformly dissected — every divide on a continent at the same angle, from the waterline to the summit, with no flat ground anywhere on any of them. That is not a tuning miss either; it is the same arithmetic as D-49 read one step further. A class was one uplift rate, n is 1, so the rate alone fixes the hillslope angle, so one class is one landscape. lowland at 0.08 mm/yr is 11.3 degrees on every divide it touches; a 45 km continent painted with it is 45 km of continuous hill country.

Europe away from the Alps is not that. It is a plain at a fraction of a degree with isolated massifs standing out of it, and what separates the two is not the rate — it is that the rate is not the same everywhere. So a class carries a massif block: the class rate is re-read as the rate a massif reaches, floor_mm_yr is the plain between them, and fraction is how much of the class stands above the midpoint of the two. lowland became 0.08 over a sixth of its ground and 0.012 — 1.7 degrees, a plain a player can build on — over the rest.

One fabric for the whole planet, not one per class. planet.massif_wavelength_km, and every class cuts the same field at its own level. That is what makes a highland belt and the hills in the lowland beside it the high and low parts of one structure rather than two unrelated noises meeting at a painted edge, which is how a foreland and its outliers work. The wavelength has to sit well below the size of a landmass: at 12.5 km against islands of 20–45 km one island came out entirely above the cut — the original defect over again, only smaller — and 7 km puts several blocks across every continent.

The hard part was the threshold, and it is the one place this could have gone quietly wrong. A fraction has to become a cut in the fabric's values, and the obvious way to find that cut is a percentile of the grid. That is precisely what uplift.FromTemplate exists not to do (D-53): Build's percentile range band is a global operation over the grid it is handed, and two regions taking quantiles of their own extents would put the same physical hillside on different sides of the cut, so the planet would disagree with itself along every region boundary. A quantile of the planet is a different animal — one number for the whole world, computed by every region from the same samples because the samples are defined by the planet and not by the caller. It is a 1024-column probe of the cylinder binned into a histogram, about ten milliseconds, and TestTwoFramesAgreeAboutTheSameGround is the test that would have caught the percentile.

Cutting the ramp in probability rather than in the fabric's own values is what makes fraction a number an author can predict: exactly that share stands above the midpoint, half again reaches the class rate outright, and half again above that is off the plain at all. Cutting in value space would have made the realised share depend on the shape of the noise's distribution, which is not a number anybody should have to know, and it would have drifted every time an octave count changed.

Fraction is a share of the planet, deliberately, and therefore only the expected share of any one island. A small island gets all of a massif or none of it, exactly as it would if it were a real island that happened to sit on or off an orogen. Normalising per landmass would hand every island its quota of hills, which is the thing being fixed.

And the mislabel that caused it. internal/stats buckets anything under 0.1 mm/yr as "plain", and the legend's own commentary repeated it as guidance. Those boundaries are a reporting convenience calibrated for the procedural path's intraplate rates; 0.1 mm/yr is a fourteen-degree hillslope. Reading them as a description of terrain is how lowland was set ten times too hot, and the fix is that terrain plan now prints what each class reads as — plain, rolling, hill country, mountain, alpine — from the divide angle rather than from the rate, beside the massif floor and its own angle. The buckets themselves are unchanged: they are a reporting axis with a run of measured numbers behind them, and moving them is a separate decision.

Measured, Bake_004 against Bake_001 and Bake_003 on the same two region boxes. The statistic that matters is the slope distribution and not the peak, and internal/stats cannot produce one at planet scale yet (Terrain-Next 3.1), so this was taken off the 16-bit heightmap directly:

region 11, the lowland continent region 13, the highland island
before median 3.7°, 7 % under 2°, max 71 m median 6.7°, 4 % under 2°, max 183 m
after median 0.7°, 93 % under 2°, max 41 m median 2.0°, 49 % under 2°, p90 8.9°, max 167 m

The lowland went from ground that is gently sloping everywhere to a plain with hill masses standing out of it; the highland island went from uniformly steep to half foreland and a concentrated range.

And peak elevation fell much further than the arithmetic suggested it would — 71 m to 41 m on the lowland, when the massifs still reach the same 0.08 mm/yr. Relief on a continent is the integral of slope along the whole flow path, not a local property: before, every kilometre of a 45 km trunk was at 0.08 and climbing, and now the trunk crosses a 0.012 plain and only gains height inside a massif. The lesson for judging a bake is that max elevation cannot see this change at all and very nearly reported it as a regression.

map_uplift.png had to learn the fabric with it. It was rendered from the per-class constant, which is exactly the thing that stopped being true, and a diagnostic showing a landmass flat when it is not would have hidden the feature entirely. It builds the fabric at the image's resolution over the cells renderRGB actually point-samples — a couple of million noise samples rather than the planet's seventy-eight.

2026-09-17, after the first whole planet. Three things the legend could not say (D-54). All three came from looking at the bake rather than from the plan, which is the argument for baking something early.

The mountains reached the water, and that is arithmetic rather than a tuning miss. D-49 again: for n = 1 the uplift rate alone fixes the hillslope angle, so a uniformly painted highland island sits at the angle of repose everywhere, the shore included. The rivers do cut down to sea level — the channels are flat at the coast, because slope goes as A^-m and A is largest at the mouth — but the ground between the channels has no idea how far from the sea it is. The result is fjords from one end of the island to the other, which is not what most coasts look like.

coastal_plain_km ramps the rate from coastal_floor_mm_yr at the waterline up to the class rate over a stated distance, smoothstepped so the plain meets the range without a crease in the slope field — a crease there is a line of channel heads all beginning at the same distance from the sea, and it reads as a contour rather than as terrain. Measured on highland: 0.06 mm/yr at the water, 0.36 at two kilometres, 0.90 at four.

This is D-52 read carefully rather than reversed. That decision removed a coastal taper and the reasoning stands, but what it removed was a hidden one: the uplift was being multiplied by the continent mask, which went to zero at the waterline, so the hundred-metre strip the surf works in was flattened and every cliff began a hundred metres inland. This is opt-in, it is the author stating where their range starts, and the waterline keeps a real rate. Where the land ends still does not decide how fast it is rising; an author saying "plain here, range there" does.

A crater cannot be an uplift rate, and finding out why is the useful part. The obvious construction is negative uplift in the middle and positive at the rim. It does not survive: the priority-flood runs every step and raises every depression to its spill level, so the basin is filled in before the run is a hundred steps old. It is also the wrong model — an impact is an event, not a rate. It postdates the landscape it sits in, which is exactly what a pass running after the solve expresses, in the same place and for the same reason as the coastal pass.

The shape is derived from the painted blob rather than drawn. Distance inward from the blob's own boundary, normalised by its widest point, is a coordinate running 0 at the shore to 1 at the centre whatever size and shape the author painted, so one set of four numbers — rim_m, floor_m, rim_at, wall_at — describes every crater on the map. Both segments are smoothstepped, because a corner at the crest or at the foot of the inner wall is ground the detail passes would then spend their time sanding off. A crater across the seam is one crater, by the same wrap-aware flood the region partitioner uses.

A desert is not a low uplift rate, and that is the fourth thing. A wet lowland has one of those too. At the geology grid the only lever is k_mult, and below 1 it means less water doing less work: steeper, more angular ground held further from being worn down, which is right as far as it goes and is nowhere near enough to tell the two apart. The difference is at two metres, so a class may now override what the detail passes do on its ground - droplets_per_cell, strata_contrast and amplitude_m. Measured on a synthetic pair over identical terrain: 43 634 droplets moving 84 km of material against 2148 moving 4.1 km, which is a dendritic gully network against a few isolated wadis. Everything left out keeps the pipeline's number, and a legend that overrides nothing does not carry the class raster through the detail passes at all.

And the thing all of that turned up. The mountains came out of the first painted bake as flat polygonal faces with hard 45- and 90-degree edges - the repose clamp cutting along the eight D8 directions, doing not some of the shaping but all of it. Three wrong guesses on the way to that, and the order is the lesson: the strata hardness (turned it off, nothing changed), then the tile hillshade (WriteThumbnail's shading term is a raw gradient over the cell size, which is fine on a 512-pixel picture of a whole map and saturates to pure black and white at two metres a cell - a real bug, replaced with a standard DEM hillshade, and still not the cause), and only then the terrain itself. What settled it was a diagnostic rather than an argument: terrain tiles --no-detail writes the geology upsampled and nothing else, so the question "did the detail passes do this, or are they faithfully magnifying something the solve produced" has an answer in twelve seconds.

The arithmetic was available the whole time. Steady state is S = U/(K·A^m) applied down to a single cell, so at a divide A^m is the cell size and U_max = tan(talus)·K·cell - 0.280 mm/yr at 35 degrees, K 5e-5 and an 8 m cell. The legend's highland was 0.9, which is 66 degrees at a divide, 3.2 times over. terrain plan prints the implied angle for every land class now and names the ones that are clamped, which is four seconds against an hour and a half. And the consequence worth stating plainly, because it constrains what a painted world can be: at a fixed cell, relief and steepness are the same knob. U/K sets both, so there is no setting that gives a 700 m range with hillslopes below repose. More relief than that out of erosion-shaped ground needs the channelization threshold to work, which is the open problem in Terrain-Next 4.D.3.

And how a world is drawn is a file now. The ramp, the water, the rivers, the ice and the light were constants in internal/field; they are a palette the planet manifest points at, with the same numbers as the default. Separate from the legend on purpose - the legend says what the colours in the input mean and is about the world, a palette is about the picture and changes no height, so it is the part most likely to want swapping. terrain palette <path> writes the defaults out to copy. Hand-formatted rather than through MarshalIndent, which re-indents whatever a custom marshaler returns and so insists on putting every channel of every stop on a line of its own: sixty lines for eight stops, a table whose shape is invisible.

Bakes are versioned (Bake_001, Bake_002, ...) for the same reason: an hour and a half is too long to spend on a change you then cannot compare against what it replaced.

And the polar caps rendered as meadow. The hypsometric ramp tops out at snow by elevation, so an ice sheet fifty metres above the water gets the same green as farmland. A snow flag on a class fixes the picture and nothing else: no height moves, no pass reads it, and the cells still take the hillshade rather than being stamped flat, so a dome and the valleys cut into it still read. It is a material hint that has arrived early, and when there is a landscape material it will be what paints the ice.

2026-09-17, later. The detail passes, tiled (D-53 continued). Passes 8 to 12 and 14 are built, so there is a full-resolution output for the first time: 5 km tiles of 2500 samples at 2 m, about twelve seconds each, written with a hillshade beside them because a 16-bit grey PNG of a hundred metres of relief is a flat grey rectangle to look at.

internal/detail is a port of Scripts/Authoring/heightmap_erosion.py and every brake came across by name - the 0.25 slope gate, the per-step cut cap, the load cap, the 3×3 cut brush with the deposit on the droplet's own cell, and thermal shedding half the largest excess. What did not come across is how the randomness is drawn, and that is the whole of what tiling costs.

Droplets have to be a pure function of world position. The numpy picks spawn cells from an RNG stream, which is index-dependent: a cell would get different droplets depending on which tile it fell in, and every seam would show. Here a cell's droplet count, each droplet's sub-cell start, and which round it belongs to are all hashes of (seed, world cell). A droplet spawned in a tile's interior is then bit-identical to the one spawned when that same cell falls inside a neighbour's margin.

And the round count had to stop being derived from the droplet count. The numpy batches so that a channel deepens as more water follows it: droplets in a batch read the height as it was when the batch began. Derived from the total, the batch count depends on how big a piece of the world is being worked on - so a droplet would land in a different round in a tile than in the whole map, and the tiles would not close. It is a manifest number now.

The margin is measured, not reasoned. Rule 2 says to size it by how far a pass can move material, and for droplets that is not simply the lifetime: across rounds the cut edge's influence walks a lifetime further in each time, which taken literally would be rounds × lifetime - 640 cells against a 2500-cell tile. Measured against the same ground in one whole run, at lifetime 12 and 8 rounds:

cells in from the cut edge 0 4 8 12 16 20 24 32 40
worst difference, m 7.97 2.53 0.72 0.49 0.44 0.18 0.03 0.00 0.00

It is the first lifetime that carries almost all of it and by three it is gone, because a droplet has to be unlucky in the same way several rounds running for the error to keep propagating. Three lifetimes plus the brush is the margin: 122 detail cells at the default lifetime, 244 m, about five per cent of a 5 km tile on each side. At one round the margin of lifetime + 2 is exactly sufficient and the match is bit-for-bit, which is what TestATilesInteriorMatchesTheWholeMap asserts.

Four things that were wrong, three of which would not have failed loudly.

  1. The detail noise cannot use the world period. A noise lattice holds (period/wavelength)² floats, so an eight-metre octave on a hundred-kilometre period is a gigabyte and a half for one octave. The detail passes get a period of their own - a kilometre, which must still divide the circumference - and what repeats at that scale is a few metres of surface roughness with no shape to it. Everything with a shape comes from the solve and the paint, and neither repeats.
  2. The derivative maps were normalised by a percentile of the tile. field.ToUnit takes the 99th percentile of what it is given, which is right for one map of one world and wrong for a tile: it is a statistic of the piece being looked at, so two tiles would stretch by different anchors and their shared valley would come out two different greys. Exactly the mistake the coastal pass's exposure made and had withdrawn. Fixed full-scale values now.
  3. The sea has to be flattened before the upsample, not after. The geology raster drops from the shore to the painted ocean depth in one cell, so a Catmull-Rom upsample rings at every coastline; and with the sea left in place, thermal weathering finds the whole shore past the angle of repose and pours it in - the mirror image of the first run with a coast, which eroded the land to 174 m below sea level. Flattening first also means the detail land mask can be read off the interpolated height, which matters more than it sounds: taken up from the geology mask by nearest neighbour instead, the coastline came out as a visible staircase of 8 m blocks.
  4. The droplet stencils must not touch water. Both the 3×3 cut brush and the bilinear deposit straddle the waterline whenever a droplet is within a cell of it, and since the sea floor is restored afterwards, anything written there is silently thrown away - sediment that should have built a beach, quietly deleted. The cut is skipped and the deposit is given to the droplet's own cell.

The parallel decomposition had to be fixed too, for a reason worth keeping. The droplets scatter into per-band buffers that are summed afterwards, and the first version partitioned by core count: floating-point addition is not associative, so a cell's contributions summed in a different grouping gave a different last bit and TestParticleIsDeterministicAcrossGOMAXPROCS failed by one ulp. field.FixedBands exists for this - a partition fixed by the grid rather than by the machine. A loop that only writes into its own rows can be split any way at all; one that reduces cannot.

Also: thermal.Apply sliced the caller's scratch buffer without checking it, so the first tile bake ended in a panic rather than a weather simulation; it allocates when it has to now. And terrain tiles refuses a bake whose manifest has moved - a heightmap is 16-bit samples over an elevation range, so decoding one under a different range shifts every height, and when the shift takes the land below sea level every tile decides it is ocean and writes a flat zero. That happened, and there was nothing in the output to say why.

Measured, on the first template. 20 × 10 tiles of 5 km; twelve seconds a tile at 2 m with 1.4 M droplets, so the whole planet is about forty minutes of detail against two hours of geology. The tiles carry the heightmap, a hillshade, and flow, wear and deposit.

Still open: the coastal pass does not wrap, so the shore is still a step where the land meets the painted ocean depth and the coastal detail of §4.E3 has nothing to attach to; pass 13, the spawn pad, is deliberately skipped because a planet has no single centre; and the weightmaps of pass 14 are not derived, because nothing imports them yet. (The first of those was closed by D-60: there is a shelf and a shore to attach to now.)

2026-09-17, after the coast. Painted planets: a template becomes a world (D-53). The generator's source stops being a seed. RawContent/World/Templates/Map3.jpg — a hand-painted flat cylindrical world map, 7738 × 3761, X wrapping, ice caps top and bottom, a meteor-crater island straddling the seam — is now an input, and the question this round answered is what a painted map is allowed to say.

It says uplift, and it may not say height. That was already written down (Terrain-Next §3.2 and §6) and building it did not change it. What building it did change is how little else is left for noise to do: with the paint supplying the outline, the ranges and the rock, the procedural side of internal/uplift reduces to two things and only one of them is optional. The initial relief still only breaks the symmetry. The regional swell is not optional at all — D-49 is arithmetic, S = U/(K·A^m) applied down to a single cell, so a painted lowland holding one rate over forty kilometres has no divides anywhere and the router draws the priority flood's traversal order across it as rivers. It is the first thing that will be cut for time and it must not be.

The solve is decomposed per landmass, and that is exact rather than approximate. This document says the fluvial solve cannot be tiled and that is still true: drainage area is an integral over the whole upstream catchment. But it is not one problem. Ocean cells are held fixed at sea level for the entire run and nothing in the solve can move them — ComputeReceivers makes every outlet its own receiver, so a receiver chain starting on land terminates the moment it steps into water; StreamPower, both diffusions, the repose clamp and thermal.Apply all skip a fixed cell; the priority-flood closes every outlet before its loop and never re-enters a closed cell. So no flow path crosses open water, every basin is contained in one eight-connected land component, and solving a landmass in a box of its own gives the same answer on land as solving the planet whole. TestOceanCellsAreUntouchedByTheSolve asserts that premise directly, because if it ever stops being true the composite is silently wrong and nothing else in the suite would say so.

Measured, at 100 km around with the geology cell fixed at 8 m by D-48: the whole planet is 12500 × 6076 = 76 M cells, which is about 2.7 GB of fluvial.Grid before uplift has allocated anything, and 100 minutes at the old rate. Cut into landmasses it is 18 regions and 49 M cells, the largest 14 M, and the peak is under a gigabyte. Regions are found by dilating the land mask with one exact distance transform and connected-componenting the result — not by overlapping dilated bounding boxes, which are transitively closed and would have collapsed this template into a single region, because one landmass is 70 km wide.

The coast is not decomposed, and working out why was the useful part. The obvious move is to give each region enough margin for the coastal pass to run inside it, and the margin that needs is shelf_km plus slope_km, 4.6 km, which nearly doubles every region. What it buys is nothing, and what it costs is specific: the fetch is truncated across every strait, so two islands six kilometres apart stop sheltering each other; the sediment budget splits, and the boxBlur symmetry argument its conservation rests on has to be re-proved per region; and ShorelineKm, SeaFraction and the exposure percentiles become statistics that do not pool. Against that, the pass costs 83 ms at 3.2 M cells — 26 ns a cell, against 80 ns a cell per step for the solve — so the whole planet is about two seconds of compute. It runs once, over the finished cylinder.

Decompose the solve, not the map. The fluvial solve is the only pass that is both expensive and non-local, and it is exactly decomposable at the ocean. Everything else runs whole, with the wrap pushed into four primitives — the distance transform, the box blur, the fetch ray march and world-coordinate noise — and nowhere else.

Three consequences taken deliberately. A polar cap touches the top row of the map, and isOutlet treats every grid-edge cell as an outlet, so painted ice there would freeze at its initial relief while the interior eroded out beneath it — the exact failure continentMask's four per cent margin exists to prevent. The planet raster gains a few rows of synthetic ocean above and below the painted map instead: the caps become ordinary landmasses with a shore, isOutlet stays exactly as written, and the fiction lives entirely in rows that are discarded before anything is written out. A cap calving into a polar sea is, as lies go, the right one. A landmass that rings the planet is refused rather than approximated, because a region has to be a rectangle with water on both sides. And the seed alone no longer names a world: the margin and the minimum landmass size decide how the planet is cut up, and the priority-flood's epsilon ladder across a flat depends on the box it is flooding, so all three live in the manifest and all three are recorded in meta.json.

The router jitter moved to world coordinates, which is rule 1 of the tiling plan and was overdue. It is a hash of (seed, world cell) rather than of the flat grid index, so the same physical cell jitters the same way whichever region's grid it turns up in. It is one line of arithmetic and it re-baselines every measured number in Terrain-Next §1, which is why it was done first and on its own.

Four things the shape of the work revealed.

  1. The exact distance transform had three callers, not one. coast.edt was written for the coastal pass, and the region partitioner's dilation and the template classifier's stroke fill both want the same function. It is internal/dt now, and it gained a cylinder: the row pass lays the row out three times and reads the answer from the middle copy, so the three images of any column sit at offsets d, d-w and d+w, whose smallest absolute value is the cyclic distance — the envelope returns the wrapped answer with no special case in it. Exact, three times the row cost, twenty lines.
  2. A Field must not carry a world origin. It was the tidy option and it is wrong: a Field is used for masks, coordinate pairs and scratch, field.New has no origin to give them, and every one of them would quietly claim to sit at (0, 0). A wrong-by-default origin cannot be seen; a missing argument is a compile error. The half-dozen places that need world coordinates take a world.Frame instead.
  3. Every image writer was silently square. WritePreview, WriteDataMap, WriteBasinMap and WriteThumbnail all resampled to size × size, which is invisible on a square canvas and squashes a 2:1 planet into a lie. They keep the field's own aspect now, which is the same number on the old canvas.
  4. White is painted twice on a hand-drawn world map — the polar caps and an outline stroke around every island — so exactly one class can own the colour, and it has to be the stroke, because the stroke is the one that must be recognised wherever it appears. What the caps become is a derived class with no colour of its own, rescued by connectivity: a white region touching the top or bottom row is a cap, and every other white dissolves into whichever real class is nearest, split down its middle by the same distance transform. On this template the run reports 2 084 442 px rescued and 0 dissolved, which is the measurement that says white here is only ever the caps.

New packages: internal/world (the cylinder and the frame), internal/dt (the transform, moved and wrapped), internal/template (the image, the legend, the classifier), internal/region (the partition) and internal/planet (the driver). New commands: terrain plan, four seconds, which reads the painting and cuts the planet up without eroding anything, and terrain bake. RawContent/World/Planet.json is the planet's own manifest; World.json, world_manifest.py and create_world.py are untouched.

Still open here: the coastal pass does not wrap yet, so a bake lays the painted sea floor and the shelf, the surf and the sediment budget are not applied; internal/stats still sorts every land cell and its local relief is O(radius²), neither of which survives 28 M land cells; and the detail passes are still unbuilt, so there is still no full-resolution output. See Terrain-Next.md. (All three are closed now: the detail passes by D-53 continued, the statistics by D-59, the coastal pass by D-60.)

2026-09-17, last. The mask is thresholded, not multiplied (D-52), and a broken statistic is retired. The coastal pass reported a mean sea cliff of two metres, and the conclusion drawn from it — that the continent mask, by multiplying the uplift rate, made every coastline the lowest-uplift ground on the map — was half right and rested on a measurement that could not have said anything else.

The statistic first, because it is the more useful lesson. "Mean cliff" measured the drop from a cell to its seaward neighbour. That is a gradient: at the angle of repose one cell of a 10 m grid is 7 m, so the number was bounded above by 7 however tall the coast was, and it read 2 m on a plain coast and 3 m on a cliffed one because it could not distinguish them. It is now backshore height — the land's elevation between one and two surf reaches inland, median and P90 — and on that metric the coast always had cliffs: P90 88 m on seed 7, 108 m on seed 9342, 120 m on seed 67914, against a median of 3 to 9 m that correctly says the ordinary coast is a plain. A cliff is how far you fall, not how steep the first cell is.

The change is still right, for a narrower reason. rate = r * l tapered uplift to zero across the shore, and steady state is S = U/(K*A^m), so ground with no uplift grades to no slope. What that flattened was the hundred-metre strip the surf works in, not the backshore — so the cliff began a hundred metres inland instead of at the water. The mask now answers only "is this cell sea", which is the yes-or-no the solve needs for its base level, and a range that runs out to the water rises at range rates right up to it. Measured with everything else held: surf cut 23.32 → 38.87 Mm³ and planed area 3.0 → 3.9 km² on seed 7, 22.42 → 30.35 Mm³ and 2.4 → 2.9 km² on seed 9342, with the detail crop showing high ground reaching the waterline where a uniform low fringe stood in front of it before.

What the margin was quietly relying on. continentMask keeps land off the map border because a border cell is an outlet — it takes no uplift and is never eroded, so land that reaches it freezes while the interior erodes out beneath it. The margin tapers the mask, and while the rate was multiplied by the mask it was tapering the uplift too, as a side effect nothing named. TestBorderIsAlwaysOcean now asserts the invariant directly on three seeds and it holds: every border cell is ocean, the nearest land is five cells in, and that land drains to ocean at sea level, so nothing is frozen. The test also logs what the margin costs, which had never been measured: 14 to 15 % of the waterline lies inside the margin band, cut along a contour of distance-to-edge, which is a straight line parallel to that edge. Pre-existing, cosmetic, and made conspicuous by this change because the land there now carries the full 2.0 mm/yr.

And a warning about the acceptance test itself. The slope–area fit moved −0.480 → −0.312 on seed 7 and −0.698 → −0.720 on seed 9342 — opposite directions, and both inside a seed-to-seed spread on identical code (−0.480, −0.698, −0.575 at R² 0.317, 0.704, 0.944) that is several times the size of the effect. It is the number this document calls the proof that closes the work, and at five or six bins on a 1400 grid it cannot carry that on a single run. Pair it — same seed, before and after — and read at least two seeds.

2026-09-17, later still. The coast: a pass rather than a line. D-48 kept the continent because sea level is a better-posed base level for the solve than one outlet edge, and that is all it was: the mask said which cells were ocean, the solve held them at sea level, and afterwards the sea floor dropped to a flat plane at −180 m in a single step. A third of the map and a third of the elevation range was one flat surface; the land met the water at whatever angle the last erosion step happened to leave; no process in the generator knew the shoreline was there. internal/coast is the pass that does, and it runs after the solve because two of its three parts need the finished terrain.

The coordinate is a signed distance, not a line. Every coastal process is written as "how far is this cell from the waterline, and which stretch of shore does it belong to", so the pass opens with an exact Euclidean distance transform carrying a feature index — Felzenszwalb and Huttenlocher's two 1-D passes, O(n) whatever the radius. Exact rather than a chamfer: there is nothing to buy by approximating an O(n) algorithm, and a chamfer's 2 % anisotropy would show as a shelf wider along the grid axes than across them. Everything after it is a lookup.

The shelf is derived, not set. A margin is a gentle shelf out to a break, then a much steeper continental slope to the abyssal floor. Its width is read off the relief standing behind each stretch of shore, so a low coastal plain gets a wide shelf and a range that comes down to the water gets a narrow one, out of the same two manifest numbers and without either having been asked for. sea_floor_m keeps its meaning; what changed is that the depth between its two ends is now a function of distance offshore.

The cliff is a consequence. Within a reach of the waterline the land is planed towards a shore platform, and the reach is set by how open the water is. Nothing draws a cliff: the cliff is the step where the reach ends, so its height is whatever the land behind it stands at, which is the right way round. The cut rolls off only over the last quarter of the reach — rolling it off across the whole reach gives a ramp, which is what a coast looks like when it has been smoothed rather than eroded.

The sediment is accounted for. What the surf cuts is counted, carried a drift length along the shore and laid in sheltered water shallower than a few tens of metres; river mouths deliver their own load in proportion to what they drain, which is what makes a delta. The summary prints the volume cut, delivered, laid and left unplaced, because the sediment budget is the one part of this that is not derived from something already measured.

And the invariant that is now enforced in one place. uplift.Result.Bathymetry is gone. Ocean cells sit at sea level for the whole solve and the coast pass owns the sea floor outright, which is the same rule as before — a coastal cell drains into an ocean cell, and an ocean cell already at −180 m makes the solver cut the river down to −180 m — but with one owner instead of two.

Four things that were wrong first, each worth keeping.

  1. Exposure by percentile. Stretching the map's own 5th-to-95th percentile onto 0..1 is robust and collapses to nonsense on a coast that does not vary: a straight one has no spread, so the whole continent came out at one end of the scale. It is also a global statistic, which rule 1 of the tiling plan rules out — two tiles would stretch by different anchors and their shared bay would be two different colours. The anchors are now fixed and physical.
  2. Fetch in every direction. That counts the land behind the shore as shelter, and every coast has land behind it, so a straight open coast — where seven rays in sixteen stop after one cell — scored as more sheltered than the back of a bay half a kilometre wide. Restricting to the seaward half-space, cosine-weighted from the shore normal, is the standard effective fetch and gets the sign right.
  3. cut_fraction as a fraction of the height above the platform. Fifteen per cent of a 120 m headland is 18 m, which is not a rough platform, it is an uncut headland. The residual is capped at a few metres.
  4. The deposition kernel, twice. dep = blur(supply) * want / blur(want) looks like a normalised convolution and is not one: the blur spreads supply onto land, deep water and exposed headlands, all of which want nothing and are skipped, and 68 % of the budget was silently dropped there. The conserving order is to divide the supply by the blurred want first, then blur, then multiply by the want — which sums to exactly the supply. And one kernel cannot do both jobs: the sediment needs zero padding to keep the kernel symmetric, which is what the balance rests on, while a carried value like the shelf width needs edge clamping, and smoothing the width with the mass-preserving kernel shrank every shelf near the border to nothing. There are now two, sharing their arithmetic so they cannot drift apart.

What the pass measured about the continent, which is the part that mattered most. With the coast built, the fetch reported that the median stretch of shoreline was fully open: there were no bays. The cause is the continent outline itself — five octaves over a 14 km map puts its finest feature at about 450 m, which is a smooth blob, and a coastline is fractal, which is the whole content of the Richardson paradox. Sweeping the outline's octave gain on seed 7 at 1400, everything else held:

outline gain 0.50 0.58 0.62 0.66
shoreline 64 km 81 km 96 km 114 km
median shore exposure 1.00 0.98 0.84 0.51

and the octave count, at gain 0.50, gave 59, 63, 64, 65, 66 km at 5, 7, 8, 9, 10 — flat past 9. D-51 takes 8 octaves at gain 0.62: islands, inlets and headlands that shelter each other, without the outline breaking into speckle. The whole coastal pass costs 83 ms at the manifest's geology grid, against 130 s for the solve.

2026-09-17, later. The plains problem: the uplift field, the router's flat ground, and the hillslope law. The complaint was that the lowlands read as mountains that had been shrunk - same texture, same shading, lower peaks. It was not an erosion-tuning problem and no amount of work downstream would have fixed it.

The diagnosis, which is arithmetic. Steady state is S = U/(K*A^m). With critical_area_m2 at 0 that law is applied down to a single cell, so at every divide A = cell^2; at K 5e-5, m 0.5 and a ~10 m geology cell that makes S = U/4.8e-4. An intraplate rate of 0.25 mm/yr is therefore a 28 degree hillslope and the 0.9 mm/yr swell is past the 35 degree repose angle - so the repose clamp, which is meant to be a mountain process, was the surface of the entire continent. For n = 1 the uplift rate alone fixes the hillslope angle: U sets how high the summits get, not how steep the ground is.

The measurement that says so is new, and is the first thing built: stats.UpliftBuckets splits the land by the uplift rate that caused it - plain below 0.1 mm/yr, rolling to 0.5, mountain above - and reports each bucket's median and P90 slope, local relief over a 500 m window, and the fraction pinned within 2 degrees of talus. Uplift is the right axis because it is an input: a cell does not change bucket when the run does, which elevation-banding cannot promise. Map-wide aggregates cannot answer "are the plains plains", which is why this was invisible for so long.

Run plain, % of land plain median mountain median mountain at talus slope-area R2
Before 1 % 6.5 deg (sea cliff) 26.4 deg 32 % 0.688 at -1.59
Uplift fixed (D-49) 42 % 0.8 deg 31.1 deg 44 % 0.055 at -0.26
plus router jitter (D-50) 42 % 0.8 deg 31.1 deg 44 % 0.459 at -0.33
plus nonlinear hillslope 42 % 0.8 deg 28.5 deg 33 % 0.225 at -0.38

The first row is the whole diagnosis in one line: 81 % of the land sat in the mountain uplift class and the plain class held 1 %, all of it coastal cliff.

Fixing the uplift field immediately exposed the next thing, exactly as expected: once the plains were genuinely flat, the only gradient across them was the priority-flood's epsilon and the router drew the flood's traversal order as rivers. Hence D-50. The jitter costs nothing and recovered most of the slope-area fit on its own.

The hillslope law is now q = D*S/(1-(S/Sc)^2) (internal/fluvial/hillslope.go), replacing linear diffusion. It is linear diffusion as S -> 0, so the lowlands are untouched, and it is mass-conserving, which the clamp is not. Three things are worth knowing about it:

  • It is stiff, and the stiffening is bounded. D_eff = D(1+u^2)/(1-u^2)^2 diverges at u = 1; at the defaults u = 0.9 alone wants seventy sub-steps a step. So u is capped at slope_cap and, if the max_hillslope_substeps budget cannot buy even that, the cap is lowered further rather than the sub-step count truncated. Truncating is the tempting branch and it is wrong: it leaves alpha above the stability limit and grows a checkerboard over hundreds of steps, which by then looks like texture.
  • It therefore cannot replace the clamp. A belt rising at millimetres a year asks for slopes no bounded-flux transport law holds; that is a fact about the forcing, not the scheme. The clamp stays in the loop.
  • What changed is the order. Diffusion runs after the clamp, every step. The clamp cuts along eight D8 directions and leaves grid-aligned pyramid faces - the blocky facets visible in every earlier mountain preview - and a symmetric five-point stencil rounds them off before the next step sees them. Clamping once at the end instead was tried and measured: a thousand steps of growth arrive together, it cuts deeply, and nothing runs afterwards to soften it. The facets came back.

The maps. A run now also writes map_uplift, map_erodibility, map_slope, map_relief, map_flow and map_basins beside the preview (internal/field/datamap.go). preview.png says whether the landscape looks right; these say why, and the uplift map would have shown this whole problem at a glance with no arithmetic at all. map_basins is the direct test of whether the solve made a network rather than scratches.

Three findings worth remembering

  1. A channelization threshold still fails, and now we know what it is waiting for. Re-measured after the uplift fix: critical_area_m2 1e4 sends the plains back to 7.0 degrees, pins 49 % of the rolling class and 79 % of the mountains against the clamp, and collapses the slope-area fit to R2 0.001. The hillslope it creates has to shed its uplift by diffusion and at D 0.02 it cannot, so the clamp takes the job. It is not a tuning question; it needs a transport law strong enough to pair with, and it stays at 0 until there is one.
  2. The nonlinear flux was written in height differences and fed a slope. u = dh/Sc instead of dh/(Sc*dx) makes u a factor of dx too large, which pins every face against the cap and turns the whole law into linear diffusion with a constant multiplier. It produced better-looking terrain than the correct version, because over-smoothing hides facets. The unit test caught it; the preview did not.
  3. Two of the four new tests passed while measuring nothing. One read the fixed border cells back and called them the result; the other wrote a checkerboard across the fixed border, which then re-injected it into the interior for ever, so the scheme was blamed for a boundary condition. A test on a grid whose edge is an outlet has to say which cells it is actually asking about.

Still open

  • The hypsometric integral is still 0.10, and the reason has changed: it is no longer a bimodal uplift field, it is that 42 % of the land is now a near-sea-level plain. Whether that is wrong depends on whether a broad low continent is what is wanted; it is a question for the continent block, not the solve.
  • Half the land is still in the mountain uplift class (51 % on seed 7, 44 % on seed 9342), because rangeMask ramps from the 40th to the 86th percentile and anything above about the 55th clears 0.5 mm/yr. Real continents are nothing like half mountain. This is the next uplift-field question.
  • Nature does not like straight lines, and three sources remain. The fault traces are single 8-point parabolas with a hard cutoff at the tips, and their polygonal influence regions are plainly visible as straight-edged facets in map_uplift. The range grain is across*2.2 with one warp octave, so chains run as straight parallel bands. And multiple-flow-direction accumulation would dissolve what is left of the diagonal river grain that the jitter only reduced. None is built.

2026-09-17. Build-order steps 3 and 4: the Go skeleton and the fluvial solver. Nothing in the editor has been touched, so steps 1 and 2 (the viewport, the edit layers) are still open and still first in the list above; they need the editor closed and it was open. The numpy pipeline is untouched and still the thing that builds L_World.

Built, in Tools/Terrain/ (module salty/terrain, Scripts/build-terrain.sh):

Package What it is
internal/field Field, the one array type: shape, cell size in metres, float32 data. Resampling on the vertex convention, Catmull-Rom integer upsample, five-point blur, slope, curvature, greyscale PNG in and out through image/png, hillshaded thumbnails. field.Rows is the only place goroutines are created
internal/manifest World.json over Go-side defaults, the height contract mirroring world_manifest.py, and a validator that refuses a resolution the importer would turn into thousands of components
internal/noise Value noise, fBm on warped coordinates, Worley crest lines, per-pass PCG sources
internal/uplift The continent, the uplift rate field and the small initial relief
internal/fluvial The solver: priority-flood, D8 receivers, the stack, drainage accumulation, the implicit stream-power update, sub-stepped hillslope diffusion
internal/stats Slope–area, hypsometry, slope histogram, drainage density, and the verdict line
internal/check The two integration tests

The proof. TestSteadyStateMatchesStreamPower puts uniform uplift on a uniform grid and checks the analytic answer of dh/dt = U − K·A^m·S^n, which is that K·A^m·S^n / U is 1 at every channel cell. It measures 1.0000 over 822 channel cells. On the real continent the fitted slope–area exponent is −0.498 against an expected −0.500 at R² 0.996, and drainage density is 1.33 /km, inside the real-world 1–10 band. That is what build-order step 4 asked for and it is met.

TestDeterministicAcrossGOMAXPROCS hashes the pipeline output at GOMAXPROCS 1, 2, 4, 8 and 16 and requires one hash, which is the assertion this document makes about Go and cross-cutting rule 12. It passes.

Where it differs from the spec above

  • fill_every is 1, not 50, and the table in the fluvial section is why. The 50 was written to protect the time budget and it silently destroyed the solve.
  • 1000 steps, not 5000. Measured, not assumed; see the time note. This is most of the budget back.
  • The priority-flood uses a monotone bucket queue, not a binary heap. The flood pops in non-decreasing elevation and never pushes below the current front, which is exactly the condition that makes a bucket queue valid, and it removed the log n from two thirds of the runtime for a measured 1.6× on the whole solve at identical output (−0.498 against −0.500 before).
  • Slope–area is measured two ways and the normalised one is the verdict. Steady-state stream power gives the same gradient but a different intercept per uplift rate. This map's uplift spans 0.2 to 5 mm/yr, so regressing every channel together stacks twenty-five-fold-separated parallel lines and fits nonsense: the raw fit reads −0.70 at R² 0.91 on a landscape whose true exponent is −0.50. Slope is normalised by (U/K)^(1/n) first. The raw figure is still reported beside it.
  • Continent is a pipeline block the spec above does not list, because D-48 kept the coast and the coast needs parameters.
  • uplift.Result carries Bathymetry separately from Height. The sea floor is held at sea level for the duration of the solve and put back afterwards. Left in, a coastal cell drains into an ocean cell at −180 m and the solver obligingly cuts the river down to −180 m; the first run with a coast eroded the land to 174 m below sea level. A river's base level is sea level; the bathymetry is scenery.

Three bugs worth remembering

All three produced plausible-looking terrain, which is the point: none of them would have been caught by looking at it.

  1. Slope–area measured with the topographic gradient instead of the channel gradient. For a cell on a valley floor the central difference is dominated by the valley walls, not by the direction the water runs. The exponent read −0.78 where the truth was −0.50. S in the stream-power law is (h − h_receiver)/L and nothing else.
  2. Cells at a local minimum were skipped entirely, uplift included. They are the cells differential uplift is actively pushing up, so freezing them removes exactly the basins that should be forming. Fixed by letting a root still rise.
  3. The fix for (2) then uplifted the outlets. The map border is an outlet but is not ocean, so on a map with no coast every border cell had Receiver == self and Base == false and base level rose 2 m a step with the whole landscape chasing it. The steady-state ratio read 0.03 instead of 1.0. The union of "ocean" and "border" is now a single fixed mask. The real runs were never wrong, because there the border is ocean — only the test had no coast, which is why it caught it.

Still open

  • Passes 1, 3 and 4 (plates, faults, lithology) are not built; uplift currently derives its rate field from the percentile-thresholded range band alone, which is build-order step 5.
  • The hypsometric integral is 0.11, against 0.4–0.6 for a mature landscape, and it has been at 0.11 in every run. The map is a wide, near-flat coastal plain with mountains on a fraction of it, which is what an intraplate rate of 0.2 mm/yr against a convergent 5 mm/yr produces: a 25-fold ratio is bimodal by construction. This is a tuning question for the uplift field, so it belongs to step 5, but it is the next thing that will look wrong.
  • Everything from the upsample onward (passes 8 to 14) is unbuilt, so there is no full-resolution output yet and the canvas has not moved: the manifest is still 4081 at 350 cm.
  • The fluvial pass costs 256 s at the real geology grid, against 120 s budgeted, and relief there reaches 2605 m against D-48's 1536 m ceiling. Neither is a defect in the solver — the first is arithmetic and the second is U/K untuned — but both are decisions waiting at steps 5 and 6. See the time budget.
  • The exponent at 1786² is −0.640 after 1000 steps, still converging toward −0.5 while R² holds at 0.990. A straight plot at 0.64 is a perfectly ordinary real landscape (measured concavities run 0.35 to 0.6), so this is a question of how long to run rather than a fault, and it trades directly against the item above.