1900 lines
138 KiB
Markdown
1900 lines
138 KiB
Markdown
# 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 |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| 0 | Template | geology | a painted map and its legend | `class`, `landMask`, `uplift`, `K` | New (D-53). Replaces 1-4 when present |
|
||
| 1 | Plates | geology | — | `uplift`, `boundaries` | New |
|
||
| 2 | Continent | geology | — | `landMask`, `baseLevel` | From today's continent falloff |
|
||
| 3 | Faults | geology | `boundaries` | `uplift`, `faults`, `warp` | New |
|
||
| 4 | Lithology | geology | — | `K` | New (plan view) |
|
||
| 5 | Base relief | geology | `uplift`, `warp` | `height` | From today's noise, amplitude cut hard |
|
||
| 6 | Fluvial | geology | `height`, `uplift`, `K`, `baseLevel` | `height`, `flowAccum`, `flowDir` | New — the point of the exercise |
|
||
| 7 | Thermal (coarse) | geology | `height` | `height` | Today's, unchanged |
|
||
| 7b | Coast | geology | `height`, `landMask`, `flowAccum` | `height`, `landMask`, `exposure` | New (D-51) |
|
||
| 7c | Craters | geology | `height`, `class` | `height` | New (D-54). An impact is an event, not a rate: a closed basin cannot survive the flood |
|
||
| 8 | Upsample | → detail | all | all | **Built** (D-53). Per tile, sea flattened first |
|
||
| 9 | Detail noise | detail | `height`, slope | `height` | **Built**. Its own short noise period |
|
||
| 10 | Strata | detail | — | `hardness` | **Built**. Feeds pass 11 rather than standing alone |
|
||
| 11 | Particle | detail | `height`, `hardness` | `height`, `wear`, `deposit` | **Built**. Ported; spawning is a hash of world position |
|
||
| 12 | Thermal (fine) | detail | `height` | `height` | **Built**, unchanged |
|
||
| 13 | Spawn pad | detail | `height` | `height` | Skipped on a planet: there is no single centre |
|
||
| 14 | Derive | detail | everything | weightmaps, flow/wear/deposit/curvature, `meta.json` | **Part built**: flow, wear, deposit and a hillshade per tile; the weightmaps wait for something that imports them |
|
||
|
||
Notes where this departs from the spec, each for a reason:
|
||
|
||
**Pass 0 replaces passes 1 to 4 rather than feeding them (D-53).** A painted template is a *statement* about
|
||
where the ranges are, which is exactly what plates, faults and lithology exist to invent. When one is present
|
||
the continent mask, the percentile range band, the normalised swell and the percentile lithology split are all
|
||
skipped - every one of them is a global operation over the grid it is given, and a region is not a world, so
|
||
two regions taking percentiles of their own extents would disagree about the same rock. What survives is the
|
||
initial relief, rebuilt on world coordinates, and a long-wavelength swell modulating the painted rate, which
|
||
is not decoration: D-49's arithmetic says a uniform rate over a wide area has no divides at all, so without it
|
||
a painted plain comes out table-flat with the priority-flood's traversal order drawn across it.
|
||
|
||
**Continent and sea (pass 2) are not in the spec at all.** The spec builds an inland region with an outlet on
|
||
one edge and leaves the boundary condition as an open question. This world has a coast, a sea plane
|
||
(`World_Sea_Proto`), a `sea_level_m` manifest key and statistics reported as "% above sea level". Keeping the
|
||
continent is also the better *simulation* choice: sea level is a fixed base level on every cell the land mask
|
||
calls ocean, which is a far better-posed boundary for a stream-power solve than one fixed edge, and it removes
|
||
the artificial drainage divide that a single-outlet map has along three of its sides. `[DECIDED]`: the world
|
||
keeps its coastline, and the spec's §9 boundary question is closed in favour of the continent.
|
||
|
||
**Lithology and strata are both kept, because they are orthogonal.** The spec's lithology is a plan-view field
|
||
of rock types multiplying `K` by 0.5× / 1× / 3×; the existing strata model is *vertical* banding of hardness
|
||
with a slow tilt, which is what puts shelves and ledges on a cliff face. One varies with where you are, the
|
||
other with how deep you have cut. Lithology enters the fluvial solve at geology resolution; strata scales the
|
||
particle pass at detail resolution, exactly as today.
|
||
|
||
**Uplift replaces the range mask.** Today `heightmap_noise.generate_metres` builds ranges from an elongated,
|
||
warped, percentile-thresholded band, thresholded so ranges and foothills cover about two fifths of the map
|
||
whatever the seed. That percentile trick is the thing that makes the result seed-independent and it is kept —
|
||
but it now shapes the *uplift rate field* that the fluvial pass integrates, not the height directly. The
|
||
spec's own constraint (20–40 % of the map at low uplift, so there are basins to build in) is the same
|
||
statement from the other end, and both are enforced by thresholding on percentile rather than on value.
|
||
|
||
**Base relief amplitude is cut hard.** The spec says 50–150 m × normalised uplift and it means it: the fluvial
|
||
pass is what produces relief, and starting it from 2600 m ridged crests means it spends its whole run tearing
|
||
them down. Today's noise makes the mountains; after this it only breaks the symmetry.
|
||
|
||
**Particle erosion keeps every brake it has.** Demoted from "carves the valleys" to "detail only", which is the
|
||
spec's §4.8, and explicitly forbidden from reshaping what the fluvial pass produced: the slope gate, the
|
||
per-step cut cap, the load cap, the 3×3 cut brush and the own-cell deposit all carry over unchanged, and the
|
||
droplet count scales with cell count (about 9 M at 7141² to hold today's density at 4081²).
|
||
|
||
### The fluvial pass, in detail
|
||
|
||
`dh/dt = U − K · A^m · S^n`, with `m = 0.5`, `n = 1`, `K` from the lithology field around a base of 2e-5 to
|
||
1e-4 /yr with A in m². Braun & Willett's 2013 implicit formulation: compute D8 receivers by steepest descent,
|
||
build the stack, update up the stack. It is O(n) per step and unconditionally stable in `dt`, which is why it
|
||
is the right solver and a naive explicit one is not.
|
||
|
||
- **Pits.** Priority-flood (Barnes) to fill or route depressions, because a D8 receiver graph with a pit in it
|
||
has no path to base level and the implicit update has nothing to solve against. **It runs every step.**
|
||
`fill_every` survives as a knob and its value is 1.
|
||
|
||
This corrects the paragraph that stood here, which set it to 50 to protect the time budget. That was
|
||
wrong, and measurably so: uplift reaches 5 mm/yr, which at `dt` 1500 is 7.5 m a step, so fifty steps is up
|
||
to 375 m of differential uplift between floods — far more than enough to close basins that then sit
|
||
unrouted while everything upstream of them stops eroding. Measured at 512² over 3000 steps, against an
|
||
expected exponent of −0.5:
|
||
|
||
| `fill_every` | 1 | 5 | 10 | 25 | 50 |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| exponent | **−0.500** | −0.277 | −0.121 | −0.061 | −0.100 |
|
||
| R² | **0.992** | 0.861 | 0.318 | 0.120 | 0.371 |
|
||
| run | 93 s | 38 s | 32 s | 28 s | 27 s |
|
||
|
||
Even every fifth step is already broken, and the saving is not worth having. The budget is paid for
|
||
elsewhere — see the time budget below. The Cordonnier/Barnes lake-flow variant, which routes through
|
||
depressions instead of refilling them, remains the fallback if the flood ever has to get cheaper.
|
||
- **Hillslope diffusion** `D = 0.01–0.05 m²/yr` after each step, which is what rounds the divides and stops
|
||
the channel heads from being needles.
|
||
- **Time.** `dt = 1000–2000 yr`, **1000 steps, 1.5 Myr**, not the 5000 steps and 5–15 Myr the incoming spec
|
||
asks for. That figure is generic advice; at this K and this scale the knickpoint celerity `K·A^m` puts the
|
||
response time of a trunk channel near 45 000 yr, so 1.5 Myr is already tens of response times. Measured at
|
||
512², the exponent is −0.499 at 500 steps and does not move afterwards. Finer grids need more, because
|
||
headwaters carry small drainage areas and so respond slowest: at 1024² the exponent is −0.640 at 500 steps
|
||
and −0.584 at 1000, still converging while R² sits at 0.99. Stopping early is the knob for a "young",
|
||
high-relief look, and it is a real one — the landscape is straight-line graded long before it is finished.
|
||
- **Parallelism.** The stack update is sequential *along a flow path* but independent *between basins*.
|
||
Partition by basin, not by row, and reduce in basin-id order so the result does not depend on scheduling.
|
||
|
||
**The parameter ranges and the canvas are not jointly consistent, and this must be checked every run.** Steady
|
||
state puts channel slope at `S = U / (K · A^m)`. At the spec's aggressive corner — `U` = 5 mm/yr, `K` = 2e-5 —
|
||
a cell draining 1 km² sits at 25 % slope, and integrating that up a 7 km profile overshoots the 1536 m ceiling
|
||
badly. At the gentle corner it undershoots into a plain. `U/K` is effectively the single relief knob and the
|
||
ceiling is a hard clip in the 16-bit encoding, so the generator reports the fraction of the map it clipped, as
|
||
`generate_heightmap.py` does today, and a run that clips more than a fraction of a percent is a failed run, not
|
||
a rounded one.
|
||
|
||
## The Go core `[DECIDED]`
|
||
|
||
Go, per the user's decision, in `Tools/Terrain/`, module path `salty/terrain`, never published. The binary is
|
||
built to `Tools/Terrain/bin/` and is gitignored; `Scripts/build-terrain.sh` builds it beside `build.sh`. Go
|
||
1.25.0 is already on the development machine, which has 16 cores, so the toolchain costs nothing and
|
||
`math/rand/v2` is available — but Go is not in the repository and not in the engine's toolchain, so a machine
|
||
that regenerates the world needs its own install, and `build-terrain.sh` says so when `go` is missing.
|
||
|
||
```
|
||
Tools/Terrain/
|
||
go.mod
|
||
cmd/terrain/main.go the CLI
|
||
field/ Field, HeightField, resample, upsample, PNG in and out
|
||
pass/ one file per pass, each implementing Pass
|
||
pipeline/ Context, Pipeline, the manifest reader
|
||
stats/ slope-area, hypsometry, drainage density
|
||
```
|
||
|
||
```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-19. The ocean was thirty metres deep (D-64).**
|
||
|
||
Raised as "it is just a landmass and no oceans really", against the exported heightmap rather than against the
|
||
preview, and the complaint is exact. Measured on `Bake_020`, the whole 100 km planet at 1000 steps:
|
||
|
||
```
|
||
below 0 m 63.7% of the planet, above 36.3%
|
||
-520 m 11.71% the painted abyss
|
||
-500 m 5.06%
|
||
-480 .. -60 m about 0.25% a 20 m bin, the whole continental slope
|
||
-40 m 2.72%
|
||
-20 m 26.80% <- the shelf
|
||
0 m 23.56% land and shallows together
|
||
```
|
||
|
||
The legend paints `ocean` and `deep` at **512 m over 55.9 % of the planet**. Seventeen per cent of it gets
|
||
there. Forty per cent of the world is water between nought and thirty metres, which at the manifest's
|
||
−1024…2048 m encoding is 1 % of the 16-bit ramp away from sea level — so in `planet_height.png` the shelf and
|
||
the land are the same grey, every landmass wears a halo the width of its shelf, and the halos join the
|
||
continents into blobs. Stretched to its own data range the picture is one white shape on black. There are no
|
||
oceans in it.
|
||
|
||
### The number came from the other canvas
|
||
|
||
`coast.Build` takes a `BreakM`, the depth at the shelf break, and both call sites computed it as
|
||
`-m.Pipeline.Continent.SeaFloorM.Hi()`. The default is −30 m, and the comment beside it says exactly why:
|
||
|
||
> The canvas is 14.28 km a side and the sea is a third of it, so a real shelf — 75 km out to a break at
|
||
> 130 m — does not fit and is not what these numbers are.
|
||
|
||
It is not what those numbers are, and the painted path took them anyway. `continent` is the square canvas's
|
||
synthetic-mask generator; D-53 decomposed the planet away from it, and this was the piece left holding on.
|
||
`AbyssM` was already per-cell from the painting (D-60) and the break never was, so the pass was joining a
|
||
painted 512 m ocean to a 30 m bench and only the bench was ever visible.
|
||
|
||
**What made it invisible is arithmetic nobody had done.** The derived margin reaches
|
||
`shelf_km.hi() + slope_km` = **4.6 km** from every shore. The first template has **1069 km of shoreline**
|
||
against a **3111 km² sea**, so 4.6 km of margin on that shoreline is **4917 km²** — more than the whole ocean.
|
||
Every strait on the planet is narrower than twice the reach, so on every one of them the painting is never
|
||
consulted and the sea is whatever `BreakM` says. At 30 m that is a pond. The pass was not wrong anywhere; its
|
||
profile is monotone and correct at any break depth, which is why every profile test passed and why the defect
|
||
had to be found in a histogram instead.
|
||
|
||
### What changed
|
||
|
||
`pipeline.coast.break_m` is a key. `Manifest.ShelfBreakM()` returns it when set and the old
|
||
`continent.sea_floor_m` reading when not, so **the square canvas keeps the behaviour it had** — verified, it
|
||
still prints `break at 30 m`. `fillPlanetDefaults` gives a planet **130 m**, which is a real shelf break and
|
||
is also the depth the first template's own `shelf` class is painted at, the same number arrived at from the
|
||
author's end. 512 m over the 4.6 km margin is a 6.3° continental slope, so the margin's *width* was never the
|
||
problem and is unchanged.
|
||
|
||
Two more copies of the same read were in `tiles.go`, both of them commented as "the shelf break":
|
||
`restoreSeaFloor` blends interpolation into nearest-neighbour over a band at the break, so at 30 m every tile
|
||
would have staircased the whole of the new shelf at the geology cell; and the tile hillshade clamps the water
|
||
at the break, so at 30 m it would have flattened the shelf to one tone. Both now read `ShelfBreakM()`.
|
||
|
||
And the bake says what its sea floor is, because nothing did:
|
||
|
||
```
|
||
sea floor: shelf 0.6..3.0 km to a break at 130 m, then 1.6 km of slope to the painted depth,
|
||
so the painting owns the water past 4.6 km offshore and nothing nearer
|
||
```
|
||
|
||
### The range, which is the other half of the picture
|
||
|
||
`elevation_m` is −1024…2048 and the data is −521…+340: the world uses **28 % of the 16-bit ramp** and its land
|
||
uses **7 %**. The clip fraction is the check on this and it only ever catches a range too *narrow* — too wide
|
||
clips nothing, reports nothing, and spends its contrast and most of its resolution on elevations no cell has.
|
||
A bake now prints the ramp it used and, under half, the range it should have had. The range itself is the
|
||
author's and `Planet.json` is unchanged.
|
||
|
||
### Measured
|
||
|
||
The full-planet re-bake was killed by memory pressure before it finished, so what is measured here is the
|
||
mechanism at unit scale, on a straight coast with 9.6 km of sea painted at 512 m
|
||
(`TestTheDerivedMarginDoesNotSwallowThePaintedOcean`):
|
||
|
||
```
|
||
break 30 m: 33% of the sea shallower than 50 m, 30 m at the break, 512 m in open water
|
||
break 130 m: 8% of the sea shallower than 50 m, 130 m at the break, 512 m in open water
|
||
```
|
||
|
||
A four-fold cut in the shallow bench, and the painting still reached in both. On the real planet the effect is
|
||
larger, because 9.6 km of sea is wide against a 4.6 km margin and the template's straits are not. The other
|
||
half is pinned too: a sea painted at 20 m still comes out 20 m deep, because the break can never be deeper
|
||
than the water it is a break in. **The whole-planet numbers are still owed.**
|
||
|
||
**2026-09-18. A planet has a shore (D-60).**
|
||
|
||
`internal/coast` ran on a flat grid, so a planet bake laid the painted sea floor and stopped. The land met
|
||
the painted ocean depth in **one cell**: no shelf, no surf-cut platform, no beach, no sediment budget and no
|
||
exposure anywhere on the world. Every bake printed the fact in its own log.
|
||
|
||
### What wraps
|
||
|
||
Four primitives, three of them one loop each: `boxBlur`'s running sum, `fetch`'s ray march, `shelfWidth`'s
|
||
inland march, and the gradient of the signed distance field that both marches take their direction from. The
|
||
distance transform already wrapped. The interior of the pass did not change at all.
|
||
|
||
The test for it is not "does the seam look right" - it is that **the same island in two places is the same
|
||
island**. `TestTheWholePassIsRotationInvariantOnACylinder` builds a world, builds it again rotated half a
|
||
turn, and requires the heightmap and every line of the accounting to follow the ground rather than the grid.
|
||
That has teeth, and the teeth were checked: forcing the ray march flat gives a 0.224 m discrepancy, forcing
|
||
the box blur flat gives 7.6e-5 m, and the tolerance is set under the smaller of the two rather than at a
|
||
comfortable round number. With everything wrapped the two worlds are **bit-identical**.
|
||
|
||
### The abyss becomes a field
|
||
|
||
A painted planet's sea classes carry their own `depth_m` - 20 m of surf, 120 of shelf, 512 of open ocean -
|
||
so the ocean is already laid at several depths before this pass runs. A derived continental slope bottoming
|
||
out at one global `AbyssM` would have stepped to the painting wherever the two disagreed, which is a cliff at
|
||
the shelf break in every strait.
|
||
|
||
`Input.Abyss` is that depth per cell, and it fixes the shallow case properly rather than by clamping: **the
|
||
break cannot be deeper than the water it is a break in**, so a strait painted at 20 m against a 30 m break is
|
||
shelf all the way across instead of a trench with a rim.
|
||
|
||
### The memory, which was the other half of the job
|
||
|
||
- **`Geometry.Ref` holds an index into `Waterline`, not a cell index.** There are tens of millions of cells
|
||
and a few hundred thousand waterline cells, so every per-shore quantity is now a couple of megabytes where
|
||
one indexed by cell is hundreds. The sediment supply was a `[]float64` over the whole grid: **608 MB** for
|
||
an array only ever read at the shore.
|
||
- **`Measure` holds one distance transform at a time, not two.** It needed one observation to reorder: a sea
|
||
cell's stretch of shore is the stretch its *nearest land cell* already belongs to, so the second pass reads
|
||
the answer out of `Ref` rather than out of the first pass's feature index. The waterline is taken straight
|
||
off the mask - it is a local question - which is what frees the ordering. That is 600 MB at planet scale.
|
||
- **`boxMean`'s coverage is separable.** Blurring a field of ones is the obvious divisor and it was the one
|
||
in use; but the blur is a row pass and a column pass, so the coverage factorises as `cx(x)·cy(y)` exactly,
|
||
for any pass count. Two vectors instead of a field and a second blur.
|
||
- **The before-and-after snapshot is the change map.** Taken into it, subtracted in place at the end.
|
||
|
||
### And a latent NaN that the last of those exposed
|
||
|
||
`deposit` computes `math.Pow(1-exposure, ShelterBias)` with a fractional bias, so an exposure over 1 is NaN -
|
||
and one NaN spreads through the drift kernel into the entire sediment budget and comes out as a laid volume
|
||
of NaN with no other symptom at all.
|
||
|
||
Exposure is a smoothed field, so it is 0..1 only to within the rounding of however it was smoothed. The old
|
||
divisor was a float32 field computed by the same code path as the numerator; the new one is a float64
|
||
product. The ratio went over 1 by five parts in a hundred thousand and 1720 cells of a 200 x 40 test came out
|
||
NaN. It is clamped at the point of use now - relying on a smoother a hundred lines away to bound its output
|
||
is not an invariant, it is a coincidence that held.
|
||
|
||
### Measured
|
||
|
||
```
|
||
coast: 971 km of shoreline, 65% sea, shelf 60% of it; surf planed 3.5 km2 and cut 34.18 Mm3,
|
||
6 river mouths delivered 1.15 Mm3, 35.32 Mm3 laid (0% unplaced) as 4.14 km2 of new beach
|
||
over the whole cylinder in 7.9s
|
||
```
|
||
|
||
Seven point nine seconds for 76 million cells, once, at the end of a two-hour bake - which is what "the pass
|
||
is tens of nanoseconds a cell and cutting it up would truncate the fetch across every strait" always meant.
|
||
|
||
And the seam, which is the point of the whole exercise. The step in the sea floor between the last column and
|
||
the first:
|
||
|
||
| | mean | worst |
|
||
| --- | --- | --- |
|
||
| before | 9.126 m | 523.1 m |
|
||
| after | **0.318 m** | 16.4 m |
|
||
| an ordinary interior column, for comparison | 0.017 - 0.285 m | 0.75 - 4.2 m |
|
||
|
||
The seam is now an ordinary column. The residual worst case sits on a row where the *painting* disagrees with
|
||
itself across the meridian - `deep` on one edge against `crater` on the other - and the profile through it is
|
||
a smooth ramp rather than a step. That is the template's own 9.4 % wrap disagreement and it is an author's to
|
||
fix, not the generator's.
|
||
|
||
**The pass is now the memory peak of a bake**, at about 8.3 GB of working set against the solve's 3.6. That is
|
||
the live fields it genuinely needs - the distance field, the reference, the exposure, the change map, the
|
||
supply and the two drift buffers - plus the collector's headroom, and it is worth knowing before running one
|
||
on a smaller machine.
|
||
|
||
**2026-09-18. A planet can be judged (D-59).**
|
||
|
||
Terrain.md has said since D-53 that "statistics pool across regions rather than being computed per region and
|
||
averaged". It was a rule with no implementation. Every function in `internal/stats` took a grid and sorted a
|
||
copy of every land cell in it, so a planet bake printed its elevation range and **nothing else** - no slope
|
||
distribution, no per-uplift-class breakdown, no drainage density, no slope-area fit.
|
||
|
||
That was not an inconvenience. Twice in one session the tool could not answer a question about its own
|
||
output: the lowland slope distribution that settled "are the lowlands hilly" (D-57) and the scarp measurement
|
||
that settled "do faults survive the solve" (D-58) were both taken by hand, in Python, off a PNG.
|
||
|
||
### The structure, and why it is the whole answer
|
||
|
||
A fixed-bin histogram is O(1) a value with no allocation and its quantile error is bounded by the bin width.
|
||
Neither of those is why it is here. It is here because it **adds**: summing two regions' bins and taking the
|
||
quantile of the sum gives *exactly* what one pass over both would have given.
|
||
|
||
Nothing else does. A median of medians is not a median. An area-weighted mean of means is right for a mean
|
||
and wrong for every quantile. Keeping every value is the thing that could not be afforded in the first place.
|
||
So the histogram is not an optimisation of the old design, it is the one structure that makes a decomposed
|
||
planet measurable at all - and the rest follows from it.
|
||
|
||
Each region builds an `Accumulator` **while its own grid is still alive**, because the composited planet has
|
||
no uplift field and no flow topology left to recover them from; both are per-region and both are thrown away
|
||
when the region's land is written into the cylinder. They merge in **region order** rather than completion
|
||
order: the bins are integer counts and would not care, but the running sums are floats, float addition is not
|
||
associative, and a run whose hypsometric integral depended on which landmass finished first would be a rule 12
|
||
failure hiding in the sixteenth decimal.
|
||
|
||
### Extent and ground are measured in different places
|
||
|
||
A region is a rectangle cut out of the cylinder with an ocean margin around it, and two neighbouring regions'
|
||
margins overlap. Pooling "how many cells" across them therefore counts the same water more than once and
|
||
reports a land fraction that means nothing.
|
||
|
||
So the two halves are split. `Add` runs per region and reads only the land that region *owns* - which
|
||
`Partition.Cut` already marks, so the pieces are disjoint by construction. `AddExtent` runs **once**, on the
|
||
composited cylinder, for the cell count, the land count, the clip count and the extremes the 16-bit encoding
|
||
has to hold.
|
||
|
||
The consequence showed up the first time a partial bake ran. `bake --only 19,7,4` solves three small islands
|
||
and leaves the rest of the planet at sea level, so the drainage density came out at 0.01 /km: three islands'
|
||
worth of channels divided by a planet's worth of land. It divides by the land actually walked now, which puts
|
||
it at 0.18, and the summary says so in as many words rather than leaving the two numbers side by side:
|
||
|
||
```
|
||
field -543..340 m; land 0..9 m (relief 9 m), 36% land, 0.00% clipped
|
||
PARTIAL: the line above is the whole world; everything below is the 3% of its land that
|
||
was actually solved (842682 of 27455176 cells). The two are not comparable.
|
||
```
|
||
|
||
### And local relief stops being quadratic
|
||
|
||
`localRelief` was max minus min over a square window, looped. At a 500 m window on 8 m cells that is a
|
||
63-cell radius, so 28 M land cells is **1.1e11 comparisons** - not a slow diagnostic, one nobody would see
|
||
the end of. `field.SlidingMin` joins the `SlidingMax` the coast mask already had, `field.LocalRelief` is the
|
||
pair subtracted, and the cost stops depending on the radius at all.
|
||
|
||
Measured, on a 3000² grid - about what region 12, the largest landmass on the 100 km template, actually is:
|
||
|
||
```
|
||
BenchmarkRegionSizedAccumulate-16 1085392500 ns/op 9.000 Mcells
|
||
```
|
||
|
||
1.09 s for 9 M cells, about 120 ns a cell, so the whole planet's 28 M land cells are a few seconds once at
|
||
the end of a two-hour bake.
|
||
|
||
### One more thing that fell out
|
||
|
||
`generate` and `bake` compute their statistics with the same code now, because an Accumulator does not care
|
||
whether the grid it is given is one region of a planet or the whole square canvas. That was not true before
|
||
and nobody had noticed it was not - one of the two printed no numbers to compare.
|
||
|
||
|
||
**2026-09-18. The seed re-rolls what the painting does not fix (D-58).**
|
||
|
||
A painting is a *composition*: where the continents are, where the ranges run, which coast was drawn on
|
||
purpose. It was never meant to be the whole world, and the question was how much of one painting could become
|
||
many worlds.
|
||
|
||
Half of it already could, and measuring that first was worth more than any of the code below. Seed 7 against
|
||
seed 9342 on the same painting, through `terrain plan`:
|
||
|
||
| | differs |
|
||
| --- | --- |
|
||
| `map_uplift.png` | **13.6 %** - the massif fabric moved: different hill masses, different forelands |
|
||
| `map_class.png` | 2.1 % - the coastline jitter re-rolled, and nothing else |
|
||
| `map_erodibility.png` | 2.1 % - **and only because the coastline moved** |
|
||
|
||
The swell, the initial relief, the crest lines, the upland fabric and the coast mask are all seeded and all
|
||
re-roll. That third row is the whole finding: `Legend.Erodibilities()` is a lookup by class index, so a
|
||
painted planet had **one flat erodibility inside every painted colour**, and `map_erodibility.png` was a
|
||
recolour of `map_class.png`. There was nothing for a seed to move and nothing to make one flank of a range
|
||
read differently from the next.
|
||
|
||
Faults were worse: they did not exist on the painted path at all.
|
||
|
||
### Why neither could be ported
|
||
|
||
Both live in `uplift.Build`, and both are written against the assumption the painted path exists to break.
|
||
|
||
`lithology` ends with `f.Percentile()` of the grid it was handed. On a decomposed planet that is two regions
|
||
measuring their own extents and putting the same physical hillside in different rock, with a wall along the
|
||
boundary between them. It is exactly the mistake D-55 caught in the massif threshold, in a second place. The
|
||
cut is now a quantile of the **planet**, from the same fixed probe, which is why `buildMassifCDF` became the
|
||
generic `measureFabric`.
|
||
|
||
`buildFaults` draws a trace centre as two calls to `Float()` read as fractions of that grid, so the same fault
|
||
lands somewhere different in every region. The set is now drawn **once for the planet**, in metres east of the
|
||
seam and metres south of the top painted row, and a region filters it to the traces that reach into its own
|
||
frame. A fault crossing a region boundary is therefore one fault, and two decompositions of the same planet
|
||
produce the same escarpment.
|
||
|
||
### What an author says
|
||
|
||
```jsonc
|
||
{ "name": "highland", "uplift_mm_yr": 0.25,
|
||
"faults": { "per_1000km2": 25, "throw_m": [120, 400], "length_km": [6, 18] } }
|
||
```
|
||
|
||
A *density* over the class rather than a count, because a class covers whatever was painted with it. A
|
||
**total throw over the run** rather than a rate, because that is the height of the scarp the fault would build
|
||
if nothing eroded it, which is a number somebody can picture. And on the class, so an author says *this range
|
||
is faulted and that plain is not* - which is the control they asked for and is also true of the world: faults
|
||
belong to orogens. The influence is not restricted to the class, because a range-front fault runs along the
|
||
edge of a range by definition and its scarp faces the lowland.
|
||
|
||
`lithology_mix`, 0 to 1, is the other half: how much of the planet's rock field shows through on this class.
|
||
An ice cap has no bedrock province showing through it, so it sets 0.
|
||
|
||
### Four recorded defects fixed rather than carried
|
||
|
||
Terrain-Next 4.A2 lists four things wrong with the procedural faults and 4.A3 a fifth. Writing the painted
|
||
implementation fresh against that list was cheaper than porting and then repairing:
|
||
|
||
- The trace is a **walk with a perturbed heading**, not one 8-point parabola. A distance field built from eight
|
||
long straight segments has visibly polygonal contours; short steps with a wandering heading do not.
|
||
- The throw **tapers to nothing over the last sixth at each tip** instead of stopping where the last segment
|
||
ends, which is what cut abruptly across a summit.
|
||
- A fault over twelve kilometres is drawn as **two or three overlapping en-echelon segments**, which is how
|
||
long faults step.
|
||
- Nothing is clamped to a fraction of a global rate. `if r > convergent*1.6` flattens exactly the strongest
|
||
throws into plateaus; the bound here is the repose ceiling **or whatever the author's own numbers asked for,
|
||
whichever is higher**, and the count of cells it binds is reported rather than hidden.
|
||
- The strike comes from a **grain field**, sampled as a vector through `atan2`. One global angle reads as
|
||
corduroy across a whole map (4.A3); a value lattice read directly as an angle would be worse, jumping a
|
||
whole turn along its own wrap and putting a hard seam through the set along a contour nobody can see.
|
||
|
||
### And one defect found by arithmetic
|
||
|
||
The escarpment weight is an exponential and the pass cuts it off at three gentle lengths so that each fault
|
||
only has to visit its own box. At three lengths the raw exponential is still **five per cent of its peak** -
|
||
0.013 mm/yr on a 400 m throw, which is a fifth of a lowland's entire uplift rate. As a hard cut that is a step
|
||
in the uplift field along a line six kilometres from every fault, and the solve would have carved it into a
|
||
perfectly straight scarp nobody placed.
|
||
|
||
So the floor is subtracted and the rest renormalised: the weight reaches zero exactly at the reach, smoothly,
|
||
and the box becomes an optimisation with no signature. Hoisting the per-segment boxes out of the cell loop at
|
||
the same time took the pass from **5.2 s to 1.5 s** on a 6.3 M cell region, against a solve of twenty minutes.
|
||
|
||
### And one defect the arithmetic missed: the profile itself (D-62)
|
||
|
||
The floor fix above is about the *far* end of the profile. The near end was worse, and it survived because
|
||
every test in `internal/uplift` asks whether the rate field is asymmetric, agrees between frames and tapers
|
||
at the tips - all of which the old one did - and none of them asks what shape it is.
|
||
|
||
Raised from a hillshade: "each fault line makes a rough line of mountains that just doesn't look realistic",
|
||
and asked as a manifest question. It was not one. `per_1000km2`, `throw_m` and `length_km` say how many
|
||
faults, how long and how much; nothing anywhere said what a fault's cross-section is. That was
|
||
`faultSteepM = 200` and `faultGentleM = 2000` with a **step** between them: the whole throw on the upthrown
|
||
side of the trace and the whole throw negated on the other, one 8 m cell apart. Measured on the designed
|
||
field, a 400 m throw gives **two throws across one cell, 89 degrees**, inside an upthrown flank that reached
|
||
zero six hundred metres out.
|
||
|
||
Both halves of that are unsolvable, and for the same reason the painted path exists at all.
|
||
|
||
**A step in the rate is a painted cliff.** "Paint the uplift, never the height" is a claim about what a solve
|
||
can undo, and a discontinuity in the rate field is precisely what it cannot: the surface has nowhere to put
|
||
the difference but into a scarp at the angle of repose. The trace facets at *any* throw, so turning `throw_m`
|
||
down only lowers the same artefact.
|
||
|
||
**And six hundred metres is narrower than one hillslope.** `Bake_013` measures a drainage density of 0.45
|
||
channels per kilometre, so a divide sits about 1.1 km from its channel. Nothing can dissect a block six
|
||
hundred metres wide - there is no drainage area at that width for stream power to work with, and hillslope
|
||
diffusion only smooths what is already there - so the uplift profile is *printed* onto the surface instead of
|
||
being eroded into a landform. That is the artefact precisely: in a hillshade every fault in that bake is a
|
||
smooth ruled ridge with no drainage on it at all, running through terrain dissected everywhere else, because
|
||
it is the one part of the map erosion never touched.
|
||
|
||
The profile now is an odd saturating ramp across the trace times a flank envelope:
|
||
|
||
```
|
||
faultShape(d) = d/sqrt(R² + d²) · (1 - (|d|/W)²)² R = 900 m, W = 6000 m footwall / 4000 m hanging wall
|
||
```
|
||
|
||
- **Zero on the trace**, which is also the honest reading: a rate difference across a line says one side
|
||
rises relative to the other, and at the line the two average to the regional rate. The old profile
|
||
asserted +throw and −throw at the same point.
|
||
- **R is about one hillslope length**, which makes the mountain front the sharpest thing this landscape can
|
||
express without being a cliff nobody solved for.
|
||
- **W is several hillslope lengths**, so a drainage network fits on the block and cuts it into spurs and
|
||
valleys — which is what a range front is and what an extruded cross-section never will be.
|
||
- **Algebraic, not transcendental.** It is evaluated at every cell of every fault's box, a few hundred
|
||
million times on a planet; `d/sqrt(R²+d²)` buys what `tanh` does and `(1-u²)²` what an exponential does.
|
||
The envelope reaches zero *with zero gradient* at its own width, so there is nothing to subtract and no
|
||
step at the box edge — the cut-off is the support of the function rather than a truncation of it, and the
|
||
reach is unchanged so the box is too.
|
||
|
||
`tipTaper` was the other half. It ramped over the last sixth at each end and held **exactly 1 over the middle
|
||
two thirds**, which dies out at the tips - what it was written for - and leaves the cross-section extruded
|
||
unchanged along most of every trace. An extrusion has no along-strike structure, so erosion has no reason to
|
||
head a valley in one place rather than another. It is a bell now (`q(2-q)` on `q = 4a(1-a)`), zero at both
|
||
tips, one in the middle, nowhere flat, and still carrying half the throw over four fifths of the trace.
|
||
|
||
And **`throw_m` now means what the word means**: the whole step across the fault, footwall crest less hanging
|
||
wall trough. The old profile put a full throw on each side and so built two. The normaliser is measured off
|
||
the profile at init rather than written down, so changing a width above cannot silently change what a
|
||
legend's number means.
|
||
|
||
Measured on a 400 m throw:
|
||
|
||
| | before | after |
|
||
| --- | --- | --- |
|
||
| steepest cell-to-cell step in the rate field | 89.0° | **17.4°** |
|
||
| step across the fault | 800 m over one 8 m cell | **400 m over 2.66 km, 8.5° mean** |
|
||
| footwall standing above half its crest | 600 m | **3.7 km** |
|
||
|
||
And solved, which is the only test that counts. The same synthetic landscape - 1500² at 8 m, 1000 steps, a
|
||
0.045 mm/yr foreland, sea at both ends, one curving 12 km trace with a 333 m throw - under the old profile,
|
||
the new one, and no fault at all:
|
||
|
||
| | relief |
|
||
| --- | --- |
|
||
| no fault | 45 m |
|
||
| old profile | 43 m — a ruler-straight cliff on the trace with a dead flat apron below it |
|
||
| new profile | **97 m** — a dissected range front with its own drainage network |
|
||
|
||
The old profile adds *less* relief than no fault at all, which is the measurement catching up with the
|
||
picture: it spends its throw on a 600 m welt and a 6 km trough, and the trough is deeper than the welt is
|
||
tall. `TestAFaultIsSolvableRatherThanPrinted` in `internal/uplift` is the three properties as assertions -
|
||
continuous through the trace, zero on it, and a footwall at least three hillslope lengths wide.
|
||
|
||
### What widening a fault broke, and how it is bounded (D-63)
|
||
|
||
Everything above was verified on region 8, which two traces reach. That is the wrong region to verify a
|
||
*width* change on, and the next bake said so: `Bake_018`, region 11, came out with its southern two thirds
|
||
a corduroy of parallel ribs - "streaking horizontally like someone just cut the mountains apart with a
|
||
knife". Two mechanisms, both of them D-62's.
|
||
|
||
**Faults stack, and a fault set is sub-parallel by construction.** `FaultDelta` accumulates with `+=`,
|
||
which was harmless while a fault reached six hundred metres because two of them almost never met. At six
|
||
kilometres they meet constantly - and traces inside one cell of the orientation grain share a strike by
|
||
design, as does every belt fault along one margin, so where they meet they are all pushing the same way.
|
||
Region 11 is 22.2 km across, `fault_grain_km` is 45, and thirteen traces reach it at strikes spanning
|
||
fourteen degrees. Sampled on a 200 m grid:
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| faulted ground with two or more faults on it | **75 %** |
|
||
| summed uplift over the largest single contribution | median **1.77x**, p90 2.87, max 4.46 |
|
||
| points asking for more than the repose ceiling on their own | 13 % |
|
||
| cells the hard clamp fired on | **160 289, 4.1 % of the region** (0.15 % planet-wide before D-62) |
|
||
|
||
That last row is the one that shows: a hard clamp does not soften anything, it makes plateaus at exactly
|
||
the repose-limited rate.
|
||
|
||
**The knee is per cell, and it is the largest single contribution there.** A bound keyed to the largest
|
||
throw in the set would not bite at all - the biggest throw on this template is 744 m while the biggest
|
||
single contribution anywhere in region 11 is 209 m, because a fault's own tip taper and flank falloff have
|
||
already had most of it by the time it reaches anywhere. So `softStack` takes the summed anomaly and the
|
||
peak single anomaly at each cell and bends one over the other:
|
||
|
||
```
|
||
|sum| <= peak -> sum the identity
|
||
|sum| > peak -> peak + 0.6*peak * tanh((|sum|-peak) / (0.6*peak))
|
||
```
|
||
|
||
Below the knee a lone fault passes through untouched, so the section above still holds exactly: the step
|
||
across one fault is the throw its author asked for. Above it the excess bends onto an asymptote of 1.6
|
||
times the knee, so a faulted belt still stands higher than an unfaulted one - which is the point of a belt
|
||
- but five parallel faults cannot deliver five throws. It is odd in `sum`, so a stack of hanging walls is
|
||
bounded on the same terms and a fault set cannot dig a hole either.
|
||
|
||
Two properties keep it honest. It is **continuous**: `peak` is a max of continuous functions and the join
|
||
has gradient 1 on both sides, so bending the stack cannot put back the step D-62 took out. And it is
|
||
**frame-independent** - `sum` and `peak` at a cell depend only on the faults within reach of it, and a
|
||
fault too far away to enter a frame's box contributes nothing to either - which is what keeps
|
||
`TestTwoFramesAgreeAboutTheSameFaults` true. For the same reason it runs unconditionally whenever anything
|
||
was rasterised, including when only one trace reached the frame: skipping it there would make a cell's
|
||
value depend on which frame it was asked about, which is the one thing `FaultDelta` may not do.
|
||
|
||
### And the initial relief was reading the finished rate
|
||
|
||
The other half, and the one that explains the *ribs* rather than the height. `painted.go` scales the
|
||
symmetry-breaking noise by `rate/maxClassRate`, and `rate` there was the finished rate with the fault delta
|
||
in it and no upper bound. On unfaulted foreland that ratio is about 0.18 and the stamped relief about 39 m.
|
||
On D-62's six-kilometre footwalls it reached 1.12 and about 166 m - on a landmass whose entire relief is
|
||
221 m.
|
||
|
||
A thousand steps cannot erase initial relief the size of the landscape. So the ridged fBm stopped being a
|
||
symmetry-breaker and became the terrain: the ribs measure 250-300 m off the hillshade, which is octave five
|
||
of a 4.2 km ridged fBm (4200/16 = 262 m). It reads the **pre-fault** rate now, bounded at one. The initial
|
||
relief exists to give the solve something to bite on; how much noise sits on a hillside is not a fault's
|
||
decision, and `TestTheInitialReliefIgnoresFaults` asserts the height field is bit-identical with the fault
|
||
set and without it.
|
||
|
||
### Measured
|
||
|
||
Region 11 re-baked at the same seed, the same region and the same thousand steps - `Bake_018` against
|
||
`Bake_D63`, so the only difference is the code:
|
||
|
||
| | before | after |
|
||
| --- | --- | --- |
|
||
| max elevation | 221.4 m | **115.6 m** |
|
||
| cells at the repose ceiling | 160 289 (4.1 %) | **2 865 (0.07 %)** |
|
||
| median land slope | 6.26° | **1.79°** |
|
||
| slope-area exponent (theory −0.500) | −0.938 | **−0.599** |
|
||
| drainage density | 0.58 /km | 0.60 /km |
|
||
| stacking factor, median / p90 | 1.77 / 2.87 | **1.50 / 1.60** |
|
||
|
||
and the corduroy is gone from the hillshade - the same 3.2 km patch that was a comb of parallel ribs is
|
||
irregular dissected hill country. The wall time went from 1m34s to 12m56s, which is the right direction
|
||
rather than the wrong one: the old run was cheap because unbounded negative stacking had driven whole
|
||
aprons to zero uplift, and dead flat ground costs one hillslope sub-step instead of twenty-four.
|
||
|
||
What is left on the flanks is a fainter version of the same ribbing, which is Terrain-Next 4.B3, predates
|
||
D-62 and is visible in `Bake_013` too. It is diagnosed there now rather than fixed.
|
||
|
||
### Re-rolling, in practice
|
||
|
||
`--seed` is on `plan`, `bake` and `tiles`, through `fs.Visit` rather than a sentinel value - a seed is an
|
||
arbitrary int64 and every sentinel is one somebody could legitimately want. It is on `tiles` as well and not
|
||
as a convenience: the detail passes hash the seed into every droplet, so a tile run has to be told the seed
|
||
its heightmap was baked under, and `CheckBake` refuses the mismatch. The studio has the seed and a **Re-roll**
|
||
button beside the wavelengths.
|
||
|
||
Measured after: seed 7 against 9342 now moves 13.8 % of the uplift map and **24.9 %** of the erodibility map,
|
||
with a different fault set on each.
|
||
|
||
### Does any of it survive the solve
|
||
|
||
A fault is applied as a rate precisely so that erosion cannot remove it, and that is a claim about a thousand
|
||
steps of stream power rather than about a weight function - so it was measured on a real bake rather than
|
||
argued. Region 15, the faulted highland landmass, 21.6 x 18.7 km, 3.8 M land cells, 1000 steps, 12 minutes
|
||
(`Bake_008`). Five traces fall inside it; for each, the mean land elevation 600 m either side of the trace:
|
||
|
||
| throw over the run | scarp across the trace |
|
||
| --- | --- |
|
||
| 399 m | +24.2 m |
|
||
| 336 m | +5.3 m |
|
||
| 297 m | +50.2 m |
|
||
| 193 m | +8.5 m |
|
||
| 139 m | +2.7 m |
|
||
|
||
**Five of five face the side the fault raises**, and every one of them is a small fraction of its own throw,
|
||
which is what it should be: the rivers cut the scarp down about as fast as the rate difference builds it, and
|
||
what is left standing is the balance. A painted 400 m step would have been gone entirely.
|
||
|
||
*Read that table with D-62 in mind.* It is the mean elevation 600 m either side of the trace, and 600 m is
|
||
exactly where the old upthrown flank reached zero - so the measurement straddles the welt rather than
|
||
spanning it, and the small numbers it reports are a fault mostly measuring itself. What the hillshade showed,
|
||
which no number here asked for, is that the thing surviving was a smooth ruled ridge. The measurement was
|
||
right that little of the throw survives; it could not say that what survives has the wrong shape.
|
||
|
||
`TestAFaultLeavesAScarpAfterTheSolve` in `internal/check` is that in miniature - one straight trace, a 400
|
||
grid, 400 steps, nine seconds - because the tests up in `internal/uplift` all check the *rate* field and none
|
||
of them says the solve leaves anything behind.
|
||
|
||
|
||
**2026-09-18. A second painting, for everything that is not geology (D-57).**
|
||
|
||
Two requests, one shape. *Say which coastlines to leave alone and which to roughen*, and *give the engine a
|
||
layer for forests, settlements and roads*. The first sounds like a coast setting and the second like an export
|
||
format, but both are the same sentence: **an author needs somewhere to say things about a place that are not
|
||
an uplift rate.**
|
||
|
||
The template cannot be that place. Every colour on it is geology — the solve reads a rate and an erodibility
|
||
off each one and answers for what it makes — so a `city` colour would have to invent an uplift rate for a
|
||
town, and a `leave this shore alone` colour would have to replace the water that is already painted there. So
|
||
there is a second image, the same size, registered to the first, painted in the same studio, with a legend of
|
||
**marks** rather than classes: `internal/overlay`.
|
||
|
||
**Blank is alpha, not a colour.** An overlay is a transparent sheet with strokes on it — that is what every
|
||
image editor produces and what the studio writes — and reserving a background colour would spend one of the
|
||
author's colours on nothing and break the moment they exported with a white matte behind it. There is a second
|
||
way to be blank and it is the more interesting one: an *opaque* pixel further than `match_distance` from every
|
||
mark is **dropped and counted**, where the class legend snaps every pixel to its nearest class no matter how
|
||
far. That inversion is deliberate. On a template every pixel must become something, so nearest is the only
|
||
total answer and the distance is only a warning. On an overlay most of the sheet is nothing, so a pixel that
|
||
matches nothing has an obvious right answer — and taking the nearest mark instead is exactly how a JPEG halo
|
||
round a road becomes road.
|
||
|
||
### The one thing any pass reads
|
||
|
||
`coast_jitter`, and nothing else. D-56 roughens the painted waterline because a drawn shore is a smooth curve
|
||
and a real coast is fractal. That argument is true of a shore nobody thought about and **false of one traced
|
||
off a map on purpose**, which is the complaint. So the amplitude stops being a number and becomes a field:
|
||
`0` inside a mark pins that stretch exactly as painted while the rest of the world is still roughened, and
|
||
above `1` chews it harder, so a fjord coast is a brush stroke rather than a global setting nobody can localise.
|
||
|
||
The part that needed thinking about is what an *unmarked* cell does. It is uninstructed — the slice carries
|
||
`-1` there, not `1` — and it takes its instruction from the nearest cell on the far side of the waterline,
|
||
which `dt.Transform` has already found for the class-inheritance rule one loop earlier. Without that, a mark
|
||
is only effective on the side of the line the author's hand happened to be on: a stroke drawn over the land
|
||
would leave the water beside it free to march inland, and the coastline would move anyway. With it, painting
|
||
either side is enough and painting over the line — which is what a brush does — is enough twice over.
|
||
|
||
Verified exactly rather than by eye. An overlay painted `coast_jitter: 0` over every pixel produces a
|
||
`map_class.png` byte for byte identical to a run with `coast_jitter_px` at 0; the same overlay with marks on
|
||
two stretches of one continent differs in 45 548 px of 2 800 800.
|
||
|
||
### Everything else is inert, and travels
|
||
|
||
No pass reads a `forest`, a `city` or a `road`. Two bakes with and without them are the same terrain to the
|
||
bit. What the marks do is come out the other end, in two shapes because two different things want them:
|
||
|
||
- **An 8-bit index raster beside every detail tile.** `Planet_x11_y07_overlay.png`, one mark a detail cell,
|
||
`0` for nothing. Indexed rather than one mask per mark, and that is not a size optimisation: marks
|
||
*cannot* overlap, because the overlay is one painting and a pixel is one colour, so 254 of them fit in the
|
||
file a single boolean mask would have taken.
|
||
- **Features in world metres, in `overlay.json`.** An **area** gives a centre, an area, a radius and an
|
||
extent per connected blob — enough to place a settlement. A **path** is thinned to its centreline and gives
|
||
an ordered polyline and a length, because the thing built from a road on the other side is a spline and not
|
||
a ribbon-shaped polygon. The centreline is the component's **geodesic diameter**: breadth-first from any
|
||
cell to the furthest, then from there again keeping parents, and the walk back is the path — smoothed once
|
||
and simplified with Douglas–Peucker at half a pixel, because a breadth-first walk leaves a D8 staircase.
|
||
|
||
The cylinder is handled where it bites rather than everywhere. A blob across the seam is one feature, and its
|
||
centre is a **circular** mean of the longitudes: a plain mean would put the centre of a coastal town on the
|
||
opposite side of the planet, which is the one failure mode a wrapped map has and the one nobody notices until
|
||
a village turns up in the ocean. Its extent is measured relative to that centre, so it is the short way round.
|
||
And a tile samples the overlay through **world metres** rather than through a tile-local index — the overlay
|
||
is 12.9 m a pixel, the geology is 8 and a detail cell is 2, and the common frame is the only place the three
|
||
agree. That is rule 1 of the tiling plan applied to a raster instead of to a noise.
|
||
|
||
**The limit is stated rather than hidden:** one stroke is one path. A fork reports its two longest arms as a
|
||
single line and drops the third, because the geodesic diameter of a Y is a line through two of its arms. The
|
||
remedy is an author's — paint each run as its own stroke — and `terrain plan` prints the piece count per mark,
|
||
which is where a fork shows.
|
||
|
||
### And the class table stopped lying about steepness
|
||
|
||
This is the other half of the same session's complaint — *the lowlands still come out hilly* — and it turned
|
||
out to be a reporting defect rather than a physics one.
|
||
|
||
`terrain plan` printed the **divide angle**: `S = U/(K·A^m)` with `A` one cell squared, which for `n = 1` is
|
||
exactly what the rate fixes (D-49). It is correct and it is the *steepest* ground the rate can make, because
|
||
`A` is smallest at the top of a catchment and almost none of a map is divide. An author reads it as the
|
||
landscape, and sets every rate they own two or three times too hot.
|
||
|
||
Measured, on a 600² grid of 8 m cells with one coast, the manifest's own constants and 1000 steps:
|
||
|
||
| U mm/yr | divide | median | P90 | over 3° | over 8° | max elevation |
|
||
| --- | --- | --- | --- | --- | --- | --- |
|
||
| 0.012 | 1.7° | 0.58° | 1.00° | 4 % | 2 % | 32 m |
|
||
| 0.045 | 6.4° | 2.12° | 2.85° | 8 % | 1 % | 38 m |
|
||
| 0.080 | 11.3° | 3.72° | 4.85° | 75 % | 1 % | 54 m |
|
||
| 0.250 | 32.0° | 11.13° | 14.03° | 99 % | 83 % | 143 m |
|
||
|
||
In tangent the median/divide ratio is 0.34, 0.33, 0.33 and 0.32 — flat enough over a factor of twenty in rate
|
||
to quote as one number. The P90 ratio drifts 0.59 → 0.40 as the ground steepens, because the top of the slope
|
||
distribution is where the repose clamp eventually binds; 0.45 is the middle of it and it is the weaker of the
|
||
two. Both are fractions of the tangent, because the steady-state law is about slope.
|
||
|
||
So the table prints both columns and takes `reads as` from the median. `highland` at 0.25 mm/yr now reads as
|
||
hill country at 11.7° rather than alpine at 32°; `lowland`'s massif floor reads as a 0.6° plain rather than a
|
||
1.7° one. It is D-55's defect one level up — there a class was one rate and therefore one landscape, here the
|
||
one number an author steers by was the one place in that landscape they would almost never stand.
|
||
|
||
### And the preview lies about scale, which is where "the lowlands are hilly" actually came from
|
||
|
||
The complaint was raised against the real world rather than against a table, so the real world was baked to
|
||
settle it: region 12, the 45.9 × 19.8 km central lowland, 9.0 M land cells, 1000 steps, 27 minutes.
|
||
(`Bake_007`.)
|
||
|
||
```
|
||
region 12 done 0..47 m, 0.000% clipped, 0.09 mm/yr peak [1611 s]
|
||
slope over the land: p50 0.61° p75 0.75° p90 1.22° p99 4.51° max 7.7°
|
||
12.2 % over 1°, 4.4 % over 3°, 0.4 % over 5°, none over 8°
|
||
```
|
||
|
||
Forty-seven metres of relief over forty-six kilometres, a median hillslope of six tenths of a degree, and
|
||
nothing anywhere on it steeper than eight. **That is not hill country by any measure, and it was never hill
|
||
country.** `map_slope.png` agrees: the continent is black with three faint massifs on it.
|
||
|
||
What made it look like hill country is `preview.png`. The hypsometric ramp's top is
|
||
`palette.land_top_percentile`, **a percentile of the world being drawn**, so the ramp's whole span — green,
|
||
tan, bare rock, white — was stretched over the 32 m between sea level and this continent's 99.5th percentile.
|
||
Its 40 m hills therefore came out with snow caps, and the dendritic network cut into a plain at a fraction of
|
||
a degree came out as visible relief texture everywhere. Redrawn against a fixed 400 m ceiling, the same
|
||
heightmap is a flat green plain with four pale hill masses on it.
|
||
|
||
That is the same defect as the divide angle, one more level out: **a relative picture is honest only when the
|
||
reader is told it is relative.** So the palette gains `land_top_m`, an absolute ceiling in metres, and
|
||
`WritePreview` returns the ceiling it used so every run prints which one the colours meant:
|
||
|
||
```
|
||
preview the hypsometric ramp tops out at 32 m - the 99.5% percentile of *this* world's land, so rock and
|
||
snow mean "the highest ground here" and nothing about scale. Set palette.land_top_m for an
|
||
absolute ramp
|
||
```
|
||
|
||
The percentile stays the default, and deliberately: an absolute ramp over a world with no mountains is a flat
|
||
green shape with nothing legible on it, and "is there drainage here" is a question the contrast has to answer.
|
||
What was missing was never the option, it was the sentence.
|
||
|
||
### What the lowlands are doing, then
|
||
|
||
**At the massif floor a painted lowland is already a plain**: 0.012 mm/yr comes out at a median of 0.58° in a
|
||
controlled run and 0.61° on the real continent, with 4 % of it over 3° in both. What makes a painted lowland
|
||
read as hill country in the *numbers* is its massif share — `fraction` 0.16 opens the ramp at the 76th
|
||
percentile of the planet, so about a quarter of the class is off the floor — and the rate those raised parts
|
||
climb to. Both are the author's, and the studio now shows the honest angle for each as they are typed.
|
||
|
||
|
||
**2026-09-17. The coastline stops being a drawn line, and the template gets a tool (D-56).**
|
||
|
||
`coast_jitter_px` had sat in the manifest since D-53, documented and defaulted at 1.5, and **nothing anywhere
|
||
read it** — three grep hits, all of them in `manifest.go`. So every painted shore reached the solve exactly as
|
||
it had been drawn, which is why the coasts read as brush strokes: an author draws a shore as a smooth curve
|
||
because that is what a hand and a bezier tool produce, and a real coast has bays inside bays inside bays.
|
||
|
||
**It is a mask on the signed distance, not a warp of the painting**, and that was measured rather than
|
||
reasoned. Displacing the point each cell asks the painting about was built first and it cannot cut a bay — a
|
||
smooth warp of a smooth boundary is another smooth boundary, just wigglier, and the amplitude that would fold
|
||
it back on itself drags every inland class boundary the same distance. What works is thresholding the signed
|
||
distance to the waterline: add fractal noise to how far a cell is from the shore, ask again which side of zero
|
||
it is on, and land juts out where the noise is positive while the sea reaches in where it is negative, at
|
||
every scale the octaves cover, with nothing away from the shore moving at all.
|
||
|
||
Two things fall out of doing it that way. A cell that changes sides needs a class, and `dt.Transform` already
|
||
returns the nearest seed *and which cell it was*, so new land grows out of the land beside it and sea eaten
|
||
out of a shore becomes the surf that was lying against it rather than deep ocean. And small islands have to
|
||
survive: an islet thirty pixels across under a four-hundred-pixel wavelength sees very nearly a constant, so
|
||
it sits still or vanishes whole. The amplitude is capped per cell at two thirds of the widest land within
|
||
reach — a sliding maximum of the land distance — and without that guard 2 of 12 test islets are erased.
|
||
`field.SlidingMax` exists for it: a monotonic deque, O(1) a cell whatever the radius, because the naive window
|
||
`localRelief` uses is seven billion comparisons at planet scale.
|
||
|
||
**The mask exposed a classifier defect and made it load-bearing in the same step.** A lossy codec blends
|
||
across every boundary, and on this template the blend of `surf` (221,238,238) and `lowland` (153,204,102) is
|
||
(186,219,174) — whose distance to `desert` (238,221,153) is **53.8** against **77.9** to either of the colours
|
||
it was actually mixed from. So every temperate coast carried a one-pixel ribbon of spurious desert, 1607
|
||
pixels of it, invisible for as long as it was one pixel wide. The mask made those strays the nearest *land* to
|
||
a stretch of open water and handed their class to everything it turned into shore: an eleven-pixel band of
|
||
desert appeared along a green continent, and the mask was blamed for it first.
|
||
|
||
The fix had to be spatial, and the wrong one is instructive. The colorimetric rule — notice the pixel lies on
|
||
the segment between two class colours, give it to the nearer — was built, measured and thrown away, because
|
||
it cannot work in general and this legend is the proof: `shelf` (153,204,221) sits **10 units** from the line
|
||
between `ocean` and `surf`, so a real shelf pixel with codec noise on it and a genuine ocean/surf blend are
|
||
the same point in colour space. That rule reclassified 943 000 painted shelf pixels. What distinguishes a
|
||
stray is *where* it is, so `Despeckle` is a 5×5 majority: a one-pixel ribbon holds five of twenty-five against
|
||
ten and ten, and a two-pixel band — something an author drew — already holds ten and is left alone. Three by
|
||
three cannot see the case at all, which is why the window is five. It moves 0.068 % of the map and takes the
|
||
stray desert from 1607 to 0.
|
||
|
||
**And the studio.** `terrain studio` serves a painting tool on loopback. The point of it is that the two
|
||
halves of a painted world used to live in different programs — the shapes in an image editor that knows
|
||
nothing about uplift rates, the meanings in a JSON legend that cannot show you where they land — so the
|
||
brushes *are* the legend's classes: picking `highland` is picking 0.25 mm/yr, and the panel says that is 32
|
||
degrees at a divide and reads as alpine while you are painting it. `plan` is a button, seven seconds round
|
||
trip, run against the painting in the browser rather than the file on disk.
|
||
|
||
Three limits are deliberate. It paints **hard-edged exact colours** — an antialiased brush would manufacture
|
||
the very blend the despeckle pass exists to remove. The canvas **wraps at the seam**, because the world does
|
||
and because the first template disagrees with itself on 9.4 % of its rows. And it **saves by patching the
|
||
text** of the legend and the manifest rather than re-marshalling them, which is the same argument the palette
|
||
writer already makes one file over: a legend is mostly commentary, `MarshalIndent` over a `map[string]any`
|
||
returns it alphabetised with every comment moved away from the thing it explained, and a file a person wrote
|
||
has to still diff after a tool touches it.
|
||
|
||
**2026-09-18, the canvas stops being a 2D canvas (D-61).** The template is 7738 × 3761 — twenty-nine million
|
||
cells, a hundred and sixteen megabytes of pixels — and the first renderer put all of it through the 2D canvas
|
||
on *every pointer event*: a full-width `putImageData` band to push the stroke back into an offscreen copy,
|
||
then a `imageSmoothingQuality: "high"` downsample of the whole image, once per repetition across the seam.
|
||
Both costs are linear in the size of the world and neither is a function of what the stroke touched, which is
|
||
the shape of the bug: measured on the same machine and the same GPU, a 24-pixel brush and a 400-pixel one
|
||
both cost **about 105 ms an event** at the zoom that shows the whole world. A mouse polling at a kilohertz
|
||
asked for that a thousand times a second.
|
||
|
||
So the world is a GPU texture. A stroke uploads the rectangle it touched and nothing else, read in place out
|
||
of the `ImageData` with no intermediate copy — which is what WebGL2's `UNPACK_ROW_LENGTH` is for, and the
|
||
reason this needs WebGL2 rather than WebGL1 at all, along with a 7738-wide non-power-of-two texture that
|
||
still wraps and still mips. Drawing is one textured quad. The seam comes free with `REPEAT`, which also fixes
|
||
something the tiling loop could not: every repetition was a separate `drawImage` with texture coordinates of
|
||
its own, so the derivative at the wrap was wrong and the seam blurred whenever the world was minified.
|
||
Measured after: **6.0 to 6.3 ms**, flat, in every case — fit zoom with a 400-pixel brush, 1:1, 4×, and a pan —
|
||
which is the floor of the measurement rather than a number about the renderer. The old path's worst case was
|
||
a 1.4-second p90 at 1:1, which is the canvas read-back stall that `getImageData`/`putImageData` on a
|
||
GPU-backed canvas costs.
|
||
|
||
Three things follow that are not speed. The **mip chain is what makes minification both correct and free**,
|
||
and regenerating it off a twenty-nine megapixel base is 4.6 ms, so it is rebuilt at most ten times a second
|
||
while the brush is down, never while the world is magnified past 1:1 because nothing reads it there, and once
|
||
for certain when the brush comes up. The **two offscreen canvases are gone**: `full` and `ovFull` are the
|
||
only copies of either sheet, and a sheet is encoded to PNG when it is pushed rather than kept mirrored in a
|
||
canvas the whole time, which is two hundred and thirty megabytes not held. And the page now **survives losing
|
||
the GPU context** — a shader compile in the engine next door is enough to cause one — by uploading both
|
||
sheets again, where before it left a black rectangle with an unsaved painting behind it and no way back but a
|
||
reload.
|
||
|
||
What the author sees is a **brush ring** at the cursor in the colour about to be painted, because 400 pixels
|
||
is 400 pixels at any zoom and the slider cannot say how much of *this* view that is; **eased zoom** that
|
||
holds the point under the cursor, because at these scales one notch of the wheel is a factor of two and a
|
||
jump has nothing for the eye to follow; `f` to fit the world and `1` for one cell to one pixel, which were
|
||
both a lot of scrolling before; space to drag; and rendering at the screen's own pixels rather than at CSS
|
||
pixels, which on a scaled display had been a smaller image stretched up — a soft coastline in a tool whose
|
||
whole job is where the coast is. A shortcut typed into a number field is now a character and not a shortcut,
|
||
which `o` in particular needed: it had been swapping the sheet under a half-written number.
|
||
|
||
**And `ctrl+z` takes back a stroke.** The same constraint decides its shape: a sheet is 116 MB, so a stack of
|
||
snapshots is not a stack. **The unit is one stroke** — brush down, drag, brush up — because that is what a
|
||
hand means by taking something back, not the last frame of it. What a step keeps is the pixels the stroke
|
||
covered, by copy-on-write over a 256-pixel tile grid: a tile is copied out the first time a stroke writes
|
||
into it, which makes the bookkeeping one `Map` lookup per stamp rather than rectangle algebra, and makes the
|
||
cost of a step a function of what was painted rather than of the size of the world. A dab is one tile and
|
||
256 KB; a 60-pixel drag is two and 512 KB; a 400-pixel brush dragged 800 pixels is 42 tiles and 9.1 MB. The
|
||
cap is 192 MB, and it bounds *both* stacks, because a new stroke empties redo and a step moves between the
|
||
two rather than being copied into it.
|
||
|
||
Two properties are worth stating because they are what make it trustworthy rather than merely present.
|
||
`keepTiles` is called from the top of `stamp`, which is the **only** writer, so there is no path by which a
|
||
pixel changes that undo has not already recorded. And undo and redo are **one function in opposite
|
||
directions**: applying a step swaps what it holds against what is on the sheet now, which is at once the undo
|
||
and the construction of the record that redoes it, so neither direction needs a copy the other does not
|
||
already hold.
|
||
|
||
**One bug, recorded because the test caught it and reading would not have.** A tile column is only meaningful
|
||
inside `[0, W)`. 7738 is not a multiple of 256, so the last column is 58 wide and the grid does not line up
|
||
with itself across the seam — cutting a stamp's *unwrapped* rectangle into tiles and wrapping the indices
|
||
afterwards gave columns 30 and 0 for a brush at x=2 that had also written into 29, and those pixels were gone
|
||
for good. It passed every check on the pixels it was asked about and failed only a whole-sheet hash. The
|
||
rectangle is wrapped into runs **before** it is cut into tiles now, which is the same order `pushRect`
|
||
already uses one function above.
|
||
|
||
Both sheets are on screen at all times — the annotation layer is dimmed while the brush is on the classes,
|
||
never hidden — so a step shows whichever tab it belongs to and no tab is switched under anybody. A map view
|
||
is dropped, because it is the one thing covering what just changed.
|
||
|
||
**2026-09-17, after the second whole planet. A class becomes two rates and a fraction (D-55).**
|
||
|
||
The first painted planet's landmasses came out *uniformly* dissected — every divide on a continent at the same
|
||
angle, from the waterline to the summit, with no flat ground anywhere on any of them. That is not a tuning
|
||
miss either; it is the same arithmetic as D-49 read one step further. A class was one uplift rate, `n` is 1, so
|
||
the rate alone fixes the hillslope angle, so **one class is one landscape**. `lowland` at 0.08 mm/yr is 11.3
|
||
degrees on every divide it touches; a 45 km continent painted with it is 45 km of continuous hill country.
|
||
|
||
Europe away from the Alps is not that. It is a plain at a fraction of a degree with isolated massifs standing
|
||
out of it, and what separates the two is not the rate — it is that the rate is not the same everywhere. So a
|
||
class carries a `massif` block: the class rate is re-read as the rate a massif *reaches*, `floor_mm_yr` is the
|
||
plain between them, and `fraction` is how much of the class stands above the midpoint of the two. `lowland`
|
||
became 0.08 over a sixth of its ground and 0.012 — 1.7 degrees, a plain a player can build on — over the rest.
|
||
|
||
**One fabric for the whole planet, not one per class.** `planet.massif_wavelength_km`, and every class cuts the
|
||
same field at its own level. That is what makes a highland belt and the hills in the lowland beside it the high
|
||
and low parts of one structure rather than two unrelated noises meeting at a painted edge, which is how a
|
||
foreland and its outliers work. The wavelength has to sit well below the size of a landmass: at 12.5 km against
|
||
islands of 20–45 km one island came out entirely above the cut — the original defect over again, only smaller —
|
||
and 7 km puts several blocks across every continent.
|
||
|
||
**The hard part was the threshold, and it is the one place this could have gone quietly wrong.** A fraction has
|
||
to become a cut in the fabric's values, and the obvious way to find that cut is a percentile of the grid. That
|
||
is precisely what `uplift.FromTemplate` exists not to do (D-53): `Build`'s percentile range band is a global
|
||
operation over the grid it is handed, and two regions taking quantiles of their own extents would put the same
|
||
physical hillside on different sides of the cut, so the planet would disagree with itself along every region
|
||
boundary. A quantile of the *planet* is a different animal — one number for the whole world, computed by every
|
||
region from the same samples because the samples are defined by the planet and not by the caller. It is a
|
||
1024-column probe of the cylinder binned into a histogram, about ten milliseconds, and
|
||
`TestTwoFramesAgreeAboutTheSameGround` is the test that would have caught the percentile.
|
||
|
||
Cutting the ramp in *probability* rather than in the fabric's own values is what makes `fraction` a number an
|
||
author can predict: exactly that share stands above the midpoint, half again reaches the class rate outright,
|
||
and half again above that is off the plain at all. Cutting in value space would have made the realised share
|
||
depend on the shape of the noise's distribution, which is not a number anybody should have to know, and it
|
||
would have drifted every time an octave count changed.
|
||
|
||
**Fraction is a share of the planet, deliberately, and therefore only the expected share of any one island.** A
|
||
small island gets all of a massif or none of it, exactly as it would if it were a real island that happened to
|
||
sit on or off an orogen. Normalising per landmass would hand every island its quota of hills, which is the
|
||
thing being fixed.
|
||
|
||
**And the mislabel that caused it.** `internal/stats` buckets anything under 0.1 mm/yr as "plain", and the
|
||
legend's own commentary repeated it as guidance. Those boundaries are a reporting convenience calibrated for
|
||
the procedural path's intraplate rates; 0.1 mm/yr is a fourteen-degree hillslope. Reading them as a description
|
||
of terrain is how `lowland` was set ten times too hot, and the fix is that `terrain plan` now prints what each
|
||
class *reads as* — plain, rolling, hill country, mountain, alpine — from the divide angle rather than from the
|
||
rate, beside the massif floor and its own angle. The buckets themselves are unchanged: they are a reporting
|
||
axis with a run of measured numbers behind them, and moving them is a separate decision.
|
||
|
||
**Measured, Bake_004 against Bake_001 and Bake_003 on the same two region boxes.** The statistic that matters
|
||
is the slope distribution and not the peak, and `internal/stats` cannot produce one at planet scale yet
|
||
(Terrain-Next 3.1), so this was taken off the 16-bit heightmap directly:
|
||
|
||
| | region 11, the lowland continent | region 13, the highland island |
|
||
| --- | --- | --- |
|
||
| before | median 3.7°, **7 %** under 2°, max 71 m | median 6.7°, **4 %** under 2°, max 183 m |
|
||
| after | median 0.7°, **93 %** under 2°, max 41 m | median 2.0°, **49 %** under 2°, p90 8.9°, max 167 m |
|
||
|
||
The lowland went from ground that is gently sloping *everywhere* to a plain with hill masses standing out of
|
||
it; the highland island went from uniformly steep to half foreland and a concentrated range.
|
||
|
||
**And peak elevation fell much further than the arithmetic suggested it would** — 71 m to 41 m on the lowland,
|
||
when the massifs still reach the same 0.08 mm/yr. Relief on a continent is the integral of slope along the
|
||
whole flow path, not a local property: before, every kilometre of a 45 km trunk was at 0.08 and climbing, and
|
||
now the trunk crosses a 0.012 plain and only gains height inside a massif. The lesson for judging a bake is
|
||
that **max elevation cannot see this change at all** and very nearly reported it as a regression.
|
||
|
||
`map_uplift.png` had to learn the fabric with it. It was rendered from the per-class constant, which is exactly
|
||
the thing that stopped being true, and a diagnostic showing a landmass flat when it is not would have hidden
|
||
the feature entirely. It builds the fabric at the *image's* resolution over the cells `renderRGB` actually
|
||
point-samples — a couple of million noise samples rather than the planet's seventy-eight.
|
||
|
||
**2026-09-17, after the first whole planet. Three things the legend could not say (D-54).** All three came
|
||
from looking at the bake rather than from the plan, which is the argument for baking something early.
|
||
|
||
**The mountains reached the water, and that is arithmetic rather than a tuning miss.** D-49 again: for `n = 1`
|
||
the uplift rate alone fixes the hillslope angle, so a uniformly painted `highland` island sits at the angle of
|
||
repose everywhere, the shore included. The rivers do cut down to sea level — the *channels* are flat at the
|
||
coast, because slope goes as `A^-m` and `A` is largest at the mouth — but the ground *between* the channels
|
||
has no idea how far from the sea it is. The result is fjords from one end of the island to the other, which is
|
||
not what most coasts look like.
|
||
|
||
`coastal_plain_km` ramps the rate from `coastal_floor_mm_yr` at the waterline up to the class rate over a
|
||
stated distance, smoothstepped so the plain meets the range without a crease in the slope field — a crease
|
||
there is a line of channel heads all beginning at the same distance from the sea, and it reads as a contour
|
||
rather than as terrain. Measured on `highland`: 0.06 mm/yr at the water, 0.36 at two kilometres, 0.90 at four.
|
||
|
||
**This is D-52 read carefully rather than reversed.** That decision removed a coastal taper and the reasoning
|
||
stands, but what it removed was a *hidden* one: the uplift was being multiplied by the continent mask, which
|
||
went to zero at the waterline, so the hundred-metre strip the surf works in was flattened and every cliff
|
||
began a hundred metres inland. This is opt-in, it is the author stating where their range starts, and the
|
||
waterline keeps a real rate. Where the land ends still does not decide how fast it is rising; an author saying
|
||
"plain here, range there" does.
|
||
|
||
**A crater cannot be an uplift rate, and finding out why is the useful part.** The obvious construction is
|
||
negative uplift in the middle and positive at the rim. It does not survive: the priority-flood runs every step
|
||
and *raises* every depression to its spill level, so the basin is filled in before the run is a hundred steps
|
||
old. It is also the wrong model — an impact is an event, not a rate. It postdates the landscape it sits in,
|
||
which is exactly what a pass running after the solve expresses, in the same place and for the same reason as
|
||
the coastal pass.
|
||
|
||
The shape is derived from the painted blob rather than drawn. Distance inward from the blob's own boundary,
|
||
normalised by its widest point, is a coordinate running 0 at the shore to 1 at the centre whatever size and
|
||
shape the author painted, so one set of four numbers — `rim_m`, `floor_m`, `rim_at`, `wall_at` — describes
|
||
every crater on the map. Both segments are smoothstepped, because a corner at the crest or at the foot of the
|
||
inner wall is ground the detail passes would then spend their time sanding off. A crater across the seam is
|
||
one crater, by the same wrap-aware flood the region partitioner uses.
|
||
|
||
**A desert is not a low uplift rate, and that is the fourth thing.** A wet lowland has one of those too. At
|
||
the geology grid the only lever is `k_mult`, and below 1 it means less water doing less work: steeper, more
|
||
angular ground held further from being worn down, which is right as far as it goes and is nowhere near enough
|
||
to tell the two apart. The difference is at two metres, so a class may now override what the detail passes do
|
||
on its ground - `droplets_per_cell`, `strata_contrast` and `amplitude_m`. Measured on a synthetic pair over
|
||
identical terrain: 43 634 droplets moving 84 km of material against 2148 moving 4.1 km, which is a dendritic
|
||
gully network against a few isolated wadis. Everything left out keeps the pipeline's number, and a legend that
|
||
overrides nothing does not carry the class raster through the detail passes at all.
|
||
|
||
**And the thing all of that turned up.** The mountains came out of the first painted bake as flat polygonal
|
||
faces with hard 45- and 90-degree edges - the repose clamp cutting along the eight D8 directions, doing not
|
||
some of the shaping but all of it. Three wrong guesses on the way to that, and the order is the lesson: the
|
||
strata hardness (turned it off, nothing changed), then the tile hillshade (`WriteThumbnail`'s shading term is
|
||
a raw gradient over the cell size, which is fine on a 512-pixel picture of a whole map and saturates to pure
|
||
black and white at two metres a cell - a real bug, replaced with a standard DEM hillshade, and still not the
|
||
cause), and only then the terrain itself. What settled it was a diagnostic rather than an argument:
|
||
`terrain tiles --no-detail` writes the geology upsampled and nothing else, so the question "did the detail
|
||
passes do this, or are they faithfully magnifying something the solve produced" has an answer in twelve
|
||
seconds.
|
||
|
||
The arithmetic was available the whole time. Steady state is `S = U/(K·A^m)` applied down to a single cell, so
|
||
at a divide `A^m` is the cell size and `U_max = tan(talus)·K·cell` - **0.280 mm/yr** at 35 degrees, K 5e-5 and
|
||
an 8 m cell. The legend's `highland` was 0.9, which is 66 degrees at a divide, 3.2 times over. `terrain plan`
|
||
prints the implied angle for every land class now and names the ones that are clamped, which is four seconds
|
||
against an hour and a half. And the consequence worth stating plainly, because it constrains what a painted
|
||
world can be: **at a fixed cell, relief and steepness are the same knob.** `U/K` sets both, so there is no
|
||
setting that gives a 700 m range with hillslopes below repose. More relief than that out of erosion-shaped
|
||
ground needs the channelization threshold to work, which is the open problem in Terrain-Next 4.D.3.
|
||
|
||
**And how a world is drawn is a file now.** The ramp, the water, the rivers, the ice and the light were
|
||
constants in `internal/field`; they are a palette the planet manifest points at, with the same numbers as the
|
||
default. Separate from the legend on purpose - the legend says what the colours in the input mean and is
|
||
about the world, a palette is about the picture and changes no height, so it is the part most likely to want
|
||
swapping. `terrain palette <path>` writes the defaults out to copy. Hand-formatted rather than through
|
||
`MarshalIndent`, which re-indents whatever a custom marshaler returns and so insists on putting every channel
|
||
of every stop on a line of its own: sixty lines for eight stops, a table whose shape is invisible.
|
||
|
||
**Bakes are versioned** (`Bake_001`, `Bake_002`, ...) for the same reason: an hour and a half is too long to
|
||
spend on a change you then cannot compare against what it replaced.
|
||
|
||
**And the polar caps rendered as meadow.** The hypsometric ramp tops out at snow by *elevation*, so an ice
|
||
sheet fifty metres above the water gets the same green as farmland. A `snow` flag on a class fixes the
|
||
picture and nothing else: no height moves, no pass reads it, and the cells still take the hillshade rather
|
||
than being stamped flat, so a dome and the valleys cut into it still read. It is a material hint that has
|
||
arrived early, and when there is a landscape material it will be what paints the ice.
|
||
|
||
**2026-09-17, later. The detail passes, tiled (D-53 continued).** Passes 8 to 12 and 14 are built, so there is
|
||
a full-resolution output for the first time: 5 km tiles of 2500 samples at 2 m, about twelve seconds each,
|
||
written with a hillshade beside them because a 16-bit grey PNG of a hundred metres of relief is a flat grey
|
||
rectangle to look at.
|
||
|
||
`internal/detail` is a port of `Scripts/Authoring/heightmap_erosion.py` and every brake came across by name -
|
||
the 0.25 slope gate, the per-step cut cap, the load cap, the 3×3 cut brush with the deposit on the droplet's
|
||
own cell, and thermal shedding half the *largest* excess. What did not come across is how the randomness is
|
||
drawn, and that is the whole of what tiling costs.
|
||
|
||
**Droplets have to be a pure function of world position.** The numpy picks spawn cells from an RNG stream,
|
||
which is index-dependent: a cell would get different droplets depending on which tile it fell in, and every
|
||
seam would show. Here a cell's droplet count, each droplet's sub-cell start, and which round it belongs to are
|
||
all hashes of (seed, world cell). A droplet spawned in a tile's interior is then bit-identical to the one
|
||
spawned when that same cell falls inside a neighbour's margin.
|
||
|
||
**And the round count had to stop being derived from the droplet count.** The numpy batches so that a channel
|
||
deepens as more water follows it: droplets in a batch read the height as it was when the batch began. Derived
|
||
from the total, the batch count depends on how big a piece of the world is being worked on - so a droplet
|
||
would land in a different round in a tile than in the whole map, and the tiles would not close. It is a
|
||
manifest number now.
|
||
|
||
**The margin is measured, not reasoned.** Rule 2 says to size it by how far a pass can move material, and for
|
||
droplets that is not simply the lifetime: across rounds the cut edge's influence walks a lifetime further in
|
||
each time, which taken literally would be `rounds × lifetime` - 640 cells against a 2500-cell tile. Measured
|
||
against the same ground in one whole run, at lifetime 12 and 8 rounds:
|
||
|
||
| cells in from the cut edge | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 |
|
||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||
| worst difference, m | 7.97 | 2.53 | 0.72 | 0.49 | 0.44 | 0.18 | 0.03 | 0.00 | 0.00 |
|
||
|
||
It is the first lifetime that carries almost all of it and by three it is gone, because a droplet has to be
|
||
unlucky in the same way several rounds running for the error to keep propagating. **Three lifetimes plus the
|
||
brush** is the margin: 122 detail cells at the default lifetime, 244 m, about five per cent of a 5 km tile on
|
||
each side. At one round the margin of lifetime + 2 is *exactly* sufficient and the match is bit-for-bit, which
|
||
is what `TestATilesInteriorMatchesTheWholeMap` asserts.
|
||
|
||
**Four things that were wrong, three of which would not have failed loudly.**
|
||
|
||
1. **The detail noise cannot use the world period.** A noise lattice holds `(period/wavelength)²` floats, so
|
||
an eight-metre octave on a hundred-kilometre period is a gigabyte and a half for one octave. The detail
|
||
passes get a period of their own - a kilometre, which must still divide the circumference - and what
|
||
repeats at that scale is a few metres of surface roughness with no shape to it. Everything with a shape
|
||
comes from the solve and the paint, and neither repeats.
|
||
2. **The derivative maps were normalised by a percentile of the tile.** `field.ToUnit` takes the 99th
|
||
percentile of what it is given, which is right for one map of one world and wrong for a tile: it is a
|
||
statistic of the piece being looked at, so two tiles would stretch by different anchors and their shared
|
||
valley would come out two different greys. Exactly the mistake the coastal pass's exposure made and had
|
||
withdrawn. Fixed full-scale values now.
|
||
3. **The sea has to be flattened before the upsample, not after.** The geology raster drops from the shore to
|
||
the painted ocean depth in one cell, so a Catmull-Rom upsample rings at every coastline; and with the sea
|
||
left in place, thermal weathering finds the whole shore past the angle of repose and pours it in - the
|
||
mirror image of the first run with a coast, which eroded the land to 174 m *below* sea level. Flattening
|
||
first also means the detail land mask can be read off the interpolated height, which matters more than it
|
||
sounds: taken up from the geology mask by nearest neighbour instead, the coastline came out as a visible
|
||
staircase of 8 m blocks.
|
||
4. **The droplet stencils must not touch water.** Both the 3×3 cut brush and the bilinear deposit straddle the
|
||
waterline whenever a droplet is within a cell of it, and since the sea floor is restored afterwards,
|
||
anything written there is silently thrown away - sediment that should have built a beach, quietly deleted.
|
||
The cut is skipped and the deposit is given to the droplet's own cell.
|
||
|
||
**The parallel decomposition had to be fixed too, for a reason worth keeping.** The droplets scatter into
|
||
per-band buffers that are summed afterwards, and the first version partitioned by core count: floating-point
|
||
addition is not associative, so a cell's contributions summed in a different grouping gave a different last
|
||
bit and `TestParticleIsDeterministicAcrossGOMAXPROCS` failed by one ulp. `field.FixedBands` exists for this -
|
||
a partition fixed by the grid rather than by the machine. A loop that only writes into its own rows can be
|
||
split any way at all; one that *reduces* cannot.
|
||
|
||
Also: `thermal.Apply` sliced the caller's scratch buffer without checking it, so the first tile bake ended in
|
||
a panic rather than a weather simulation; it allocates when it has to now. And `terrain tiles` refuses a bake
|
||
whose manifest has moved - a heightmap is 16-bit samples over an elevation range, so decoding one under a
|
||
different range shifts every height, and when the shift takes the land below sea level every tile decides it
|
||
is ocean and writes a flat zero. That happened, and there was nothing in the output to say why.
|
||
|
||
**Measured, on the first template.** 20 × 10 tiles of 5 km; twelve seconds a tile at 2 m with 1.4 M droplets,
|
||
so the whole planet is about forty minutes of detail against two hours of geology. The tiles carry the
|
||
heightmap, a hillshade, and flow, wear and deposit.
|
||
|
||
**Still open:** the coastal pass does not wrap, so the shore is still a step where the land meets the painted
|
||
ocean depth and the coastal detail of §4.E3 has nothing to attach to; pass 13, the spawn pad, is deliberately
|
||
skipped because a planet has no single centre; and the weightmaps of pass 14 are not derived, because nothing
|
||
imports them yet. *(The first of those was closed by D-60: there is a shelf and a shore to attach to now.)*
|
||
|
||
**2026-09-17, after the coast. Painted planets: a template becomes a world (D-53).** The generator's source
|
||
stops being a seed. `RawContent/World/Templates/Map3.jpg` — a hand-painted flat cylindrical world map,
|
||
7738 × 3761, X wrapping, ice caps top and bottom, a meteor-crater island straddling the seam — is now an
|
||
input, and the question this round answered is what a painted map is allowed to say.
|
||
|
||
**It says uplift, and it may not say height.** That was already written down (Terrain-Next §3.2 and §6) and
|
||
building it did not change it. What building it *did* change is how little else is left for noise to do: with
|
||
the paint supplying the outline, the ranges and the rock, the procedural side of `internal/uplift` reduces to
|
||
two things and only one of them is optional. The initial relief still only breaks the symmetry. The regional
|
||
swell is not optional at all — D-49 is arithmetic, `S = U/(K·A^m)` applied down to a single cell, so a painted
|
||
lowland holding one rate over forty kilometres has no divides anywhere and the router draws the priority
|
||
flood's traversal order across it as rivers. It is the first thing that will be cut for time and it must not
|
||
be.
|
||
|
||
**The solve is decomposed per landmass, and that is exact rather than approximate.** This document says the
|
||
fluvial solve cannot be tiled and that is still true: drainage area is an integral over the whole upstream
|
||
catchment. But it is not one problem. Ocean cells are held fixed at sea level for the entire run and *nothing
|
||
in the solve can move them* — `ComputeReceivers` makes every outlet its own receiver, so a receiver chain
|
||
starting on land terminates the moment it steps into water; `StreamPower`, both diffusions, the repose clamp
|
||
and `thermal.Apply` all skip a fixed cell; the priority-flood closes every outlet before its loop and never
|
||
re-enters a closed cell. So no flow path crosses open water, every basin is contained in one eight-connected
|
||
land component, and solving a landmass in a box of its own gives the same answer on land as solving the planet
|
||
whole. `TestOceanCellsAreUntouchedByTheSolve` asserts that premise directly, because if it ever stops being
|
||
true the composite is silently wrong and nothing else in the suite would say so.
|
||
|
||
Measured, at 100 km around with the geology cell fixed at 8 m by D-48: the whole planet is 12500 × 6076 =
|
||
**76 M cells**, which is about 2.7 GB of `fluvial.Grid` before `uplift` has allocated anything, and 100
|
||
minutes at the old rate. Cut into landmasses it is **18 regions and 49 M cells**, the largest 14 M, and the
|
||
peak is under a gigabyte. Regions are found by dilating the land mask with one exact distance transform and
|
||
connected-componenting the result — **not** by overlapping dilated bounding boxes, which are transitively
|
||
closed and would have collapsed this template into a single region, because one landmass is 70 km wide.
|
||
|
||
**The coast is not decomposed, and working out why was the useful part.** The obvious move is to give each
|
||
region enough margin for the coastal pass to run inside it, and the margin that needs is `shelf_km` plus
|
||
`slope_km`, 4.6 km, which nearly doubles every region. What it buys is nothing, and what it costs is specific:
|
||
the fetch is truncated across every strait, so two islands six kilometres apart stop sheltering each other;
|
||
the sediment budget splits, and the `boxBlur` symmetry argument its conservation rests on has to be re-proved
|
||
per region; and `ShorelineKm`, `SeaFraction` and the exposure percentiles become statistics that do not pool.
|
||
Against that, the pass costs **83 ms at 3.2 M cells** — 26 ns a cell, against 80 ns a cell *per step* for the
|
||
solve — so the whole planet is about two seconds of compute. It runs once, over the finished cylinder.
|
||
|
||
> **Decompose the solve, not the map.** The fluvial solve is the only pass that is both expensive and
|
||
> non-local, and it is exactly decomposable at the ocean. Everything else runs whole, with the wrap pushed
|
||
> into four primitives — the distance transform, the box blur, the fetch ray march and world-coordinate
|
||
> noise — and nowhere else.
|
||
|
||
**Three consequences taken deliberately.** A polar cap touches the top row of the map, and `isOutlet` treats
|
||
every grid-edge cell as an outlet, so painted ice there would freeze at its initial relief while the interior
|
||
eroded out beneath it — the exact failure `continentMask`'s four per cent margin exists to prevent. The planet
|
||
raster gains a few rows of synthetic ocean above and below the painted map instead: the caps become ordinary
|
||
landmasses with a shore, `isOutlet` stays exactly as written, and the fiction lives entirely in rows that are
|
||
discarded before anything is written out. A cap calving into a polar sea is, as lies go, the right one. A
|
||
landmass that rings the planet is **refused** rather than approximated, because a region has to be a rectangle
|
||
with water on both sides. And the seed alone no longer names a world: the margin and the minimum landmass size
|
||
decide how the planet is cut up, and the priority-flood's epsilon ladder across a flat depends on the box it
|
||
is flooding, so all three live in the manifest and all three are recorded in `meta.json`.
|
||
|
||
**The router jitter moved to world coordinates**, which is rule 1 of the tiling plan and was overdue. It is a
|
||
hash of (seed, world cell) rather than of the flat grid index, so the same physical cell jitters the same way
|
||
whichever region's grid it turns up in. It is one line of arithmetic and it re-baselines every measured number
|
||
in Terrain-Next §1, which is why it was done first and on its own.
|
||
|
||
**Four things the shape of the work revealed.**
|
||
|
||
1. **The exact distance transform had three callers, not one.** `coast.edt` was written for the coastal pass,
|
||
and the region partitioner's dilation and the template classifier's stroke fill both want the same
|
||
function. It is `internal/dt` now, and it gained a cylinder: the row pass lays the row out three times and
|
||
reads the answer from the middle copy, so the three images of any column sit at offsets `d`, `d-w` and
|
||
`d+w`, whose smallest absolute value is the cyclic distance — the envelope returns the wrapped answer with
|
||
no special case in it. Exact, three times the row cost, twenty lines.
|
||
2. **A `Field` must not carry a world origin.** It was the tidy option and it is wrong: a `Field` is used for
|
||
masks, coordinate pairs and scratch, `field.New` has no origin to give them, and every one of them would
|
||
quietly claim to sit at (0, 0). A wrong-by-default origin cannot be seen; a missing argument is a compile
|
||
error. The half-dozen places that need world coordinates take a `world.Frame` instead.
|
||
3. **Every image writer was silently square.** `WritePreview`, `WriteDataMap`, `WriteBasinMap` and
|
||
`WriteThumbnail` all resampled to `size × size`, which is invisible on a square canvas and squashes a 2:1
|
||
planet into a lie. They keep the field's own aspect now, which is the same number on the old canvas.
|
||
4. **White is painted twice on a hand-drawn world map** — the polar caps and an outline stroke around every
|
||
island — so exactly one class can own the colour, and it has to be the stroke, because the stroke is the
|
||
one that must be recognised wherever it appears. What the caps become is a `derived` class with no colour
|
||
of its own, rescued by connectivity: a white region touching the top or bottom row is a cap, and every
|
||
other white dissolves into whichever real class is nearest, split down its middle by the same distance
|
||
transform. On this template the run reports **2 084 442 px rescued and 0 dissolved**, which is the
|
||
measurement that says white here is only ever the caps.
|
||
|
||
New packages: `internal/world` (the cylinder and the frame), `internal/dt` (the transform, moved and
|
||
wrapped), `internal/template` (the image, the legend, the classifier), `internal/region` (the partition) and
|
||
`internal/planet` (the driver). New commands: `terrain plan`, four seconds, which reads the painting and cuts
|
||
the planet up without eroding anything, and `terrain bake`. `RawContent/World/Planet.json` is the planet's own
|
||
manifest; `World.json`, `world_manifest.py` and `create_world.py` are untouched.
|
||
|
||
**Still open here:** the coastal pass does not wrap yet, so a bake lays the painted sea floor and the shelf,
|
||
the surf and the sediment budget are not applied; `internal/stats` still sorts every land cell and its local
|
||
relief is O(radius²), neither of which survives 28 M land cells; and the detail passes are still unbuilt, so
|
||
there is still no full-resolution output. See `Terrain-Next.md`. *(All three are closed now: the detail passes
|
||
by D-53 continued, the statistics by D-59, the coastal pass by D-60.)*
|
||
|
||
**2026-09-17, last. The mask is thresholded, not multiplied (D-52), and a broken statistic is retired.** The
|
||
coastal pass reported a mean sea cliff of two metres, and the conclusion drawn from it — that the continent
|
||
mask, by multiplying the uplift rate, made every coastline the lowest-uplift ground on the map — was half
|
||
right and rested on a measurement that could not have said anything else.
|
||
|
||
**The statistic first, because it is the more useful lesson.** "Mean cliff" measured the drop from a cell to
|
||
its seaward neighbour. That is a *gradient*: at the angle of repose one cell of a 10 m grid is 7 m, so the
|
||
number was bounded above by 7 however tall the coast was, and it read 2 m on a plain coast and 3 m on a
|
||
cliffed one because it could not distinguish them. It is now backshore height — the land's elevation between
|
||
one and two surf reaches inland, median and P90 — and on that metric the coast always had cliffs: P90 88 m on
|
||
seed 7, 108 m on seed 9342, 120 m on seed 67914, against a median of 3 to 9 m that correctly says the ordinary
|
||
coast is a plain. **A cliff is how far you fall, not how steep the first cell is.**
|
||
|
||
**The change is still right, for a narrower reason.** `rate = r * l` tapered uplift to zero across the shore,
|
||
and steady state is `S = U/(K*A^m)`, so ground with no uplift grades to no slope. What that flattened was the
|
||
hundred-metre strip the surf works in, not the backshore — so the cliff began a hundred metres inland instead
|
||
of at the water. The mask now answers only "is this cell sea", which is the yes-or-no the solve needs for its
|
||
base level, and a range that runs out to the water rises at range rates right up to it. Measured with
|
||
everything else held: surf cut 23.32 → 38.87 Mm³ and planed area 3.0 → 3.9 km² on seed 7, 22.42 → 30.35 Mm³
|
||
and 2.4 → 2.9 km² on seed 9342, with the detail crop showing high ground reaching the waterline where a
|
||
uniform low fringe stood in front of it before.
|
||
|
||
**What the margin was quietly relying on.** `continentMask` keeps land off the map border because a border
|
||
cell is an outlet — it takes no uplift and is never eroded, so land that reaches it freezes while the interior
|
||
erodes out beneath it. The margin tapers the mask, and while the rate was multiplied by the mask it was
|
||
tapering the uplift too, as a side effect nothing named. `TestBorderIsAlwaysOcean` now asserts the invariant
|
||
directly on three seeds and it holds: every border cell is ocean, the nearest land is five cells in, and that
|
||
land drains to ocean at sea level, so nothing is frozen. The test also logs what the margin costs, which had
|
||
never been measured: **14 to 15 % of the waterline lies inside the margin band**, cut along a contour of
|
||
distance-to-edge, which is a straight line parallel to that edge. Pre-existing, cosmetic, and made conspicuous
|
||
by this change because the land there now carries the full 2.0 mm/yr.
|
||
|
||
**And a warning about the acceptance test itself.** The slope–area fit moved −0.480 → −0.312 on seed 7 and
|
||
−0.698 → −0.720 on seed 9342 — opposite directions, and both inside a seed-to-seed spread on identical code
|
||
(−0.480, −0.698, −0.575 at R² 0.317, 0.704, 0.944) that is several times the size of the effect. It is the
|
||
number this document calls the proof that closes the work, and at five or six bins on a 1400 grid it cannot
|
||
carry that on a single run. Pair it — same seed, before and after — and read at least two seeds.
|
||
|
||
**2026-09-17, later still. The coast: a pass rather than a line.** D-48 kept the continent because sea level
|
||
is a better-posed base level for the solve than one outlet edge, and that is all it was: the mask said which
|
||
cells were ocean, the solve held them at sea level, and afterwards the sea floor dropped to a flat plane at
|
||
−180 m in a single step. A third of the map and a third of the elevation range was one flat surface; the land
|
||
met the water at whatever angle the last erosion step happened to leave; no process in the generator knew the
|
||
shoreline was there. `internal/coast` is the pass that does, and it runs *after* the solve because two of its
|
||
three parts need the finished terrain.
|
||
|
||
**The coordinate is a signed distance, not a line.** Every coastal process is written as "how far is this cell
|
||
from the waterline, and which stretch of shore does it belong to", so the pass opens with an exact Euclidean
|
||
distance transform carrying a feature index — Felzenszwalb and Huttenlocher's two 1-D passes, O(n) whatever
|
||
the radius. Exact rather than a chamfer: there is nothing to buy by approximating an O(n) algorithm, and a
|
||
chamfer's 2 % anisotropy would show as a shelf wider along the grid axes than across them. Everything after it
|
||
is a lookup.
|
||
|
||
**The shelf is derived, not set.** A margin is a gentle shelf out to a break, then a much steeper continental
|
||
slope to the abyssal floor. Its width is read off the relief standing behind each stretch of shore, so a low
|
||
coastal plain gets a wide shelf and a range that comes down to the water gets a narrow one, out of the same two
|
||
manifest numbers and without either having been asked for. `sea_floor_m` keeps its meaning; what changed is that
|
||
the depth between its two ends is now a function of distance offshore.
|
||
|
||
**The cliff is a consequence.** Within a reach of the waterline the land is planed towards a shore platform, and
|
||
the reach is set by how open the water is. Nothing draws a cliff: the cliff is the step where the reach ends, so
|
||
its height is whatever the land behind it stands at, which is the right way round. The cut rolls off only over
|
||
the last quarter of the reach — rolling it off across the whole reach gives a ramp, which is what a coast looks
|
||
like when it has been smoothed rather than eroded.
|
||
|
||
**The sediment is accounted for.** What the surf cuts is counted, carried a drift length along the shore and
|
||
laid in sheltered water shallower than a few tens of metres; river mouths deliver their own load in proportion
|
||
to what they drain, which is what makes a delta. The summary prints the volume cut, delivered, laid and left
|
||
unplaced, because the sediment budget is the one part of this that is not derived from something already
|
||
measured.
|
||
|
||
**And the invariant that is now enforced in one place.** `uplift.Result.Bathymetry` is gone. Ocean cells sit at
|
||
sea level for the whole solve and the coast pass owns the sea floor outright, which is the same rule as before
|
||
— a coastal cell drains into an ocean cell, and an ocean cell already at −180 m makes the solver cut the river
|
||
down to −180 m — but with one owner instead of two.
|
||
|
||
*Four things that were wrong first, each worth keeping.*
|
||
|
||
1. **Exposure by percentile.** Stretching the map's own 5th-to-95th percentile onto 0..1 is robust and collapses
|
||
to nonsense on a coast that does not vary: a straight one has no spread, so the whole continent came out at
|
||
one end of the scale. It is also a global statistic, which rule 1 of the tiling plan rules out — two tiles
|
||
would stretch by different anchors and their shared bay would be two different colours. The anchors are now
|
||
fixed and physical.
|
||
2. **Fetch in every direction.** That counts the land *behind* the shore as shelter, and every coast has land
|
||
behind it, so a straight open coast — where seven rays in sixteen stop after one cell — scored as more
|
||
sheltered than the back of a bay half a kilometre wide. Restricting to the seaward half-space, cosine-weighted
|
||
from the shore normal, is the standard effective fetch and gets the sign right.
|
||
3. **`cut_fraction` as a fraction of the height above the platform.** Fifteen per cent of a 120 m headland is
|
||
18 m, which is not a rough platform, it is an uncut headland. The residual is capped at a few metres.
|
||
4. **The deposition kernel, twice.** `dep = blur(supply) * want / blur(want)` looks like a normalised convolution
|
||
and is not one: the blur spreads supply onto land, deep water and exposed headlands, all of which want
|
||
nothing and are skipped, and 68 % of the budget was silently dropped there. The conserving order is to divide
|
||
the supply by the blurred want *first*, then blur, then multiply by the want — which sums to exactly the
|
||
supply. And one kernel cannot do both jobs: the sediment needs zero padding to keep the kernel symmetric,
|
||
which is what the balance rests on, while a carried *value* like the shelf width needs edge clamping, and
|
||
smoothing the width with the mass-preserving kernel shrank every shelf near the border to nothing. There are
|
||
now two, sharing their arithmetic so they cannot drift apart.
|
||
|
||
**What the pass measured about the continent, which is the part that mattered most.** With the coast built, the
|
||
fetch reported that the median stretch of shoreline was *fully open*: there were no bays. The cause is the
|
||
continent outline itself — five octaves over a 14 km map puts its finest feature at about 450 m, which is a
|
||
smooth blob, and a coastline is fractal, which is the whole content of the Richardson paradox. Sweeping the
|
||
outline's octave gain on seed 7 at 1400, everything else held:
|
||
|
||
| outline gain | 0.50 | 0.58 | **0.62** | 0.66 |
|
||
| --- | --- | --- | --- | --- |
|
||
| shoreline | 64 km | 81 km | **96 km** | 114 km |
|
||
| median shore exposure | 1.00 | 0.98 | **0.84** | 0.51 |
|
||
|
||
and the octave count, at gain 0.50, gave 59, 63, 64, 65, 66 km at 5, 7, 8, 9, 10 — flat past 9. D-51 takes 8
|
||
octaves at gain 0.62: islands, inlets and headlands that shelter each other, without the outline breaking into
|
||
speckle. The whole coastal pass costs 83 ms at the manifest's geology grid, against 130 s for the solve.
|
||
|
||
**2026-09-17, later. The plains problem: the uplift field, the router's flat ground, and the hillslope law.**
|
||
The complaint was that the lowlands read as mountains that had been shrunk - same texture, same shading,
|
||
lower peaks. It was not an erosion-tuning problem and no amount of work downstream would have fixed it.
|
||
|
||
**The diagnosis, which is arithmetic.** Steady state is `S = U/(K*A^m)`. With `critical_area_m2` at 0 that law
|
||
is applied down to a single cell, so at every divide `A = cell^2`; at K 5e-5, m 0.5 and a ~10 m geology cell
|
||
that makes `S = U/4.8e-4`. An intraplate rate of 0.25 mm/yr is therefore a 28 degree hillslope and the 0.9
|
||
mm/yr swell is past the 35 degree repose angle - so the repose clamp, which is meant to be a mountain
|
||
process, was the surface of the entire continent. For n = 1 the uplift rate alone fixes the hillslope angle:
|
||
`U` sets how *high* the summits get, not how steep the ground is.
|
||
|
||
**The measurement that says so** is new, and is the first thing built: `stats.UpliftBuckets` splits the land
|
||
by the uplift rate that caused it - plain below 0.1 mm/yr, rolling to 0.5, mountain above - and reports each
|
||
bucket's median and P90 slope, local relief over a 500 m window, and the fraction pinned within 2 degrees of
|
||
talus. Uplift is the right axis because it is an *input*: a cell does not change bucket when the run does,
|
||
which elevation-banding cannot promise. Map-wide aggregates cannot answer "are the plains plains", which is
|
||
why this was invisible for so long.
|
||
|
||
| Run | plain, % of land | plain median | mountain median | mountain at talus | slope-area R2 |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| Before | 1 % | 6.5 deg (sea cliff) | 26.4 deg | 32 % | 0.688 at -1.59 |
|
||
| Uplift fixed (D-49) | 42 % | 0.8 deg | 31.1 deg | 44 % | 0.055 at -0.26 |
|
||
| plus router jitter (D-50) | 42 % | 0.8 deg | 31.1 deg | 44 % | 0.459 at -0.33 |
|
||
| plus nonlinear hillslope | 42 % | 0.8 deg | 28.5 deg | **33 %** | 0.225 at -0.38 |
|
||
|
||
The first row is the whole diagnosis in one line: 81 % of the land sat in the mountain uplift class and the
|
||
plain class held 1 %, all of it coastal cliff.
|
||
|
||
**Fixing the uplift field immediately exposed the next thing**, exactly as expected: once the plains were
|
||
genuinely flat, the only gradient across them was the priority-flood's epsilon and the router drew the
|
||
flood's traversal order as rivers. Hence D-50. The jitter costs nothing and recovered most of the slope-area
|
||
fit on its own.
|
||
|
||
**The hillslope law** is now `q = D*S/(1-(S/Sc)^2)` (`internal/fluvial/hillslope.go`), replacing linear
|
||
diffusion. It is linear diffusion as `S -> 0`, so the lowlands are untouched, and it is mass-conserving,
|
||
which the clamp is not. Three things are worth knowing about it:
|
||
|
||
- **It is stiff, and the stiffening is bounded.** `D_eff = D(1+u^2)/(1-u^2)^2` diverges at `u = 1`; at the
|
||
defaults `u = 0.9` alone wants seventy sub-steps a step. So `u` is capped at `slope_cap` and, if the
|
||
`max_hillslope_substeps` budget cannot buy even that, the cap is lowered further rather than the sub-step
|
||
count truncated. Truncating is the tempting branch and it is wrong: it leaves alpha above the stability
|
||
limit and grows a checkerboard over hundreds of steps, which by then looks like texture.
|
||
- **It therefore cannot replace the clamp.** A belt rising at millimetres a year asks for slopes no
|
||
bounded-flux transport law holds; that is a fact about the forcing, not the scheme. The clamp stays in the
|
||
loop.
|
||
- **What changed is the order.** Diffusion runs *after* the clamp, every step. The clamp cuts along eight D8
|
||
directions and leaves grid-aligned pyramid faces - the blocky facets visible in every earlier mountain
|
||
preview - and a symmetric five-point stencil rounds them off before the next step sees them. Clamping once
|
||
at the end instead was tried and measured: a thousand steps of growth arrive together, it cuts deeply, and
|
||
nothing runs afterwards to soften it. The facets came back.
|
||
|
||
**The maps.** A run now also writes `map_uplift`, `map_erodibility`, `map_slope`, `map_relief`, `map_flow`
|
||
and `map_basins` beside the preview (`internal/field/datamap.go`). `preview.png` says whether the landscape
|
||
looks right; these say *why*, and the uplift map would have shown this whole problem at a glance with no
|
||
arithmetic at all. `map_basins` is the direct test of whether the solve made a network rather than scratches.
|
||
|
||
### Three findings worth remembering
|
||
|
||
1. **A channelization threshold still fails, and now we know what it is waiting for.** Re-measured after the
|
||
uplift fix: `critical_area_m2` 1e4 sends the plains back to 7.0 degrees, pins 49 % of the rolling class
|
||
and 79 % of the mountains against the clamp, and collapses the slope-area fit to R2 0.001. The hillslope
|
||
it creates has to shed its uplift by diffusion and at D 0.02 it cannot, so the clamp takes the job. It is
|
||
not a tuning question; it needs a transport law strong enough to pair with, and it stays at 0 until there
|
||
is one.
|
||
2. **The nonlinear flux was written in height differences and fed a slope.** `u = dh/Sc` instead of
|
||
`dh/(Sc*dx)` makes `u` a factor of `dx` too large, which pins every face against the cap and turns the
|
||
whole law into linear diffusion with a constant multiplier. It produced *better-looking* terrain than the
|
||
correct version, because over-smoothing hides facets. The unit test caught it; the preview did not.
|
||
3. **Two of the four new tests passed while measuring nothing.** One read the fixed border cells back and
|
||
called them the result; the other wrote a checkerboard across the fixed border, which then re-injected it
|
||
into the interior for ever, so the scheme was blamed for a boundary condition. A test on a grid whose
|
||
edge is an outlet has to say which cells it is actually asking about.
|
||
|
||
### Still open
|
||
|
||
- **The hypsometric integral is still 0.10**, and the reason has changed: it is no longer a bimodal uplift
|
||
field, it is that 42 % of the land is now a near-sea-level plain. Whether that is wrong depends on whether
|
||
a broad low continent is what is wanted; it is a question for the continent block, not the solve.
|
||
- **Half the land is still in the mountain uplift class** (51 % on seed 7, 44 % on seed 9342), because
|
||
`rangeMask` ramps from the 40th to the 86th percentile and anything above about the 55th clears 0.5 mm/yr.
|
||
Real continents are nothing like half mountain. This is the next uplift-field question.
|
||
- **Nature does not like straight lines, and three sources remain.** The fault traces are single 8-point
|
||
parabolas with a hard cutoff at the tips, and their polygonal influence regions are plainly visible as
|
||
straight-edged facets in `map_uplift`. The range grain is `across*2.2` with one warp octave, so chains run
|
||
as straight parallel bands. And multiple-flow-direction accumulation would dissolve what is left of the
|
||
diagonal river grain that the jitter only reduced. None is built.
|
||
|
||
|
||
**2026-09-17. Build-order steps 3 and 4: the Go skeleton and the fluvial solver.** Nothing in the editor has
|
||
been touched, so steps 1 and 2 (the viewport, the edit layers) are still open and still first in the list
|
||
above; they need the editor closed and it was open. The numpy pipeline is untouched and still the thing that
|
||
builds `L_World`.
|
||
|
||
Built, in `Tools/Terrain/` (module `salty/terrain`, `Scripts/build-terrain.sh`):
|
||
|
||
| Package | What it is |
|
||
| --- | --- |
|
||
| `internal/field` | `Field`, the one array type: shape, cell size in metres, float32 data. Resampling on the vertex convention, Catmull-Rom integer upsample, five-point blur, slope, curvature, greyscale PNG in and out through `image/png`, hillshaded thumbnails. `field.Rows` is the only place goroutines are created |
|
||
| `internal/manifest` | `World.json` over Go-side defaults, the height contract mirroring `world_manifest.py`, and a validator that refuses a resolution the importer would turn into thousands of components |
|
||
| `internal/noise` | Value noise, fBm on warped coordinates, Worley crest lines, per-pass PCG sources |
|
||
| `internal/uplift` | The continent, the uplift *rate* field and the small initial relief |
|
||
| `internal/fluvial` | The solver: priority-flood, D8 receivers, the stack, drainage accumulation, the implicit stream-power update, sub-stepped hillslope diffusion |
|
||
| `internal/stats` | Slope–area, hypsometry, slope histogram, drainage density, and the verdict line |
|
||
| `internal/check` | The two integration tests |
|
||
|
||
**The proof.** `TestSteadyStateMatchesStreamPower` puts uniform uplift on a uniform grid and checks the
|
||
analytic answer of `dh/dt = U − K·A^m·S^n`, which is that `K·A^m·S^n / U` is 1 at every channel cell. It
|
||
measures **1.0000** over 822 channel cells. On the real continent the fitted slope–area exponent is **−0.498
|
||
against an expected −0.500 at R² 0.996**, and drainage density is 1.33 /km, inside the real-world 1–10 band.
|
||
That is what build-order step 4 asked for and it is met.
|
||
|
||
`TestDeterministicAcrossGOMAXPROCS` hashes the pipeline output at GOMAXPROCS 1, 2, 4, 8 and 16 and requires
|
||
one hash, which is the assertion this document makes about Go and cross-cutting rule 12. It passes.
|
||
|
||
### Where it differs from the spec above
|
||
|
||
- **`fill_every` is 1, not 50**, and the table in the fluvial section is why. The 50 was written to protect
|
||
the time budget and it silently destroyed the solve.
|
||
- **1000 steps, not 5000.** Measured, not assumed; see the time note. This is most of the budget back.
|
||
- **The priority-flood uses a monotone bucket queue, not a binary heap.** The flood pops in non-decreasing
|
||
elevation and never pushes below the current front, which is exactly the condition that makes a bucket
|
||
queue valid, and it removed the `log n` from two thirds of the runtime for a measured 1.6× on the whole
|
||
solve at identical output (−0.498 against −0.500 before).
|
||
- **Slope–area is measured two ways and the normalised one is the verdict.** Steady-state stream power gives
|
||
the same gradient but a different *intercept* per uplift rate. This map's uplift spans 0.2 to 5 mm/yr, so
|
||
regressing every channel together stacks twenty-five-fold-separated parallel lines and fits nonsense: the
|
||
raw fit reads −0.70 at R² 0.91 on a landscape whose true exponent is −0.50. Slope is normalised by
|
||
`(U/K)^(1/n)` first. The raw figure is still reported beside it.
|
||
- **`Continent` is a pipeline block the spec above does not list**, because D-48 kept the coast and the coast
|
||
needs parameters.
|
||
- **`uplift.Result` carries `Bathymetry` separately from `Height`.** The sea floor is held at sea level for
|
||
the duration of the solve and put back afterwards. Left in, a coastal cell drains into an ocean cell at
|
||
−180 m and the solver obligingly cuts the river down to −180 m; the first run with a coast eroded the land
|
||
to 174 m *below* sea level. A river's base level is sea level; the bathymetry is scenery.
|
||
|
||
### Three bugs worth remembering
|
||
|
||
All three produced plausible-looking terrain, which is the point: none of them would have been caught by
|
||
looking at it.
|
||
|
||
1. **Slope–area measured with the topographic gradient instead of the channel gradient.** For a cell on a
|
||
valley floor the central difference is dominated by the valley walls, not by the direction the water
|
||
runs. The exponent read −0.78 where the truth was −0.50. `S` in the stream-power law is
|
||
`(h − h_receiver)/L` and nothing else.
|
||
2. **Cells at a local minimum were skipped entirely, uplift included.** They are the cells differential
|
||
uplift is actively pushing up, so freezing them removes exactly the basins that should be forming. Fixed
|
||
by letting a root still rise.
|
||
3. **The fix for (2) then uplifted the outlets.** The map border is an outlet but is not ocean, so on a map
|
||
with no coast every border cell had `Receiver == self` and `Base == false` and base level rose 2 m a step
|
||
with the whole landscape chasing it. The steady-state ratio read 0.03 instead of 1.0. The union of "ocean"
|
||
and "border" is now a single `fixed` mask. The real runs were never wrong, because there the border *is*
|
||
ocean — only the test had no coast, which is why it caught it.
|
||
|
||
### Still open
|
||
|
||
- Passes 1, 3 and 4 (plates, faults, lithology) are not built; `uplift` currently derives its rate field from
|
||
the percentile-thresholded range band alone, which is build-order step 5.
|
||
- **The hypsometric integral is 0.11, against 0.4–0.6 for a mature landscape**, and it has been at 0.11 in
|
||
every run. The map is a wide, near-flat coastal plain with mountains on a fraction of it, which is what an
|
||
intraplate rate of 0.2 mm/yr against a convergent 5 mm/yr produces: a 25-fold ratio is bimodal by
|
||
construction. This is a tuning question for the uplift field, so it belongs to step 5, but it is the next
|
||
thing that will look wrong.
|
||
- Everything from the upsample onward (passes 8 to 14) is unbuilt, so there is no full-resolution output yet
|
||
and the canvas has not moved: the manifest is still 4081 at 350 cm.
|
||
- **The fluvial pass costs 256 s at the real geology grid, against 120 s budgeted**, and relief there reaches
|
||
2605 m against D-48's 1536 m ceiling. Neither is a defect in the solver — the first is arithmetic and the
|
||
second is `U/K` untuned — but both are decisions waiting at steps 5 and 6. See the time budget.
|
||
- The exponent at 1786² is −0.640 after 1000 steps, still converging toward −0.5 while R² holds at 0.990. A
|
||
straight plot at 0.64 is a perfectly ordinary real landscape (measured concavities run 0.35 to 0.6), so
|
||
this is a question of how long to run rather than a fault, and it trades directly against the item above.
|