739 lines
54 KiB
Markdown
739 lines
54 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 |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| 1 | Plates | geology | — | `uplift`, `boundaries` | New |
|
||
| 2 | Continent | geology | — | `landMask`, `baseLevel` | From today's continent falloff |
|
||
| 3 | Faults | geology | `boundaries` | `uplift`, `faults`, `warp` | New |
|
||
| 4 | Lithology | geology | — | `K` | New (plan view) |
|
||
| 5 | Base relief | geology | `uplift`, `warp` | `height` | From today's noise, amplitude cut hard |
|
||
| 6 | Fluvial | geology | `height`, `uplift`, `K`, `baseLevel` | `height`, `flowAccum`, `flowDir` | New — the point of the exercise |
|
||
| 7 | Thermal (coarse) | geology | `height` | `height` | Today's, unchanged |
|
||
| 7b | Coast | geology | `height`, `landMask`, `flowAccum` | `height`, `landMask`, `exposure` | New (D-51) |
|
||
| 8 | Upsample | → detail | all | all | New |
|
||
| 9 | Detail noise | detail | `height`, slope | `height` | From today's detail octaves |
|
||
| 10 | Strata | detail | — | `hardness` | Today's, unchanged (vertical) |
|
||
| 11 | Particle | detail | `height`, `hardness` | `height`, `wear`, `deposit` | Today's, demoted to detail |
|
||
| 12 | Thermal (fine) | detail | `height` | `height` | Today's, unchanged |
|
||
| 13 | Spawn pad | detail | `height` | `height` | Today's, unchanged |
|
||
| 14 | Derive | detail | everything | weightmaps, flow/wear/deposit/curvature, `meta.json` | Today's rules, ported |
|
||
|
||
Notes where this departs from the spec, each for a reason:
|
||
|
||
**Continent and sea (pass 2) are not in the spec at all.** The spec builds an inland region with an outlet on
|
||
one edge and leaves the boundary condition as an open question. This world has a coast, a sea plane
|
||
(`World_Sea_Proto`), a `sea_level_m` manifest key and statistics reported as "% above sea level". Keeping the
|
||
continent is also the better *simulation* choice: sea level is a fixed base level on every cell the land mask
|
||
calls ocean, which is a far better-posed boundary for a stream-power solve than one fixed edge, and it removes
|
||
the artificial drainage divide that a single-outlet map has along three of its sides. `[DECIDED]`: the world
|
||
keeps its coastline, and the spec's §9 boundary question is closed in favour of the continent.
|
||
|
||
**Lithology and strata are both kept, because they are orthogonal.** The spec's lithology is a plan-view field
|
||
of rock types multiplying `K` by 0.5× / 1× / 3×; the existing strata model is *vertical* banding of hardness
|
||
with a slow tilt, which is what puts shelves and ledges on a cliff face. One varies with where you are, the
|
||
other with how deep you have cut. Lithology enters the fluvial solve at geology resolution; strata scales the
|
||
particle pass at detail resolution, exactly as today.
|
||
|
||
**Uplift replaces the range mask.** Today `heightmap_noise.generate_metres` builds ranges from an elongated,
|
||
warped, percentile-thresholded band, thresholded so ranges and foothills cover about two fifths of the map
|
||
whatever the seed. That percentile trick is the thing that makes the result seed-independent and it is kept —
|
||
but it now shapes the *uplift rate field* that the fluvial pass integrates, not the height directly. The
|
||
spec's own constraint (20–40 % of the map at low uplift, so there are basins to build in) is the same
|
||
statement from the other end, and both are enforced by thresholding on percentile rather than on value.
|
||
|
||
**Base relief amplitude is cut hard.** The spec says 50–150 m × normalised uplift and it means it: the fluvial
|
||
pass is what produces relief, and starting it from 2600 m ridged crests means it spends its whole run tearing
|
||
them down. Today's noise makes the mountains; after this it only breaks the symmetry.
|
||
|
||
**Particle erosion keeps every brake it has.** Demoted from "carves the valleys" to "detail only", which is the
|
||
spec's §4.8, and explicitly forbidden from reshaping what the fluvial pass produced: the slope gate, the
|
||
per-step cut cap, the load cap, the 3×3 cut brush and the own-cell deposit all carry over unchanged, and the
|
||
droplet count scales with cell count (about 9 M at 7141² to hold today's density at 4081²).
|
||
|
||
### The fluvial pass, in detail
|
||
|
||
`dh/dt = U − K · A^m · S^n`, with `m = 0.5`, `n = 1`, `K` from the lithology field around a base of 2e-5 to
|
||
1e-4 /yr with A in m². Braun & Willett's 2013 implicit formulation: compute D8 receivers by steepest descent,
|
||
build the stack, update up the stack. It is O(n) per step and unconditionally stable in `dt`, which is why it
|
||
is the right solver and a naive explicit one is not.
|
||
|
||
- **Pits.** Priority-flood (Barnes) to fill or route depressions, because a D8 receiver graph with a pit in it
|
||
has no path to base level and the implicit update has nothing to solve against. **It runs every step.**
|
||
`fill_every` survives as a knob and its value is 1.
|
||
|
||
This corrects the paragraph that stood here, which set it to 50 to protect the time budget. That was
|
||
wrong, and measurably so: uplift reaches 5 mm/yr, which at `dt` 1500 is 7.5 m a step, so fifty steps is up
|
||
to 375 m of differential uplift between floods — far more than enough to close basins that then sit
|
||
unrouted while everything upstream of them stops eroding. Measured at 512² over 3000 steps, against an
|
||
expected exponent of −0.5:
|
||
|
||
| `fill_every` | 1 | 5 | 10 | 25 | 50 |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| exponent | **−0.500** | −0.277 | −0.121 | −0.061 | −0.100 |
|
||
| R² | **0.992** | 0.861 | 0.318 | 0.120 | 0.371 |
|
||
| run | 93 s | 38 s | 32 s | 28 s | 27 s |
|
||
|
||
Even every fifth step is already broken, and the saving is not worth having. The budget is paid for
|
||
elsewhere — see the time budget below. The Cordonnier/Barnes lake-flow variant, which routes through
|
||
depressions instead of refilling them, remains the fallback if the flood ever has to get cheaper.
|
||
- **Hillslope diffusion** `D = 0.01–0.05 m²/yr` after each step, which is what rounds the divides and stops
|
||
the channel heads from being needles.
|
||
- **Time.** `dt = 1000–2000 yr`, **1000 steps, 1.5 Myr**, not the 5000 steps and 5–15 Myr the incoming spec
|
||
asks for. That figure is generic advice; at this K and this scale the knickpoint celerity `K·A^m` puts the
|
||
response time of a trunk channel near 45 000 yr, so 1.5 Myr is already tens of response times. Measured at
|
||
512², the exponent is −0.499 at 500 steps and does not move afterwards. Finer grids need more, because
|
||
headwaters carry small drainage areas and so respond slowest: at 1024² the exponent is −0.640 at 500 steps
|
||
and −0.584 at 1000, still converging while R² sits at 0.99. Stopping early is the knob for a "young",
|
||
high-relief look, and it is a real one — the landscape is straight-line graded long before it is finished.
|
||
- **Parallelism.** The stack update is sequential *along a flow path* but independent *between basins*.
|
||
Partition by basin, not by row, and reduce in basin-id order so the result does not depend on scheduling.
|
||
|
||
**The parameter ranges and the canvas are not jointly consistent, and this must be checked every run.** Steady
|
||
state puts channel slope at `S = U / (K · A^m)`. At the spec's aggressive corner — `U` = 5 mm/yr, `K` = 2e-5 —
|
||
a cell draining 1 km² sits at 25 % slope, and integrating that up a 7 km profile overshoots the 1536 m ceiling
|
||
badly. At the gentle corner it undershoots into a plain. `U/K` is effectively the single relief knob and the
|
||
ceiling is a hard clip in the 16-bit encoding, so the generator reports the fraction of the map it clipped, as
|
||
`generate_heightmap.py` does today, and a run that clips more than a fraction of a percent is a failed run, not
|
||
a rounded one.
|
||
|
||
## The Go core `[DECIDED]`
|
||
|
||
Go, per the user's decision, in `Tools/Terrain/`, module path `salty/terrain`, never published. The binary is
|
||
built to `Tools/Terrain/bin/` and is gitignored; `Scripts/build-terrain.sh` builds it beside `build.sh`. Go
|
||
1.25.0 is already on the development machine, which has 16 cores, so the toolchain costs nothing and
|
||
`math/rand/v2` is available — but Go is not in the repository and not in the engine's toolchain, so a machine
|
||
that regenerates the world needs its own install, and `build-terrain.sh` says so when `go` is missing.
|
||
|
||
```
|
||
Tools/Terrain/
|
||
go.mod
|
||
cmd/terrain/main.go the CLI
|
||
field/ Field, HeightField, resample, upsample, PNG in and out
|
||
pass/ one file per pass, each implementing Pass
|
||
pipeline/ Context, Pipeline, the manifest reader
|
||
stats/ slope-area, hypsometry, drainage density
|
||
```
|
||
|
||
```go
|
||
type Field struct { W, H int; CellM float64; Data []float32 }
|
||
|
||
type Pass interface {
|
||
Name() string
|
||
Apply(ctx *Context) error
|
||
}
|
||
|
||
type Context struct {
|
||
Seed int64
|
||
Fields map[string]*Field // iterated only through an explicitly sorted key list
|
||
Params Params
|
||
Log func(string, ...any)
|
||
}
|
||
```
|
||
|
||
**Determinism is the one cross-cutting rule that applies here, and Go fights it in three specific ways.** Map
|
||
iteration order is randomised by design, so no pass may iterate `Fields` (or any map) directly to produce
|
||
output; goroutine completion order is nondeterministic, so every parallel reduction must be into a
|
||
pre-allocated indexed slot rather than a channel drain; and `math/rand`'s global source is shared. Each pass
|
||
takes its own `rand.New(rand.NewPCG(seed, passIndex))`, works on integer coordinates, and its result must be
|
||
byte-identical whatever `GOMAXPROCS` is. That last sentence is a test: run the pipeline at `GOMAXPROCS=1` and
|
||
at `GOMAXPROCS=N` and compare the output hashes.
|
||
|
||
**The CLI keeps today's flags**, so the README, the muscle memory and the two documented commands survive:
|
||
|
||
```bash
|
||
Tools/Terrain/bin/terrain generate # the manifest as it stands
|
||
Tools/Terrain/bin/terrain generate --seed 12 # another continent
|
||
Tools/Terrain/bin/terrain generate --source-file RawContent/World/Sources/dem.png --source-elevation 0 2400
|
||
Tools/Terrain/bin/terrain generate --stage fluvial --size 1024 # one pass at a small size, for iterating
|
||
```
|
||
|
||
Output goes where `create_world.py` already looks, `RawContent/World/Heightmaps/`, with the same file names.
|
||
The spec's `out/<preset>/<seed>/` layout serves a preset gallery that does not exist; it is deferred with the
|
||
gallery.
|
||
|
||
**The DEM escape hatch is already built and is better specified than the spec's §8.** `source.kind = "file"`
|
||
reads 16-bit PNG or raw little-endian `.r16`, widens 8-bit, optionally flips, centre-crops to a square,
|
||
converts with the file's own elevation range, resamples (box when shrinking, bilinear otherwise), optionally
|
||
smooths, and re-encodes into the world's range while reporting what it clipped. That behaviour is ported to Go
|
||
as-is, including `--source-file` and `--source-elevation`. A file source skips passes 1 through 8 and enters
|
||
the pipeline at the detail grid, as `"enabled": false` does today.
|
||
|
||
## The manifest
|
||
|
||
One file, not two. The spec's `UTerrainPreset` data asset and its JSON preset are a second and third place for
|
||
the same numbers to live, for a gallery of presets that does not exist and one world that does; the project's
|
||
own rule against abstraction for a single implementation applies. `World.json` keeps `level`,
|
||
`vertices_per_side`, `quad_cm`, `elevation_m`, `sea_level_m`, `spawn_pad_m`, `streaming_grid_components`,
|
||
`source` and `layers` unchanged in meaning, and the `erosion` block is replaced by `pipeline`:
|
||
|
||
```json
|
||
"pipeline": {
|
||
"geology_factor": 4,
|
||
"plates": { "count": 6, "velocity_cm_yr": [1, 5], "convergent_mm_yr": [2, 5], "band_km": [2, 4],
|
||
"divergent_mm_yr": [-2, -1], "rift_km": [3, 6], "intraplate_mm_yr": 0.2,
|
||
"low_uplift_fraction": [0.2, 0.4] },
|
||
"faults": { "major": [3, 6], "minor": [10, 30], "length_km": [2, 15], "spacing_km": [1, 4],
|
||
"throw_major_m": [100, 400], "throw_minor_m": [20, 80], "strike_slip_m": [200, 800] },
|
||
"lithology": { "types": 3, "k_multipliers": [0.5, 1.0, 3.0] },
|
||
"coast": { "shelf_km": [0.6, 3.0], "steep_coast_m": 300, "slope_km": 1.6, "surf_reach_m": 110,
|
||
"platform_grade": 0.02, "cut_fraction": 0.85, "deposit_reach_m": 350, "drift_m": 300,
|
||
"river_m3_per_km2": 1.2e5 },
|
||
"relief": { "octaves": 7, "gain": 0.45, "base_frequency_m": 4000, "amplitude_m": [50, 150],
|
||
"crest_weight": 0.12 },
|
||
"fluvial": { "k": 5e-5, "m": 0.5, "n": 1.0, "dt_yr": 1500, "steps": 1000,
|
||
"diffusion_m2_yr": 0.02, "fill_every": 1 },
|
||
"thermal": { "coarse_passes": 12, "fine_passes": 24, "talus_deg": 35 },
|
||
"strata": { "period_m": 160, "contrast": 0.6 },
|
||
"detail": { "octaves": 4, "amplitude_m": [2, 8] },
|
||
"particle": { "droplets": 9000000, "lifetime": 40, "scale": 0.5, "min_erode_slope": 0.25,
|
||
"max_change": 0.2, "inertia": 0.1, "capacity": 2.0, "max_load": 2.0, "erode_rate": 0.2,
|
||
"deposit_rate": 0.2, "evaporation": 0.02, "gravity": 4.0 }
|
||
}
|
||
```
|
||
|
||
Every key has a default in the Go source, as `heightmap_erosion.DEFAULTS` holds them today, so the manifest
|
||
carries only what differs. `create_world.py` and `world_manifest.py` do not read `pipeline` at all and do not
|
||
need to.
|
||
|
||
## The bridge
|
||
|
||
Two changes to `ULandscapeAuthoringLibrary`, one of which was already the Worklog's open item 1.
|
||
|
||
**`ReimportHeightmapIntoLayer` `[PROPOSED]`.** The spec's §5.1 is the project's existing open item: edit
|
||
layers, `Generated` at the bottom owned by the tool and rewritten whole on every apply, `Sculpt` above it never
|
||
touched. Today `create_world.py` empties the level and builds a fresh landscape, so any hand sculpting dies
|
||
with the next rerun, which is why nobody sculpts. The new entry point takes an existing `ALandscape`, a layer
|
||
name, a heightmap file and the weightmaps, and writes only into that layer — `FScopedSetLandscapeEditingLayer`
|
||
around `FLandscapeEditDataInterface::SetHeightData`, or `ULandscapeEditorObject::ImportHeightmap` per layer;
|
||
the exact API is confirmed against 5.8 before it is written, not guessed. `create_world.py` then reimports
|
||
instead of rebuilding whenever the landscape already exists with the right resolution, and falls back to
|
||
`CreateLandscapeFromHeightmap` when it does not. **Check first** whether edit-layer data survives
|
||
`ChangeGridSize`, since the world-partition split happens after creation. Header changes mean a full rebuild of
|
||
`SaltyEditor` with the editor closed.
|
||
|
||
**A guard on the component layout.** Not needed at 7141, which the importer's own rule resolves correctly, but
|
||
the resolution is now something a manifest edit can break silently and expensively. `CreateLandscapeFromHeightmap`
|
||
logs the layout it chose and refuses a resolution that yields more than 1024 components rather than spending
|
||
forty minutes proving the point.
|
||
|
||
**Deferred, with triggers**, in the project's sense — not undecided, decided against for now:
|
||
|
||
| Deferred | Until |
|
||
| --- | --- |
|
||
| The Editor Utility Widget: preset gallery, thumbnails, preview render target, watched folder | There is a second preset. One world does not need a gallery; two shell commands are the interface |
|
||
| `UTerrainPreset` data assets | The same trigger. `World.json` is the preset |
|
||
| Water plugin river splines from `meta.json` | A water body is wanted in the world. The polylines are exported meanwhile, so the day it happens is a script |
|
||
| Build-zone volumes in the level | Something places a settlement. The polygons are exported meanwhile |
|
||
| Porting a pass to a compute shader | A pass is measured too slow, not predicted to be |
|
||
| A GPU or C++ path for the fluvial solve | The same trigger |
|
||
|
||
## Validation
|
||
|
||
The project already measures rather than eyeballs — the slope histogram settled the noise tuning and attributed
|
||
the rill damage. The spec adds two standard checks that cost nothing and say more, and both go in `meta.json`
|
||
beside the existing statistics:
|
||
|
||
- **Slope–area.** log slope against log drainage area over channel cells should be a straight line with
|
||
negative slope. Curvature or scatter means `K`, `m`, `n` or the run length is wrong. This is the direct test
|
||
of whether the fluvial pass did what it is there for, and it is the reason for the rewrite, so it is the
|
||
proof that closes the work.
|
||
- **Hypsometry.** Cumulative area against normalised elevation should be S-shaped. Convex means too young or
|
||
too much uplift; concave means over-eroded.
|
||
- **Kept from today:** the slope histogram (the standing target is 80 % of the land under 15°), the fraction
|
||
above sea level, the fraction clipped by the elevation range, and the per-layer coverage percentages.
|
||
|
||
Drainage density and a straight slope–area plot are what "it reads as real geology" means operationally. Not a
|
||
screenshot.
|
||
|
||
## Build order
|
||
|
||
The spec's order, adjusted for what is already proven. Steps 1 and 2 of the spec's own list are effectively
|
||
done: the loop is proven end to end, every day, at full resolution.
|
||
|
||
1. **Fix the viewport first.** Worklog open item 2. A generator whose output cannot be seen in the editor
|
||
cannot be iterated on, and this is a World Partition setting, not work.
|
||
2. **Edit layers.** `ReimportHeightmapIntoLayer`, `Generated` and `Sculpt`, `create_world.py` reimporting. Done
|
||
against the *current* PNGs, so it is proven before the generator moves. Worklog open item 1.
|
||
3. ~~**The Go skeleton.**~~ **Done, 2026-09-17.** `Field`, the manifest reader, 16-bit PNG out, the
|
||
thumbnail, the noise toolkit and the continent. The determinism test runs at five values of `GOMAXPROCS`.
|
||
4. ~~**The fluvial solver.**~~ **Done, 2026-09-17.** D8 receivers, stack, implicit update, priority-flood,
|
||
diffusion. The slope–area plot is straight at the expected gradient and the analytic steady-state test
|
||
passes exactly. See what was built, below.
|
||
5. **Plates, faults, lithology** feeding `uplift` and `K`. Scale to the geology grid at 1786².
|
||
6. **Thermal, upsample, detail noise, strata, particle** at 7141². Profile; no GPU work before a measurement.
|
||
7. **Derived outputs**: weightmaps by today's rules, the four derivative maps, rivers, build zones, statistics.
|
||
8. **The canvas move**: manifest to 7141 at 200 cm, elevation −512…1536, streaming grid 2. Full rebuild,
|
||
timed, with the component layout logged.
|
||
|
||
Steps 1 and 2 are worth doing whatever happens to the rest, which is why they are first.
|
||
|
||
## The time budget `[DECIDED]`
|
||
|
||
**A full run holds today's bar of about five minutes.** That is a design constraint, not an aspiration: the
|
||
generator is a thing you rerun while judging a change, and a pipeline you stop rerunning is a pipeline you stop
|
||
tuning. The budget on the development machine's 16 cores, to be replaced by measurements as each pass lands:
|
||
|
||
| Stage | Grid | Budget | Measured |
|
||
| --- | --- | --- | --- |
|
||
| Plates, continent, faults, lithology, base relief | 1786² | 5 s | 1 s (continent and relief only) |
|
||
| Fluvial, 1000 steps, flooding every step | 1786² | 120 s | **256 s** |
|
||
| Thermal, coarse | 1786² | 5 s | — |
|
||
| Coast: shelf, surf, sediment | 1786² | 5 s | **0.08 s** |
|
||
| Upsample and detail noise | → 7141² | 15 s | — |
|
||
| Particle, 9 M droplets × 40 steps | 7141² | 90 s | — |
|
||
| Thermal, fine, 24 passes | 7141² | 20 s | — |
|
||
| Weightmaps, derivative maps, statistics | 7141² | 15 s | — |
|
||
| PNG encode and write, one 16-bit and seven 8-bit | 7141² | 30 s | — |
|
||
|
||
**The fluvial pass is over its budget by a factor of two and the five-minute bar is at risk.** 256 s measured
|
||
against 120 s budgeted, with the rest of the pipeline unbuilt and notionally another 175 s. It is not the
|
||
flood's `log n` — that is already gone, see what was built — it is simply 3.2 M cells × 1000 steps, most of
|
||
it in the two genuinely sequential parts (the stack walk and the flood's cursor). Three ways out, in the
|
||
order they should be tried, and this is a decision for build-order step 6, when there is something to
|
||
measure against:
|
||
|
||
1. **`geology_factor` 8 instead of 4**, a 894² geology grid at 16 m cells: four times cheaper, about 64 s,
|
||
and the upsample has to invent more of the fine drainage.
|
||
2. **Parallelise the stack update by basin.** Disjoint basins are independent; only the walk within one is
|
||
sequential. On this continent the trunk basins are large and few, so the gain is real but bounded.
|
||
3. **Spend the time.** Seven minutes instead of five, with `--size` carrying the iteration loop anyway.
|
||
|
||
**PNG writing is not free at this size** either: eight maps of 51 M samples is over 100 MB through zlib, and
|
||
`heightmap_io.py` compresses at level 6 today. The generated maps are rebuilt from a seed, never archived, so
|
||
the Go writer uses level 1 for the 8-bit derivative maps and keeps a higher level only for the height, which
|
||
the editor imports once.
|
||
|
||
**Relief is resolution-dependent, and by a lot.** The same seed and the same uplift field give 1020 m of land
|
||
relief at 512², and **2605 m at 1786²** — well past D-48's 1536 m ceiling. Finer grids resolve smaller
|
||
drainage areas near the divides, and `S = (U/K)^(1/n)·A^(-m/n)` makes small `A` steep, so the headwaters keep
|
||
getting taller as the grid gets finer. The practical consequence is a trap: **`U/K` tuned at `--size 512`
|
||
will overshoot at full resolution.** The iteration loop is for judging the *shape*; the elevation budget has
|
||
to be confirmed at the real geology grid, and the clip warning is what confirms it.
|
||
|
||
`--stage` and `--size` exist so nobody waits for a full run to judge one pass; the iteration loop is
|
||
`--stage fluvial --size 1024` against the slope–area plot, and the full run is what you do when it looks right.
|
||
|
||
## Settled
|
||
|
||
Everything the reconciliation left open was answered on 2026-09-17. Recorded here so the reasoning stays with
|
||
the document; the decisions themselves are D-47 and D-48 in [`Decisions.md`](Decisions.md).
|
||
|
||
| Was | Settled |
|
||
| --- | --- |
|
||
| Continent and sea, or an inland region? | **The continent stays.** Sea level is the base level on every ocean cell; the spec's §9 boundary question closes with it |
|
||
| 1536 m peaks or 2800 m? | **−512…1536 m, Z scale 400.** The mountains get lower and the valleys get better |
|
||
| 7141, 5101 or 4081? | **7141 at 200 cm**, 2.0 m cells, 784 components |
|
||
| Go on the machine? | **Go 1.25.0, 16 cores, already installed.** No toolchain cost |
|
||
| How long may a run take? | **About five minutes**, as today. `steps` is the knob; `fill_every` turned out not to be one (see what was built) |
|
||
|
||
Nothing is open. What remains is measurement, and the first thing that could reopen any of this is the
|
||
slope–area plot at build-order step 4 coming out curved.
|
||
|
||
---
|
||
|
||
## What was built, and where it differs
|
||
|
||
**2026-09-17, last. The mask is thresholded, not multiplied (D-52), and a broken statistic is retired.** The
|
||
coastal pass reported a mean sea cliff of two metres, and the conclusion drawn from it — that the continent
|
||
mask, by multiplying the uplift rate, made every coastline the lowest-uplift ground on the map — was half
|
||
right and rested on a measurement that could not have said anything else.
|
||
|
||
**The statistic first, because it is the more useful lesson.** "Mean cliff" measured the drop from a cell to
|
||
its seaward neighbour. That is a *gradient*: at the angle of repose one cell of a 10 m grid is 7 m, so the
|
||
number was bounded above by 7 however tall the coast was, and it read 2 m on a plain coast and 3 m on a
|
||
cliffed one because it could not distinguish them. It is now backshore height — the land's elevation between
|
||
one and two surf reaches inland, median and P90 — and on that metric the coast always had cliffs: P90 88 m on
|
||
seed 7, 108 m on seed 9342, 120 m on seed 67914, against a median of 3 to 9 m that correctly says the ordinary
|
||
coast is a plain. **A cliff is how far you fall, not how steep the first cell is.**
|
||
|
||
**The change is still right, for a narrower reason.** `rate = r * l` tapered uplift to zero across the shore,
|
||
and steady state is `S = U/(K*A^m)`, so ground with no uplift grades to no slope. What that flattened was the
|
||
hundred-metre strip the surf works in, not the backshore — so the cliff began a hundred metres inland instead
|
||
of at the water. The mask now answers only "is this cell sea", which is the yes-or-no the solve needs for its
|
||
base level, and a range that runs out to the water rises at range rates right up to it. Measured with
|
||
everything else held: surf cut 23.32 → 38.87 Mm³ and planed area 3.0 → 3.9 km² on seed 7, 22.42 → 30.35 Mm³
|
||
and 2.4 → 2.9 km² on seed 9342, with the detail crop showing high ground reaching the waterline where a
|
||
uniform low fringe stood in front of it before.
|
||
|
||
**What the margin was quietly relying on.** `continentMask` keeps land off the map border because a border
|
||
cell is an outlet — it takes no uplift and is never eroded, so land that reaches it freezes while the interior
|
||
erodes out beneath it. The margin tapers the mask, and while the rate was multiplied by the mask it was
|
||
tapering the uplift too, as a side effect nothing named. `TestBorderIsAlwaysOcean` now asserts the invariant
|
||
directly on three seeds and it holds: every border cell is ocean, the nearest land is five cells in, and that
|
||
land drains to ocean at sea level, so nothing is frozen. The test also logs what the margin costs, which had
|
||
never been measured: **14 to 15 % of the waterline lies inside the margin band**, cut along a contour of
|
||
distance-to-edge, which is a straight line parallel to that edge. Pre-existing, cosmetic, and made conspicuous
|
||
by this change because the land there now carries the full 2.0 mm/yr.
|
||
|
||
**And a warning about the acceptance test itself.** The slope–area fit moved −0.480 → −0.312 on seed 7 and
|
||
−0.698 → −0.720 on seed 9342 — opposite directions, and both inside a seed-to-seed spread on identical code
|
||
(−0.480, −0.698, −0.575 at R² 0.317, 0.704, 0.944) that is several times the size of the effect. It is the
|
||
number this document calls the proof that closes the work, and at five or six bins on a 1400 grid it cannot
|
||
carry that on a single run. Pair it — same seed, before and after — and read at least two seeds.
|
||
|
||
**2026-09-17, later still. The coast: a pass rather than a line.** D-48 kept the continent because sea level
|
||
is a better-posed base level for the solve than one outlet edge, and that is all it was: the mask said which
|
||
cells were ocean, the solve held them at sea level, and afterwards the sea floor dropped to a flat plane at
|
||
−180 m in a single step. A third of the map and a third of the elevation range was one flat surface; the land
|
||
met the water at whatever angle the last erosion step happened to leave; no process in the generator knew the
|
||
shoreline was there. `internal/coast` is the pass that does, and it runs *after* the solve because two of its
|
||
three parts need the finished terrain.
|
||
|
||
**The coordinate is a signed distance, not a line.** Every coastal process is written as "how far is this cell
|
||
from the waterline, and which stretch of shore does it belong to", so the pass opens with an exact Euclidean
|
||
distance transform carrying a feature index — Felzenszwalb and Huttenlocher's two 1-D passes, O(n) whatever
|
||
the radius. Exact rather than a chamfer: there is nothing to buy by approximating an O(n) algorithm, and a
|
||
chamfer's 2 % anisotropy would show as a shelf wider along the grid axes than across them. Everything after it
|
||
is a lookup.
|
||
|
||
**The shelf is derived, not set.** A margin is a gentle shelf out to a break, then a much steeper continental
|
||
slope to the abyssal floor. Its width is read off the relief standing behind each stretch of shore, so a low
|
||
coastal plain gets a wide shelf and a range that comes down to the water gets a narrow one, out of the same two
|
||
manifest numbers and without either having been asked for. `sea_floor_m` keeps its meaning; what changed is that
|
||
the depth between its two ends is now a function of distance offshore.
|
||
|
||
**The cliff is a consequence.** Within a reach of the waterline the land is planed towards a shore platform, and
|
||
the reach is set by how open the water is. Nothing draws a cliff: the cliff is the step where the reach ends, so
|
||
its height is whatever the land behind it stands at, which is the right way round. The cut rolls off only over
|
||
the last quarter of the reach — rolling it off across the whole reach gives a ramp, which is what a coast looks
|
||
like when it has been smoothed rather than eroded.
|
||
|
||
**The sediment is accounted for.** What the surf cuts is counted, carried a drift length along the shore and
|
||
laid in sheltered water shallower than a few tens of metres; river mouths deliver their own load in proportion
|
||
to what they drain, which is what makes a delta. The summary prints the volume cut, delivered, laid and left
|
||
unplaced, because the sediment budget is the one part of this that is not derived from something already
|
||
measured.
|
||
|
||
**And the invariant that is now enforced in one place.** `uplift.Result.Bathymetry` is gone. Ocean cells sit at
|
||
sea level for the whole solve and the coast pass owns the sea floor outright, which is the same rule as before
|
||
— a coastal cell drains into an ocean cell, and an ocean cell already at −180 m makes the solver cut the river
|
||
down to −180 m — but with one owner instead of two.
|
||
|
||
*Four things that were wrong first, each worth keeping.*
|
||
|
||
1. **Exposure by percentile.** Stretching the map's own 5th-to-95th percentile onto 0..1 is robust and collapses
|
||
to nonsense on a coast that does not vary: a straight one has no spread, so the whole continent came out at
|
||
one end of the scale. It is also a global statistic, which rule 1 of the tiling plan rules out — two tiles
|
||
would stretch by different anchors and their shared bay would be two different colours. The anchors are now
|
||
fixed and physical.
|
||
2. **Fetch in every direction.** That counts the land *behind* the shore as shelter, and every coast has land
|
||
behind it, so a straight open coast — where seven rays in sixteen stop after one cell — scored as more
|
||
sheltered than the back of a bay half a kilometre wide. Restricting to the seaward half-space, cosine-weighted
|
||
from the shore normal, is the standard effective fetch and gets the sign right.
|
||
3. **`cut_fraction` as a fraction of the height above the platform.** Fifteen per cent of a 120 m headland is
|
||
18 m, which is not a rough platform, it is an uncut headland. The residual is capped at a few metres.
|
||
4. **The deposition kernel, twice.** `dep = blur(supply) * want / blur(want)` looks like a normalised convolution
|
||
and is not one: the blur spreads supply onto land, deep water and exposed headlands, all of which want
|
||
nothing and are skipped, and 68 % of the budget was silently dropped there. The conserving order is to divide
|
||
the supply by the blurred want *first*, then blur, then multiply by the want — which sums to exactly the
|
||
supply. And one kernel cannot do both jobs: the sediment needs zero padding to keep the kernel symmetric,
|
||
which is what the balance rests on, while a carried *value* like the shelf width needs edge clamping, and
|
||
smoothing the width with the mass-preserving kernel shrank every shelf near the border to nothing. There are
|
||
now two, sharing their arithmetic so they cannot drift apart.
|
||
|
||
**What the pass measured about the continent, which is the part that mattered most.** With the coast built, the
|
||
fetch reported that the median stretch of shoreline was *fully open*: there were no bays. The cause is the
|
||
continent outline itself — five octaves over a 14 km map puts its finest feature at about 450 m, which is a
|
||
smooth blob, and a coastline is fractal, which is the whole content of the Richardson paradox. Sweeping the
|
||
outline's octave gain on seed 7 at 1400, everything else held:
|
||
|
||
| outline gain | 0.50 | 0.58 | **0.62** | 0.66 |
|
||
| --- | --- | --- | --- | --- |
|
||
| shoreline | 64 km | 81 km | **96 km** | 114 km |
|
||
| median shore exposure | 1.00 | 0.98 | **0.84** | 0.51 |
|
||
|
||
and the octave count, at gain 0.50, gave 59, 63, 64, 65, 66 km at 5, 7, 8, 9, 10 — flat past 9. D-51 takes 8
|
||
octaves at gain 0.62: islands, inlets and headlands that shelter each other, without the outline breaking into
|
||
speckle. The whole coastal pass costs 83 ms at the manifest's geology grid, against 130 s for the solve.
|
||
|
||
**2026-09-17, later. The plains problem: the uplift field, the router's flat ground, and the hillslope law.**
|
||
The complaint was that the lowlands read as mountains that had been shrunk - same texture, same shading,
|
||
lower peaks. It was not an erosion-tuning problem and no amount of work downstream would have fixed it.
|
||
|
||
**The diagnosis, which is arithmetic.** Steady state is `S = U/(K*A^m)`. With `critical_area_m2` at 0 that law
|
||
is applied down to a single cell, so at every divide `A = cell^2`; at K 5e-5, m 0.5 and a ~10 m geology cell
|
||
that makes `S = U/4.8e-4`. An intraplate rate of 0.25 mm/yr is therefore a 28 degree hillslope and the 0.9
|
||
mm/yr swell is past the 35 degree repose angle - so the repose clamp, which is meant to be a mountain
|
||
process, was the surface of the entire continent. For n = 1 the uplift rate alone fixes the hillslope angle:
|
||
`U` sets how *high* the summits get, not how steep the ground is.
|
||
|
||
**The measurement that says so** is new, and is the first thing built: `stats.UpliftBuckets` splits the land
|
||
by the uplift rate that caused it - plain below 0.1 mm/yr, rolling to 0.5, mountain above - and reports each
|
||
bucket's median and P90 slope, local relief over a 500 m window, and the fraction pinned within 2 degrees of
|
||
talus. Uplift is the right axis because it is an *input*: a cell does not change bucket when the run does,
|
||
which elevation-banding cannot promise. Map-wide aggregates cannot answer "are the plains plains", which is
|
||
why this was invisible for so long.
|
||
|
||
| Run | plain, % of land | plain median | mountain median | mountain at talus | slope-area R2 |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| Before | 1 % | 6.5 deg (sea cliff) | 26.4 deg | 32 % | 0.688 at -1.59 |
|
||
| Uplift fixed (D-49) | 42 % | 0.8 deg | 31.1 deg | 44 % | 0.055 at -0.26 |
|
||
| plus router jitter (D-50) | 42 % | 0.8 deg | 31.1 deg | 44 % | 0.459 at -0.33 |
|
||
| plus nonlinear hillslope | 42 % | 0.8 deg | 28.5 deg | **33 %** | 0.225 at -0.38 |
|
||
|
||
The first row is the whole diagnosis in one line: 81 % of the land sat in the mountain uplift class and the
|
||
plain class held 1 %, all of it coastal cliff.
|
||
|
||
**Fixing the uplift field immediately exposed the next thing**, exactly as expected: once the plains were
|
||
genuinely flat, the only gradient across them was the priority-flood's epsilon and the router drew the
|
||
flood's traversal order as rivers. Hence D-50. The jitter costs nothing and recovered most of the slope-area
|
||
fit on its own.
|
||
|
||
**The hillslope law** is now `q = D*S/(1-(S/Sc)^2)` (`internal/fluvial/hillslope.go`), replacing linear
|
||
diffusion. It is linear diffusion as `S -> 0`, so the lowlands are untouched, and it is mass-conserving,
|
||
which the clamp is not. Three things are worth knowing about it:
|
||
|
||
- **It is stiff, and the stiffening is bounded.** `D_eff = D(1+u^2)/(1-u^2)^2` diverges at `u = 1`; at the
|
||
defaults `u = 0.9` alone wants seventy sub-steps a step. So `u` is capped at `slope_cap` and, if the
|
||
`max_hillslope_substeps` budget cannot buy even that, the cap is lowered further rather than the sub-step
|
||
count truncated. Truncating is the tempting branch and it is wrong: it leaves alpha above the stability
|
||
limit and grows a checkerboard over hundreds of steps, which by then looks like texture.
|
||
- **It therefore cannot replace the clamp.** A belt rising at millimetres a year asks for slopes no
|
||
bounded-flux transport law holds; that is a fact about the forcing, not the scheme. The clamp stays in the
|
||
loop.
|
||
- **What changed is the order.** Diffusion runs *after* the clamp, every step. The clamp cuts along eight D8
|
||
directions and leaves grid-aligned pyramid faces - the blocky facets visible in every earlier mountain
|
||
preview - and a symmetric five-point stencil rounds them off before the next step sees them. Clamping once
|
||
at the end instead was tried and measured: a thousand steps of growth arrive together, it cuts deeply, and
|
||
nothing runs afterwards to soften it. The facets came back.
|
||
|
||
**The maps.** A run now also writes `map_uplift`, `map_erodibility`, `map_slope`, `map_relief`, `map_flow`
|
||
and `map_basins` beside the preview (`internal/field/datamap.go`). `preview.png` says whether the landscape
|
||
looks right; these say *why*, and the uplift map would have shown this whole problem at a glance with no
|
||
arithmetic at all. `map_basins` is the direct test of whether the solve made a network rather than scratches.
|
||
|
||
### Three findings worth remembering
|
||
|
||
1. **A channelization threshold still fails, and now we know what it is waiting for.** Re-measured after the
|
||
uplift fix: `critical_area_m2` 1e4 sends the plains back to 7.0 degrees, pins 49 % of the rolling class
|
||
and 79 % of the mountains against the clamp, and collapses the slope-area fit to R2 0.001. The hillslope
|
||
it creates has to shed its uplift by diffusion and at D 0.02 it cannot, so the clamp takes the job. It is
|
||
not a tuning question; it needs a transport law strong enough to pair with, and it stays at 0 until there
|
||
is one.
|
||
2. **The nonlinear flux was written in height differences and fed a slope.** `u = dh/Sc` instead of
|
||
`dh/(Sc*dx)` makes `u` a factor of `dx` too large, which pins every face against the cap and turns the
|
||
whole law into linear diffusion with a constant multiplier. It produced *better-looking* terrain than the
|
||
correct version, because over-smoothing hides facets. The unit test caught it; the preview did not.
|
||
3. **Two of the four new tests passed while measuring nothing.** One read the fixed border cells back and
|
||
called them the result; the other wrote a checkerboard across the fixed border, which then re-injected it
|
||
into the interior for ever, so the scheme was blamed for a boundary condition. A test on a grid whose
|
||
edge is an outlet has to say which cells it is actually asking about.
|
||
|
||
### Still open
|
||
|
||
- **The hypsometric integral is still 0.10**, and the reason has changed: it is no longer a bimodal uplift
|
||
field, it is that 42 % of the land is now a near-sea-level plain. Whether that is wrong depends on whether
|
||
a broad low continent is what is wanted; it is a question for the continent block, not the solve.
|
||
- **Half the land is still in the mountain uplift class** (51 % on seed 7, 44 % on seed 9342), because
|
||
`rangeMask` ramps from the 40th to the 86th percentile and anything above about the 55th clears 0.5 mm/yr.
|
||
Real continents are nothing like half mountain. This is the next uplift-field question.
|
||
- **Nature does not like straight lines, and three sources remain.** The fault traces are single 8-point
|
||
parabolas with a hard cutoff at the tips, and their polygonal influence regions are plainly visible as
|
||
straight-edged facets in `map_uplift`. The range grain is `across*2.2` with one warp octave, so chains run
|
||
as straight parallel bands. And multiple-flow-direction accumulation would dissolve what is left of the
|
||
diagonal river grain that the jitter only reduced. None is built.
|
||
|
||
|
||
**2026-09-17. Build-order steps 3 and 4: the Go skeleton and the fluvial solver.** Nothing in the editor has
|
||
been touched, so steps 1 and 2 (the viewport, the edit layers) are still open and still first in the list
|
||
above; they need the editor closed and it was open. The numpy pipeline is untouched and still the thing that
|
||
builds `L_World`.
|
||
|
||
Built, in `Tools/Terrain/` (module `salty/terrain`, `Scripts/build-terrain.sh`):
|
||
|
||
| Package | What it is |
|
||
| --- | --- |
|
||
| `internal/field` | `Field`, the one array type: shape, cell size in metres, float32 data. Resampling on the vertex convention, Catmull-Rom integer upsample, five-point blur, slope, curvature, greyscale PNG in and out through `image/png`, hillshaded thumbnails. `field.Rows` is the only place goroutines are created |
|
||
| `internal/manifest` | `World.json` over Go-side defaults, the height contract mirroring `world_manifest.py`, and a validator that refuses a resolution the importer would turn into thousands of components |
|
||
| `internal/noise` | Value noise, fBm on warped coordinates, Worley crest lines, per-pass PCG sources |
|
||
| `internal/uplift` | The continent, the uplift *rate* field and the small initial relief |
|
||
| `internal/fluvial` | The solver: priority-flood, D8 receivers, the stack, drainage accumulation, the implicit stream-power update, sub-stepped hillslope diffusion |
|
||
| `internal/stats` | Slope–area, hypsometry, slope histogram, drainage density, and the verdict line |
|
||
| `internal/check` | The two integration tests |
|
||
|
||
**The proof.** `TestSteadyStateMatchesStreamPower` puts uniform uplift on a uniform grid and checks the
|
||
analytic answer of `dh/dt = U − K·A^m·S^n`, which is that `K·A^m·S^n / U` is 1 at every channel cell. It
|
||
measures **1.0000** over 822 channel cells. On the real continent the fitted slope–area exponent is **−0.498
|
||
against an expected −0.500 at R² 0.996**, and drainage density is 1.33 /km, inside the real-world 1–10 band.
|
||
That is what build-order step 4 asked for and it is met.
|
||
|
||
`TestDeterministicAcrossGOMAXPROCS` hashes the pipeline output at GOMAXPROCS 1, 2, 4, 8 and 16 and requires
|
||
one hash, which is the assertion this document makes about Go and cross-cutting rule 12. It passes.
|
||
|
||
### Where it differs from the spec above
|
||
|
||
- **`fill_every` is 1, not 50**, and the table in the fluvial section is why. The 50 was written to protect
|
||
the time budget and it silently destroyed the solve.
|
||
- **1000 steps, not 5000.** Measured, not assumed; see the time note. This is most of the budget back.
|
||
- **The priority-flood uses a monotone bucket queue, not a binary heap.** The flood pops in non-decreasing
|
||
elevation and never pushes below the current front, which is exactly the condition that makes a bucket
|
||
queue valid, and it removed the `log n` from two thirds of the runtime for a measured 1.6× on the whole
|
||
solve at identical output (−0.498 against −0.500 before).
|
||
- **Slope–area is measured two ways and the normalised one is the verdict.** Steady-state stream power gives
|
||
the same gradient but a different *intercept* per uplift rate. This map's uplift spans 0.2 to 5 mm/yr, so
|
||
regressing every channel together stacks twenty-five-fold-separated parallel lines and fits nonsense: the
|
||
raw fit reads −0.70 at R² 0.91 on a landscape whose true exponent is −0.50. Slope is normalised by
|
||
`(U/K)^(1/n)` first. The raw figure is still reported beside it.
|
||
- **`Continent` is a pipeline block the spec above does not list**, because D-48 kept the coast and the coast
|
||
needs parameters.
|
||
- **`uplift.Result` carries `Bathymetry` separately from `Height`.** The sea floor is held at sea level for
|
||
the duration of the solve and put back afterwards. Left in, a coastal cell drains into an ocean cell at
|
||
−180 m and the solver obligingly cuts the river down to −180 m; the first run with a coast eroded the land
|
||
to 174 m *below* sea level. A river's base level is sea level; the bathymetry is scenery.
|
||
|
||
### Three bugs worth remembering
|
||
|
||
All three produced plausible-looking terrain, which is the point: none of them would have been caught by
|
||
looking at it.
|
||
|
||
1. **Slope–area measured with the topographic gradient instead of the channel gradient.** For a cell on a
|
||
valley floor the central difference is dominated by the valley walls, not by the direction the water
|
||
runs. The exponent read −0.78 where the truth was −0.50. `S` in the stream-power law is
|
||
`(h − h_receiver)/L` and nothing else.
|
||
2. **Cells at a local minimum were skipped entirely, uplift included.** They are the cells differential
|
||
uplift is actively pushing up, so freezing them removes exactly the basins that should be forming. Fixed
|
||
by letting a root still rise.
|
||
3. **The fix for (2) then uplifted the outlets.** The map border is an outlet but is not ocean, so on a map
|
||
with no coast every border cell had `Receiver == self` and `Base == false` and base level rose 2 m a step
|
||
with the whole landscape chasing it. The steady-state ratio read 0.03 instead of 1.0. The union of "ocean"
|
||
and "border" is now a single `fixed` mask. The real runs were never wrong, because there the border *is*
|
||
ocean — only the test had no coast, which is why it caught it.
|
||
|
||
### Still open
|
||
|
||
- Passes 1, 3 and 4 (plates, faults, lithology) are not built; `uplift` currently derives its rate field from
|
||
the percentile-thresholded range band alone, which is build-order step 5.
|
||
- **The hypsometric integral is 0.11, against 0.4–0.6 for a mature landscape**, and it has been at 0.11 in
|
||
every run. The map is a wide, near-flat coastal plain with mountains on a fraction of it, which is what an
|
||
intraplate rate of 0.2 mm/yr against a convergent 5 mm/yr produces: a 25-fold ratio is bimodal by
|
||
construction. This is a tuning question for the uplift field, so it belongs to step 5, but it is the next
|
||
thing that will look wrong.
|
||
- Everything from the upsample onward (passes 8 to 14) is unbuilt, so there is no full-resolution output yet
|
||
and the canvas has not moved: the manifest is still 4081 at 350 cm.
|
||
- **The fluvial pass costs 256 s at the real geology grid, against 120 s budgeted**, and relief there reaches
|
||
2605 m against D-48's 1536 m ceiling. Neither is a defect in the solver — the first is arithmetic and the
|
||
second is `U/K` untuned — but both are decisions waiting at steps 5 and 6. See the time budget.
|
||
- The exponent at 1786² is −0.640 after 1000 steps, still converging toward −0.5 while R² holds at 0.990. A
|
||
straight plot at 0.64 is a perfectly ordinary real landscape (measured concavities run 0.35 to 0.6), so
|
||
this is a question of how long to run rather than a fault, and it trades directly against the item above.
|