# 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`](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/`](Spec/README.md). 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`](../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 | | --- | --- | --- | --- | --- | --- | | 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) | | 8 | Upsample | → detail | all | all | New | | 9 | Detail noise | detail | `height`, slope | `height` | From today's detail octaves | | 10 | Strata | detail | — | `hardness` | Today's, unchanged (vertical) | | 11 | Particle | detail | `height`, `hardness` | `height`, `wear`, `deposit` | Today's, demoted to detail | | 12 | Thermal (fine) | detail | `height` | `height` | Today's, unchanged | | 13 | Spawn pad | detail | `height` | `height` | Today's, unchanged | | 14 | Derive | detail | everything | weightmaps, flow/wear/deposit/curvature, `meta.json` | Today's rules, ported | Notes where this departs from the spec, each for a reason: **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 ``` ```go 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: ```bash 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///` 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`: ```json "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`](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-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.