Files
UnrealPrototyping/Docs/Terrain.md
T

739 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<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`:
```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.