This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+585 -143
View File
@@ -36,6 +36,30 @@ Every `bs.*`-style knob in the manifest has a `--flag` override so an experiment
and for the coast `--no-coast`, `--outline-octaves`, `--outline-gain`, `--shelf-km`, `--surf-reach`,
`--cut-fraction`, `--deposit-reach`, `--drift`, `--river-sediment`.
### The planet, which is the other half of the tool now
A painted template is a different command and a different manifest. `RawContent/World/Planet.json` points at
an image and a legend; `RawContent/World/Templates/README.md` is how to paint one.
```bash
# four seconds: read the painting, cut the planet into regions, solve nothing.
# map_class.png and map_regions.png are the two pictures that decide whether a bake is worth starting.
Tools/Terrain/bin/terrain.exe plan
# about two hours at 100 km round. Run it detached, never under a tool timeout.
Tools/Terrain/bin/terrain.exe bake --out RawContent/World/Bake --jobs 4
# one landmass, short, for tuning the legend's numbers
Tools/Terrain/bin/terrain.exe bake --only 11 --steps 200 --out /tmp/try
# the detail passes over a bake, a batch of tiles at a time. About twelve seconds a 5 km tile.
Tools/Terrain/bin/terrain.exe tiles --bake RawContent/World/Bake --only 11,7,13,8
```
**Every number in this section is pre-D-53 and has not been re-measured.** The router jitter moved from a
hash of the grid index to a hash of the world position, which changes the square canvas's output everywhere;
the summary block below was measured before it. Re-baseline before comparing anything against it.
### What a run writes
| File | What it is for |
@@ -43,18 +67,31 @@ and for the coast `--no-coast`, `--outline-octaves`, `--outline-gain`, `--shelf-
| `preview.png` | Hypsometric tint, hillshade, rivers. "Does this look like a landscape" |
| `preview_detail.png` | A crop at 2× vertical exaggeration. The whole continent at 1600 px cannot show whether lowlands read as hill country or as small mountains; this can. Move it with `--crop-x/-y/-size`. It cannot be rendered finer than the grid: a crop of 0.14 at `--size 1400` is 196 cells, so that is the image, whatever `Size` asks for |
| `geology_height.png` | The 16-bit heightmap itself, encoded to the manifest's elevation range |
| `map_uplift.png` | **The most useful diagnostic.** Rock uplift in mm/yr — the field everything else is a consequence of. It and `map_slope` should be recognisably the same picture; when they are not, something downstream is overriding the tectonics |
| `map_uplift.png` | **The most useful diagnostic.** Fault traces are stroked over it in cyan - the line rather than the rate it contributes, because even after D-62 a fault is a few kilometres wide and the map is a hundred kilometres across | Rock uplift in mm/yr — the field everything else is a consequence of. It and `map_slope` should be recognisably the same picture; when they are not, something downstream is overriding the tectonics |
| `map_slope.png` | Degrees, 0–45 |
| `map_relief.png` | Local relief over 500 m. Separates a 5 m hummock from a 500 m mountainside — both stand at 30° and the slope map cannot tell them apart |
| `map_erodibility.png` | The lithology multiplier on K. Where texture inside a range comes from |
| `map_erodibility.png` | The lithology multiplier on K. Where texture inside a range comes from - on a painted planet that is each class's `k_mult` times the planet's rock field, which the seed re-rolls (D-58) |
| `map_exposure.png` | How open the water is in front of each stretch of shore, 0 sheltered to 1 open. Drawn only within a kilometre of the waterline, because past that it is a map of the continent's medial axis. The one to read when a beach turns up on a headland |
| `map_coast.png` | Everything the coastal pass moved, in metres: cool where the surf cut, warm where the sediment landed. The sea floor is excluded, or its few hundred metres would swamp the few the processes move |
| `map_flow.png`, `geology_flow.png` | Log drainage area: the rivers |
| `map_basins.png` | One colour per drainage basin, hashed from the basin root. The direct test of whether the solve made a *network* rather than scratches: basins must tile the land, sizes must span orders of magnitude, and divides must sit on the ridge crests. Confetti means the router is re-deciding where water goes every few cells |
| `map_overlay.png` | The annotation layer over a dimmed class map, when the planet has one. A mark means nothing on its own and everything against the coastline it was drawn along |
| `overlay.json` | Every mark's area and piece count, and every feature in **world metres**: a centre, area, radius and extent per painted blob, an ordered polyline per path. What the engine places things from; nothing in the generator reads it back |
| `meta.json` | The full manifest as resolved, plus every statistic |
### How a run is judged
**The class table prints two angles now and the second one is the one to read (D-57).** `divide` is
`U/(K·A^m)` at a single cell, which is exact and is the *steepest* ground a rate can make; `typical` is the
median over the class, measured at a third of it in tangent and flat across a factor of twenty in rate. Almost
none of a map is divide. The old single column is why a legend could be set two or three times too hot and
still look reasonable on paper.
**A planet bake prints this block now (D-59).** Until then it printed its elevation range and nothing else,
which is why two questions this session - "are the lowlands hilly" and "do faults leave scarps" - had to be
answered by hand in Python off a PNG. A partial run (`bake --only`) marks itself PARTIAL: its extent is the
whole cylinder and its ground statistics are only the landmasses that were solved.
The printed summary is the verdict, and the block that matters most is the per-uplift-class breakdown —
map-wide medians cannot answer "are the plains plains", which is precisely how the last problem stayed
invisible. Current state, seed 7 at 1400²:
@@ -93,134 +130,237 @@ after — and across at least two seeds, or raise the bin count before leaning o
## 2. What was just built, in one paragraph
The coast, which until now was a line in a mask: the sea floor dropped to a flat plane at −180 m in one step
and no process knew the shoreline was there. `internal/coast` adds three that do, each derived rather than
drawn — a continental shelf whose width is read off the relief standing behind each stretch of shore, a surf
that planes the land to a shore platform within a reach set by how open the water is (the cliff is the step
where the reach ends), and a sediment budget that carries what the surf cut along the shore and lays it in
sheltered shallow water, with river mouths delivering their own load. It runs after the fluvial solve, on the
terrain the solve produced, and it owns the sea floor outright: `uplift.Result.Bathymetry` is gone and ocean
cells stay at sea level for the whole solve. Measuring it then said something about the *continent* rather than
about the coast — the fetch reported the median stretch of shoreline as fully open, because five octaves of
outline noise over a 14 km map put the finest coastal feature at 450 m and a coastline is fractal. D-51 takes
the outline to 8 octaves at gain 0.62, which is 96 km of shoreline against 64.
Painted planets (D-53). The source stops being a seed: an author paints a flat cylindrical world map, a JSON
legend beside it says what each colour means in uplift and erodibility, and the simulation makes the terrain.
X wraps and Y does not, so a landmass may straddle the seam and comes out whole. The geology is solved **one
landmass at a time**, which is exact rather than approximate because ocean cells are fixed at sea level for
the whole run and nothing in the solve can move them, so no flow path crosses open water; the coastal pass and
everything else run once over the whole cylinder, because the coast costs 26 ns a cell against 80 ns a cell
*per step* for the solve and cutting it up would truncate the fetch across every strait and split the sediment
budget. `terrain plan` reads the painting and cuts the planet into regions in four seconds, without eroding
anything; `terrain bake` solves it. The router jitter moved to a hash of the world position at the same time,
which is rule 1 of the tiling plan and re-baselines §1.
Full detail, including four things that were wrong first, is in `Terrain.md` under **What was built, and where
it differs**.
The detail passes came with it: 8 to 12 and 14 are built and tiled, so there is a full-resolution output for
the first time — 5 km tiles of 2500 samples at 2 m, twelve seconds each, with a margin **measured** at three
droplet lifetimes rather than reasoned at `rounds × lifetime`. The droplets had to become a pure function of
world position for that to close, which is rule 1 arriving where it was always headed.
Full detail, including the eight things the shape of the work revealed, is in `Terrain.md` under **What was
built, and where it differs**.
## 3. Where this is going
**Composition is parked.** The mountain fraction, the range grain and the fault traces are all real and all
still listed below, but they are *tuning* and the map is good enough to work against. Do not spend the next
session on them.
still listed below, but they are *tuning*, and on a painted world two of the three are the author's job now.
Do not spend the next session on them. One item that looked like composition was not and is closed: a class
was one rate and therefore one landscape, which is §4.A0 and D-55.
The goal is: **get generation working end to end, then make the world author-driven and scalable.** Three
things, in order.
The goal is: **get generation working end to end at player scale.** Three things, in order.
### 3.1 Finish the pipeline (build-order step 6)
### 3.1 Finish the planet's own passes
Passes 8–14 are unbuilt — upsample, detail noise, strata, particle, fine thermal, spawn pad, derive — so the
generator stops at the geology grid and `L_World` is still built by the numpy pipeline it was meant to
replace. Until this lands there is no full-resolution output and nothing to import, at any scale. It is also
the only work that changes how the terrain reads to a player standing on it: see §4.C.
Two things the planet needed that the square canvas did not, and neither was optional for a bake to be judged.
Both are closed:
### 3.2 Painted maps as the source
- ~~**The coastal pass has to wrap.**~~ **Closed (D-60).** Four primitives wrap now, the abyss is a field so
the derived slope meets the painted ocean depth rather than stepping to it, and `Geometry.Ref` holds a
waterline slot so `supply` is a few hundred thousand entries instead of 608 MB. `Measure` holds one distance
transform at a time. Measured: 7.9 s over the whole 76 M cell cylinder, and the seam step in the sea floor
went from a mean of 9.1 m to 0.32 m, which is what an ordinary interior column is. The pass is now the
memory peak of a bake, about 8.3 GB against the solve's 3.6.
- ~~**`internal/stats` does not survive 28 M land cells.**~~ **Closed (D-59).** Fixed-bin histograms
replaced every sort and `field.LocalRelief` replaced the O(radius²) window, but the change that mattered was
not either of those: a histogram **adds**, so a planet's statistics are now *pooled* from its regions rather
than computed on a grid that never exists. Each region accumulates while its own grid is alive and they
merge in region order; the extent is measured once on the composited cylinder, because regions carry
overlapping ocean margins and pooling their cell counts would double-count the water between them.
Measured: 1.09 s for a 9 M cell region, about 120 ns a cell, so a whole planet is a few seconds at the end
of a two-hour bake. A bake prints the full block now, and a *partial* one says so rather than letting the
whole world's extent be compared with three islands' worth of ground.
An author paints a world map; the simulation turns it into terrain. The manifest already anticipates a file
source — `"source": {"kind": "file", "path": ...}` is documented in `RawContent/World/README.md` and
`field.ReadHeightmap` exists — but **nothing in the Go tool reads it**: `Source.Kind` appears only in a
`Describe()` string, and the run always builds noise. So this is new work, not a re-wiring.
And a third, smaller, which is both a cost and a correctness wart:
**Paint the uplift, not the height.** This is the one design decision that matters and it follows directly
from D-47 and from everything measured this session. The architecture is *noise becomes tectonics, and the
solve makes the terrain*; a painted heightmap would be handed to a solver that promptly erodes it into
something else, throwing away the drainage network that is the entire reason the generator was rewritten.
Painting uplift instead means an author draws intent — "a range here, lowlands there, coast like this" — and
gets terrain with real rivers, real divides and real valley hierarchy honouring it.
- **`DiffuseNonlinear` bounds its sub-step count with the steepest slope on the whole grid**
(`hillslope.go:62`). That is where the five-fold cost of mountains comes from and most of it is honest work
— the sub-steps buy stability, and truncating them checkerboards the surface a few hundred steps later. But
the bound is a *global statistic of the grid it is given*, so the steepest cell anywhere in a region sets
the diffusion for every plain in it, and two different decompositions of the same world would differ
slightly. It is the one place where the per-landmass split leaks into the answer, which is why the margin
and the minimum landmass size are in the manifest and in `meta.json`. A per-band bound would close it and
would be cheaper; whether it changes anything visible has not been measured.
Suggested channels, all optional, all falling back to the procedural field where absent:
### 3.1a The painting has a tool now
| Painted layer | Feeds | Notes |
| --- | --- | --- |
| Land / sea mask | `uplift.Result.Land`, `Base` | The outline. Almost certainly the first thing anyone wants to draw |
| Uplift rate | `Result.Rate` | The load-bearing one. Greyscale mapped to a mm/yr range from the manifest |
| Erodibility | `Result.K` | Rock types. Cheap, and it is where texture inside a range comes from |
| Sea floor | `Result.Bathymetry` | Cosmetic; it is put back after the solve and never erodes |
| Fault lines | `buildFaults` | Later. A line layer, not a raster |
`terrain studio` (D-56) is the loop for everything in this section that is *authoring* rather than physics:
brushes that carry the legend's numbers, a seam-aware canvas, and `plan` as a button. It has two sheets now
(D-57): `classes` is the geology and `overlay` is the annotation layer, whose brushes are its marks and whose
`coast_jitter` marks are the only thing on it any pass reads. The two measurements
below - the 9.4 % seam disagreement and the JPEG halo - are both things it exists to stop happening again, and
the first is a thing it can fix by painting.
**Rivers cannot be painted directly**, and it is worth knowing why before someone tries: a river is an
*output* of the drainage solve. What does work is biasing — raise `K` along a painted line so the water finds
the soft rock, or drop the uplift slightly along it, or seed a shallow valley into the initial relief. The
solve then chooses to put a river there for its own reasons and the result is still a coherent network. A
painted line forced into the height directly would be cut apart by the first thousand steps.
**Plan and Bake apply the panel before they run.** They always pushed both paintings first and never the
numbers beside them, and since both read the legend and the manifest off *disk*, an edit still sitting in the
rail was an edit the prepare never saw. A re-rolled seed was the case that showed it: the plan key did not
change, the cached prepare came back, and the uplift and erodibility maps were identical - which looks exactly
like a generator that ignores its seed. Both buttons now flush the planet block, the class legend and the
overlay legend, in that order, and the report says what it applied; a `unsaved:` note under the Plan bar says
what is pending before you press anything. Bake also pushes the paintings, which it never did: it solves the
server's copy, so a stroke made since the last plan was two hours of answering the wrong question.
**The blend rule, which keeps painted maps from looking painted.** A painted map is coarse — 2048 px across a
100 km world is 50 m a pixel, five geology cells. Upsample it smoothly and let procedural noise supply
everything below its pixel size: **the painted map owns wavelengths above its resolution, noise owns those
below.** Without that rule a painted world is visibly blocky at the paint resolution; with it, an author
controls structure and the generator still supplies texture.
**The region map is numbered.** Region hues came from a hash of the index, and independent hues collide - the
closest pair of the first twenty was 8.5 apart in RGB, which nobody can distinguish on a map whose whole job is
"is that one landmass or two". They walk by the golden angle now, with saturation and value on a 3 and 2 cycle:
44.0 at twenty regions, 41.9 at twenty-six, 37.7 at forty. Colour alone still cannot carry forty regions, so
`planet.RegionLabels` returns the centroid of each region's land and the studio writes the id over the map in
screen-space text - crisp at any zoom, and tiled across the seam like everything else. The mean across is
circular, because a landmass at `x = 0` and `x = W-1` has an arithmetic mean on the far side of the planet.
These are the ids `bake --only` takes.
### 3.3 Scale, and why tiling is an architecture question
**A finished bake lets go of the screen.** Its status and stamp outlive the run, so the first poll of every page
load re-opened the preview of a bake that had ended hours ago - over the painting, blocking the brush, and
reloading the page put it straight back. A load that finds nothing running now adopts the stamp instead of
drawing it; a bake that finishes while somebody watches still lands its final preview. Every map view also has a
visible way out now rather than only Escape, and the bake block has a Preview button to bring the last one back.
A world is big. Today's canvas is 14.28 km a side; the interesting sizes are 50–200 km. The numbers, measured
and extrapolated from the 256 s full geology run:
### 3.1b The first template does not wrap, and that is the input rather than the tool
| World side | Area | Geology cells at 8 m | Fluvial solve, 1000 steps | Grid memory |
Measured by `terrain plan` on `Map3.jpg`: the left and right edges, which are the same meridian, **disagree on
9.4 % of rows, 261 of them land against water**. The crater island crosses the seam perfectly — heights run
continuously from the last geology column into the first — but islets drawn touching `x = 0` have nothing to
meet them at `x = W-1`, so the world has a 400 m cliff down the seam wherever that happens. There is also a
two-pixel JPEG halo on the outermost columns which classifies as shelf, putting a 400 m ledge the height of
the map down the same line.
The tool is right and the painting is not, so nothing here is a defect to fix in code. What was added is the
measurement, because it is the one defect an author cannot see by looking at their own picture: the two edges
are as far apart on screen as they can be. The fix is to paint round the edge and export PNG.
### 3.2 The coastal detail, which is the last pass with nothing built
Passes 8 to 12 and 14 are built (see `Terrain.md`), so there is a full-resolution output: 5 km tiles of 2500
samples at 2 m, about twelve seconds each. What is missing at player scale is now only the *shore*, and it is
no longer blocked: `internal/coast` wraps (D-60), so there is a shelf, a shore platform and a beach for a
detail pass to refine. Section 4.E3 is still the shape of it - the surf reach is 110 m, which is
55 detail cells, enough for a real berm, a wave-cut notch and a scree apron below a cliff - and the tile bake
is where it goes.
Two smaller gaps in pass 14: the weightmaps are not derived (nothing imports them yet), and pass 13, the spawn
pad, is deliberately skipped because a planet has no single centre.
### 3.3 What is left of tiling
Both halves are built. The geology solve is decomposed per landmass and everything else runs whole (D-53);
the detail passes tile, with a margin **measured** rather than reasoned - three droplet lifetimes plus the
brush, which is 122 detail cells at the defaults, about five per cent of a 5 km tile on each side. Rule 1 is
done for the router, for the painted path and for every detail pass; the one place still on map-relative
coordinates is the square canvas's own `uplift.Build`, and that is deliberate, because there the noise *is*
the continent.
**How big can a world be, now.** The binding number is no longer the planet but its largest landmass, because
the solve is per landmass. Measured on the 100 km template: 18 regions, 49 M cells of 76 M, the largest 14 M
at under a gigabyte, and the detail another forty minutes for all 200 tiles, fully batchable. A 200 km world
with landmasses of the same *shape* is four times that; the case to watch is one landmass four times as wide,
because that single region is the peak.
**The wall time is set by the largest single region, and that region runs at about one core.** Measured on
the second bake of the 100 km template: seventeen regions finished in 11 586 s of solve with four in flight,
and the eighteenth - the 14 M cell central lowland - then ran alone for over 90 minutes at 1.0 to 1.1 cores.
That is not a defect, it is the shape of the solver: `Terrain.md` records that most of the runtime is the
stack walk and the priority-flood's cursor, and neither parallelises *within* one grid. Running regions
concurrently hides it while there are several left and hides nothing at the end.
Two consequences. The `--jobs` throughput number is not the wall time: a template whose land is one big
landmass gets almost no benefit from it. And **parallelising the stack update by basin** - which `Terrain.md`
already lists as option 2 for the time budget, and which is where the cores would actually go - has moved
from "a real gain, bounded" to the only thing that would shorten a bake of this shape. Disjoint basins are
independent; only the walk within one is sequential.
**And the cost per cell depends on the uplift rate, by a factor of eighteen.** Measured on the same bake at
1000 steps with four regions in flight:
| class | rate | cells | time | per million cells |
| --- | --- | --- | --- | --- |
| 14 km — today | 204 km² | 3.2 M | 4 min | ~150 MB |
| 50 km | 2 500 km² | 39 M | ~50 min | ~1.8 GB |
| 100 km | 10 000 km² | 156 M | ~3.5 h | ~7 GB |
| 200 km | 40 000 km² | 625 M | ~14 h | ~28 GB |
| lowland | 0.08 mm/yr | 14.0 M | 1014 s | 72 s |
| highland | 0.90 mm/yr | 4.0 M | 1394 s | 350 s |
| crater | 1.60 mm/yr | 1.4 M | 1829 s | 1278 s |
**The fluvial solve cannot be tiled.** Drainage area accumulates across the whole map and the priority-flood
needs global connectivity, so a river crossing a tile boundary needs its upstream catchment from the next
tile. Solving tiles independently gives wrong drainage areas and a discontinuity at every seam — and drainage
area is the term the whole model is built on. Halo exchange between tiles would work in principle and is a
large, iterative piece of work.
**The detail passes tile perfectly**, because every one of them is local: noise is pointwise, thermal
weathering propagates a cell at a time, and a droplet travels at most its lifetime in cells.
So the architecture already contains the answer, and it is the two-grid split that is already there:
> **Solve the geology whole, once, at a fixed physical cell size. Tile only the detail.**
That gives consistent relief for free, because the geology cell never changes — which matters more than it
sounds, and §4.D.3 explains why. It makes maximum world size a memory-and-patience question rather than a
correctness one: ~50 km is an hour, 100 km is an overnight bake, and beyond that the geology stage needs to
go out-of-core. Since the goal is explicitly a batched, offline bake, that seems an acceptable trade — but it
should be a decision made deliberately, with these numbers in front of whoever makes it.
**Two rules that make tiles seamless, and are much easier to adopt now than to retrofit:**
1. **Index every noise and every hash by absolute world coordinates, never by tile-local index.** Both the
fBm lattices in `internal/noise` and the D8 router's jitter (`internal/fluvial/jitter.go`, D-50) currently
key off grid index. Two tiles would then get different values for the same physical place and every seam
would show. This is a small change now and a pervasive one later.
2. **Every tile carries an overlap margin, discarded after the pass.** Size it by how far the pass can move
material: a few cells for thermal, the droplet lifetime (~40–64 cells) for particle, zero for pointwise
noise. Cheapest correct approach; no inter-tile communication needed.
---
It is not the stream power; it is the hillslope. `DiffuseNonlinear` sub-steps to stay stable, the count rises
with the steepest slope on the grid, and it saturates at `max_hillslope_substeps` — 24 by default. Steep
ground pays all 24 every step and a plain pays one. Three consequences: `terrain plan`'s estimate is
calibrated on the plains and is a **floor**; raising an `uplift_mm_yr` changes the bake time as well as the
terrain; and the wall time is set by the single slowest region, not the total, so one small steep landmass can
be the whole tail.
## 4. What looks wrong now
Ordered by how much it matters to the direction above, which is *not* the order of how visible it is on a
preview image.
### C. Detail — nothing exists at player scale · the blocker
### C. Detail — built, and what it left behind
Passes 8–14 of the pipeline table in `Terrain.md` are entirely unbuilt: upsample, detail noise, strata,
particle erosion, fine thermal, spawn pad, derive. The generator stops at the geology grid — 8 m cells at full
resolution — so at 2 m quads a player stands on a 4× upsample of a coarse grid with **no detail added at
all**. Ledges, scree, gullies, the strata shelves on a cut face: all of it lives in those passes, and every
one already exists as tuned numpy in `Scripts/Authoring/heightmap_erosion.py` waiting to be **ported, not
reinvented**. Carry its brakes across unchanged — the droplet slope gate, the per-step cut cap, the load cap,
the 3×3 cut brush and the own-cell deposit are each a lesson from the Worklog.
~~Nothing exists at player scale~~ — closed. Passes 8 to 12 and 14 are built and tiled; see `Terrain.md` for
what the port cost and the four things that were wrong on the way. What is left of this entry is three
narrower items, none of them a blocker:
Budget from `Terrain.md`: upsample and detail noise 15 s, particle 90 s, fine thermal 20 s. The fluvial pass
is already 256 s against 120 s budgeted, so the five-minute bar is at risk before these land — and §3.3 says
the bar is probably the wrong constraint for a batch bake anyway. Worth deciding rather than drifting.
- ~~**The shore is still a step.**~~ Closed (D-60): the coastal pass wraps, so a planet has a shelf, a
shore platform and a beach for the detail passes to refine. §4.E3 is now unblocked and is the next thing.
D-56's coast mask is a different thing and does not close this: it decides *where the waterline is*, at the
paint's own resolution, before anything is solved. `internal/coast` is what puts a shelf and a shore
platform under it, and that still has to wrap.
- **The weightmaps of pass 14 are not derived.** The rules are in the numpy and they are ported unchanged when
something imports them; flow, wear and deposit already come out per tile, which is what those rules read.
- **Nothing has been judged on the ground.** The tiles look right in a hillshade and the seam is measured, but
the question the whole pipeline exists to answer — does this read as ground to somebody standing on it — has
not been asked, because nothing imports a tile yet.
### A0b. What the lowlands are actually doing — measured, and mostly the author's numbers
Raised again as "the lowlands still by default become super hilly". Measured at last, on a 600² grid of 8 m
cells with the manifest's own constants at 1000 steps and a uniform rate:
| U mm/yr | divide | median | P90 | over 3° | max elevation |
| --- | --- | --- | --- | --- | --- | --- |
| 0.012 | 1.7° | 0.58° | 1.00° | 4 % | 32 m |
| 0.045 | 6.4° | 2.12° | 2.85° | 8 % | 38 m |
| 0.080 | 11.3° | 3.72° | 4.85° | 75 % | 54 m |
| 0.250 | 32.0° | 11.13° | 14.03° | 99 % | 143 m |
Three things follow and the first is the answer to the complaint.
**The massif floor already is a plain, and the real continent confirms it.** Region 12 baked whole — 45.9 ×
19.8 km, 9.0 M land cells, 1000 steps, 27 minutes — comes out **0..47 m** with a slope distribution of p50
0.61°, p90 1.22°, 4.4 % over three degrees and **nothing at all over eight**. The controlled run at a uniform
0.012 mm/yr gives 0.58° and 4 %, so the two agree. There is no missing process here and no fine dissection to
remove; D-55 did what it said.
**What made it look hilly was `preview.png`.** The hypsometric ramp's top is a *percentile of the world being
drawn*, so the whole ramp — green, tan, rock, snow — was stretched over this continent's 32 m, and its 40 m
hills came out with the white caps a 2800 m range would get. Redrawn against a fixed 400 m ceiling the same
heightmap is a flat green plain with four pale massifs on it. Closed: `palette.land_top_m` is an absolute
ceiling, and every run now prints which ceiling its preview was drawn against. The percentile stays the
default, because an absolute ramp over a world with no mountains is a green shape with nothing legible on it.
**What makes a painted lowland read as hill country is its massif share and the rate the massifs reach.** At
`fraction` 0.16 the ramp opens at the 76th percentile of the planet, so about a *quarter* of the class is off
the floor, and the class rate it climbs to — 0.08 mm/yr — is a 3.7° median, which is continuous rolling
ground. Both numbers are the author's. The lever for "more flat ground" is a smaller `fraction`; the lever
for "gentler hills where they are" is a lower `uplift_mm_yr`.
**And the number they were steering by was wrong by a factor of three**, which is D-57 and is closed: the
table printed the divide angle, which is the steepest place in a catchment, as if it were the landscape.
What is *not* closed, and is the real version of "lowlands should not consider mountainous erosion": every
cell of the world runs one process with one diffusivity, one critical slope and one channel threshold, and the
only per-class levers are `U` and `k_mult`. At a fixed cell those two set relief and steepness together
(§4.B0), so "flat but with real relief" is not expressible. The principled fix is the pairing in §4.D.3 — a
critical area with a hillslope diffusivity to match — and the thing that makes it newly plausible is that it
could be **per class**: §6 rejects it because D large enough to shed the uplift "smooths away every landform",
which is a fatal objection on a mountain and a *description of a plain*. Cost is the obstacle, not principle:
`DiffuseNonlinear` sub-steps on `D·dt/dx²`, so D 0.3 on a lowland region is 36 sub-steps against 3, and the
14 M cell region that already takes an hour and a half would take most of a day. Implicit diffusion, or a
per-band sub-step bound, is what would make it affordable. **Not started, and not to be started without
measuring the sub-step cost first.**
### D. Scale-independence — the one that becomes load-bearing
@@ -267,16 +407,20 @@ strip the surf works in, so the cliff began a hundred metres inland instead of a
worth 35 to 67 % more surf cut and a visibly steeper shore, and the principle stands — **where the land ends
does not decide how fast it is rising** — but it was a sharpening, not the transformation this entry predicted.
**E1b. The map margin draws one coastline in seven, and it draws it straight.** Measured on three seeds:
14.1 %, 15.0 % and 14.0 % of the waterline sits inside the 4 % margin band that `continentMask` imposes to keep
land off the map border. The margin tapers by distance-to-edge, and a contour of distance-to-edge is a line
parallel to that edge, so wherever the continent would have run past the boundary it is cut off square. This is
pre-existing and it is *not* the frozen-rim failure the margin exists to prevent — `TestBorderIsAlwaysOcean`
confirms every border cell is still ocean on all three seeds, so nothing is frozen. It is cosmetic, and D-52
made it conspicuous: land inside the band now takes the full 2.0 mm/yr instead of a tapered rate, so the
straight-cut coast can be a mountain range rather than a low plain, which is exactly what seed 67914's southern
coast is. Cheapest fix, and it belongs with the outline work rather than with the coastal pass: perturb the
margin distance with a low-amplitude noise field so the cut follows a crenellated line instead of a ruled one.
**E1b. The map margin draws one coastline in seven, and it draws it straight — on the square canvas only.**
Measured on three seeds: 14.1 %, 15.0 % and 14.0 % of the waterline sits inside the 4 % margin band that
`continentMask` imposes to keep land off the map border. The margin tapers by distance-to-edge, and a contour
of distance-to-edge is a line parallel to that edge, so wherever the continent would have run past the
boundary it is cut off square. This is pre-existing and it is *not* the frozen-rim failure the margin exists
to prevent — `TestBorderIsAlwaysOcean` confirms every border cell is still ocean on all three seeds, so
nothing is frozen. It is cosmetic, and D-52 made it conspicuous: land inside the band now takes the full
2.0 mm/yr instead of a tapered rate, so the straight-cut coast can be a mountain range rather than a low
plain, which is exactly what seed 67914's southern coast is.
**On a painted planet this is closed rather than deferred (D-53):** there is no `continentMask` and no map
border, because the outline is the paint and a region's edges are open ocean by construction. The cheapest fix
for the square canvas is still the same one — perturb the margin distance with a low-amplitude noise field so
the cut follows a crenellated line instead of a ruled one — and it now has one fewer consumer.
**E2. The shelter contrast is real but thin.** Exposure comes out 0.00 / 0.90 / 1.00 at p10 / p50 / p90, so the
distribution is one long tail: a handful of genuine embayments and a lot of open coast. A floor of 0.15 on
@@ -285,6 +429,12 @@ budget came back unplaced, because real exposed coasts do have beaches, they jus
next door. Re-measure this once the outline is painted rather than noised; it is the same question as E1 from
the other end.
*First painted reading (D-60), and it is not yet the answer:* a partial planet bake came out 0.96 / 1.00 /
1.00, which is not a thinner contrast than the square canvas had but a flatter world - the run solved one
landmass, so most of the painted coast was still unsolved ground sitting at sea level with nothing behind it
to shelter anything. Exposure is measured on the waterline and the shape of the land behind the shore is half
of what sets it. Read this off a whole bake before touching the floor.
**E3. The beach is a beach at 8 m, which is to say it is not one.** The surf reach is 110 m, or 14 cells on the
geology grid, and the berm, the wave-cut notch, the scree below a cliff and the sand itself are all finer than
that. They belong in the detail passes (§4.C) — and note that the surf reach is one of the few lengths in the
@@ -302,8 +452,51 @@ at `--size 1400` (10.2 m cells). A finer grid resolves more of the threshold's w
8 m, and any painted map after it, could turn the same setting into a scatter of one-cell islands. Check the
shoreline length per unit land area and look at `preview.png` before assuming it carries over.
**E6. The shelf break is fixed, and the whole-planet measurement is owed (D-64).** The near-shore sea was
30 m deep everywhere because `BreakM` came from `pipeline.continent.sea_floor_m`, a square-canvas default; it
is `pipeline.coast.break_m` now and a planet gets 130 m. The mechanism is measured at unit scale — a painted
512 m sea goes from 33 % to 8 % shallower than 50 m — but **the re-bake that would give the planet numbers
was killed by memory pressure before it wrote anything.** What to run, and the numbers to put beside
`Bake_020`'s:
```bash
Tools/Terrain/bin/terrain.exe bake --out RawContent/World/Bake_D64 --jobs 4 # detached; ~13 min, peaks near 8.3 GB
```
Read back the depth histogram of `planet_height_low.png` against `Bake_020`'s, which was `-520 m 11.7 %`,
`-20 m 26.8 %`, `0 m 23.6 %`. What should have changed: the −20 m spike disperses across 0…−130 m, the share
deeper than 400 m rises towards the 55.9 % the legend paints, and `preview.png` gains a bathymetric gradient
where it had one flat mid-blue halo — the preview ramps `sea_shallow`→`sea_deep` linearly over the deepest
sea, so 20 m out of 520 was the first colour and nothing else. `--jobs 2` if memory is tight.
**E7. `elevation_m` is three times wider than any world that has been baked, and it is the author's key.**
−1024…2048 against data of −521…+340: the planet uses 28 % of its 16-bit ramp and its land 7 %, which is most
of why the exported heightmap reads as a flat grey picture with no coastline in it. A bake prints this now.
It is not a defect — headroom is a legitimate choice and D-64 deliberately did not touch `Planet.json` — but
about −576…320 is 3.4× the contrast and 3.4× the vertical resolution for the compositions built so far. **The
trap if it is changed:** `tiles` decodes `planet_height.png` through the *current* manifest, so a bake made
under one range and tiled under another is silently wrong by the difference. Re-bake, or do not change it.
### B. Texture — wrong at mid scale
**B0. The clamp ceiling has a number now, and a painted legend walks straight into it.** Steady state is
`S = U/(K·A^m)` applied down to a single cell, so at a divide `A` is one cell squared and `A^m` is the cell
size. Setting that equal to the angle of repose gives the rate above which the clamp does all the shaping:
> `U_max = tan(talus) · K · cell` — at 35°, K 5e-5 and an 8 m cell, **0.280 mm/yr**.
The first painted legend put `highland` at 0.9 mm/yr, which is **66° at a divide**, 3.2 times over. Baked, the
geology comes out as flat polygonal faces with hard 45- and 90-degree edges - the D8 clamp, visible at a
glance once the detail passes are stripped off with `terrain tiles --no-detail`. Not 33 % of the class shaped
by the clamp: all of it.
`terrain plan` now prints the implied divide angle for every land class and says which are clamped, which is
four seconds against an hour and a half. The deeper point is the one §4.D.3 is about: **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 - 0.28 mm/yr on a 20 km island is about 350 m. Getting more relief than that out
of erosion-shaped ground needs the channelization threshold to work, which is exactly the open problem in
§4.D.3 and §6.
**B1. 33 % of the mountain class still sits within 2° of the repose angle**, so a third of the mountains are
shaped by the clamp rather than by erosion. Down from 44 %, and the nonlinear diffusion pass after the clamp
keeps it from showing as hard facets. Levers, most principled first: raise `max_hillslope_substeps` and
@@ -311,23 +504,165 @@ keeps it from showing as hard facets. Levers, most principled first: raise `max_
away the landforms — measured before as "melted wax"); or accept it, since a belt rising at 2 mm/yr genuinely
*is* landslide-dominated in the real world and the clamp is the right model there.
**B2. Multiple-flow-direction accumulation is unbuilt.** The hash jitter recovered most of the damage (R²
0.055 → 0.459) but D8 still lets a cell drain to only one of eight neighbours, and some basin boundaries on
the plains in `map_basins.png` are visibly straight. The proper fix is Freeman/Quinn MFD for `Accumulate`
only, keeping D8 receivers for the implicit solve — Braun–Willett needs a single receiver per node for the
update, but the *area* can come from MFD. Cost: MFD needs its own processing order (descending elevation)
rather than the D8 stack.
**B2. ~~Multiple-flow-direction accumulation is unbuilt~~ - built (D-65), and it turned out to be B3's
cause rather than a refinement of the plains.** This entry read "some basin boundaries on the plains in
`map_basins.png` are visibly straight" and treated MFD as tidying. It is not: on a planar hillslope the
correct specific catchment area is the same at every point along a contour, and D8 cannot say so - every
cell picks the same steepest neighbour, the flow lines run exactly parallel and never converge, and a cell
either sits on a line and carries the whole tube or sits off one and carries a single cell for ever.
Measured on a ramp at an aspect of 22.5 degrees with no erosion at all, one fill and one accumulate
(`internal/fluvial/flow_test.go`): the most-drained cell in a contour band carried **769 times the median**
and **29.5 % of the grid drained nothing**. At an MFD exponent of one the same numbers are **1.34** and
**0.4 %**.
**B3. A ribbed, combed texture on the range flanks**, regularly spaced, roughly perpendicular to the crest.
Not diagnosed. Candidates to check before changing anything: the ridged-noise initial relief showing through
where the solve has not had time to overwrite it; channel spacing locking to the grid at small drainage area;
or the `crests` cellular-edge field at `crest_weight` 0.12. Test with `--stage uplift` and compare the initial
relief against the final flanks.
Built as planned - Freeman/Quinn/Holmgren partition for `Accumulate` only, D8 receivers kept for the
implicit update, because Braun-Willett walks one receiver chain and has no unconditionally stable
multi-receiver form. Two things this entry had wrong. **The processing order is not descending elevation**:
Kahn's algorithm over the flow graph is exact, O(n), and needs no elevation comparison at all - count each
cell's strictly higher neighbours, release on zero. A bucket sort by elevation would have been worse than
useless, because the queue quantises to a centimetre while the flood's epsilon ladder across a filled flat is
a millimetre a cell, so ten cells of one descending chain share a bucket and every lake bed would leak its
area. And **float32 is enough**: a cell's accumulator takes at most eight contributions, each already an
aggregate, so the drift is a random walk over the flow path and measures 2.4e-9 relative over a closed basin.
**The bake-scale verification is owed.** Everything above is measured on the router in isolation and on the
square canvas. The comparison that matters - region 8 of `Planet.json`, the streaked left continent, the same
painting and seed - has a D8 baseline in `RawContent/World/R8_d8` (15m40s, 922 s of solve, land relief 195 m,
slope-area exponent -0.509 at R² 0.966, drainage density 0.43 /km, and 31 sources to 13 confluences in the
12.8 km window at 1391,2625) and **no MFD twin**: that run was killed by memory pressure about 70 % through
and wrote nothing. Until it exists, what is established is the mechanism, not the cure - and one result
argues for caution rather than optimism, which is that after three hundred steps of solving a planar ramp the
leaf fraction converges (D8 8.2 %, MFD 8.9 %), because a dissected landscape's own divides dominate that
count. Run:
```bash
Tools/Terrain/bin/terrain.exe bake --only 8 --steps 1000 --jobs 1 --out RawContent/World/R8_all # detached, ~20 min
```
and read the hillshade of the same window against `R8_d8`'s first, the sources-per-confluence second, and the
slope-area exponent third - it should move *towards* -0.5, which is the number that says the fix is physics
rather than a smoother.
The cost is real and it is the argument against, if there is one: **101 ns a cell against D8's 17**, measured
on a 1024² ramp, and the walk is serial where `ComputeReceivers` and the hillslope law are not, so it lands on
wall clock rather than on cores. `pipeline.fluvial.mfd_exponent` is 1 by default and 0 is the old behaviour,
so the A/B is one flag. **`secondsPerCellStep` in `internal/planet/planet.go` is still the D8 number**, so
`terrain plan`'s estimate now reads low; recalibrate it from the first full MFD bake rather than guessing, and
note that its comment already says it is a floor.
**B3. ~~A ribbed, combed texture on the range flanks~~ - the diagnosis in this entry was wrong, and the
cause is B2 (D-65).** It read "the ridged-noise initial relief showing through where the solve has not had
time to overwrite it", and the reasoning that ruled out the alternative does not hold: it dismissed grid
locking because "the ribs are oblique, not axis-aligned", but D8's parallel-flow grooves run in whatever
direction the slope faces, so obliqueness is the expected appearance and not a counter-indication.
What the grooves are, measured on `Bake_x4` and `Bake_020` - the same painting and seed at 32 m and at 8 m,
both 12500 x 6076, so the same window in cells compares directly:
- they are in `map_flow.png` as parallel high-accumulation lines, so they are **channels**, not surface
texture, and they are absent from `map_uplift.png`;
- the network re-derived from `planet_height.png` is **pinnate** - ruler-straight parallel trunks with short
barbs joining at a near-constant angle - not dendritic. In a 12.8 km window at a 1 km² channel threshold:
25 sources and **0 confluences** at x4, 30 and 11 at 100 km, against about one source per confluence for a
dendritic network; the largest catchment in a 164 km² window is 4.07 km²;
- the D8 receiver histogram over that window is anisotropic: 17.3 % on one diagonal, 14.3 % on its opposite,
the other six 10.7-12.4 %, against 12.5 % uniform;
- and the pitch is **the same 18 cells in both bakes**. That is the decisive one. Every physical candidate -
the ridged fBm this entry blamed (250-300 m), the massif fabric, the fault grain - is fixed in *metres* and
would change its pitch in cells by four. Only a grid-scale mechanism survives it.
The spacing is set by the ±0.05 % tie-break jitter at `fluvial.go`, which is a **static** field - the same
hash at step 1 and at step 1000 - so the rare merges it allows are re-carved a thousand times instead of
averaged out. The ridged fBm may still contribute; it cannot make grooves that are strictly downslope,
strictly parallel, and visible in the flow map.
**B4. `ClampToRepose` left grid-aligned facets, and `bucketPQ` broke ties in raster order** - fixed (D-65), and the fix is worth less than it looks.
*The entry as written, which is still the right description of the mechanism:* Checked
while chasing B3 and **ruled out as its cause** - B3's ribs are oblique - but real and worth not
re-deriving. `internal/fluvial/repose.go` pushes every cell in flat-index order and lowers neighbours in
place, so which neighbour gets cut is decided by pop order; `bucketpq.go` pops LIFO within a 1 cm bucket,
so ground flat to within a centimetre propagates consistently along −X within a row. `hillslope.go`'s own
comment admits the signature: "pyramids with faces aligned to the grid - the blocky, ruler-cut facets". The
designed mitigation is `DiffuseNonlinear` running after it, which bails entirely at `diffusion_m2_yr` 0.
Visible today only as a fine chevron texture inside B3's ribs. The related hazard - `bucketPQ` collapsing
everything above `SetElevationRange`'s ceiling into one bucket processed in strict reverse row-major - is
**not** firing: `ClipFrac` is 0 in every region of every bake measured.
*What the fix did, and what it did not.* The clamp now jitters both its pop order and its allowance with the
same world-keyed hash `ComputeReceivers` uses, through a `pushJittered` that scatters a cell over sixteen
buckets rather than one. Half a bucket was tried first and is not enough - it splits a tie across two buckets
and halves the correlation instead of removing it. Sixteen is safe for a reason worth keeping: the clamp's
order can only matter between two cells whose heights differ by about the talus allowance, which is *metres*,
so reordering cells that are centimetres apart cannot break a constraint that only bites metres apart. The
bound is `talus*cell/2`, 2.8 m at 35 degrees on an 8 m cell. Measured: the pop order's rank correlation with
the flat index went from **-1.000 to -0.025**.
But the isotropy test built for it (`TestClampToReposeIsIsotropic`) reads **identical** with the jitter, without
it, and with either half alone - 0.97 % four-fold and 2.39 % eight-fold on a clamped cone. On a cone no two
cells share a bucket, because the surface falls twenty metres a cell against a one-centimetre bucket, so the
ordering bias has nothing to bite on. The residual octagon is geometry, not order: a path to a point at 22.5
degrees is built of cardinal and diagonal steps and the octile distance it accumulates exceeds the straight
line by up to 8 %, so an eight-connected clamp cuts an octagon out of a cone whatever order it works in. That
is irreducible without a wider neighbourhood. **And the clamp is a small actor anyway** - `near_talus_fraction`
is 0.32 % of mountain cells - so this was housekeeping, not the fix.
**B5. The hillslope smoother transported across four faces while the clamp cut across eight** - fixed (D-65).
`Run`'s design is that the clamp cuts and `DiffuseNonlinear` rounds off what it cut before the next step sees
it, and a five-point stencil cannot transport across a diagonal face at all, so a diagonally-cut facet was
left standing by construction. That was a hole in the stated design rather than a refinement of it. The
stencil is nine-point now, weights 4/6 cardinal and 1/6 diagonal - the isotropic nine-point Laplacian, which
on `h = (a/2)(x²+y²)` gives `(1/6)(8ad² + 4ad²) = 2ad²`, exactly what the five-point gave, so `coeff` is
unchanged. A diagonal face carries its own critical height difference, `sc*dx*sqrt(2)`, or every diagonal
would read as 1.41 times its true S/Sc. Stability improves and pays for the extra faces: the checkerboard
amplification goes from `1 - 8*coeff` to `1 - 5.333*coeff`, so the limit moves 0.25 -> 0.375 and the sub-step
target moves 0.2 -> 0.3 at the same 1.25x margin. Making either change without the other is a scheme that
checkerboards a few hundred steps in, which is why they are one commit and why the stability test now runs
2000 steps rather than 500.
**B6. There is an edge-preserving smooth now, and it is off.** `field.SmoothEdgePreserving`, ported from the
World Orogen browser generator, which has one for exactly this reason - to blend its own routing artefacts
without rounding the landforms off with them. `w = 1/(1 + |dh|/(d*slopeRef))` over eight neighbours, land
only, waterline locked, run once after the solve and never inside the step loop: it conserves nothing and has
no time in it, so per-step it would act as an uncontrolled extra diffusivity, and that moves the steady-state
slope, which is `U/K`, which is the one knob the generator's relief hangs on. The deviation from the
reference is units: a sensitivity in 1/m is a height threshold and means something four times as aggressive on
an 8 m cell as on a 32 m one, which is exactly what section 4.D says the generator lives or dies by, so it is
a slope. Measured on a synthetic: 77 % of a 4 m ripple removed, 98 % of a 300 m cliff kept. `pipeline.smooth.passes`
is **0** by default - turning it on is a decision to hide something rather than fix it, so it is a decision
somebody makes in a file - and a run with it on has to match a run with it off on the slope-area exponent,
the drainage density and the per-class median slopes, or it is shaping terrain rather than polishing it.
**And measured against those gates it fails, which is the point of having them.** On the square canvas at
`--size 500`, 300 steps: two passes at `slope_ref` 0.3 take the slope-area exponent from **-1.02 to +0.36**
with the fit collapsing from R² 0.92 to 0.36, the channel cells from 772 to 566, and the mountain class's
median slope from 15.3° to 11.6°. Backing off does not rescue it - one pass at 0.02, which is a one-degree
reference, still lands at **+0.16** and 14.4°. Drainage density is the one thing that does not move (0.43 /km
throughout). So this is not a free polish at any setting: it is a filter, it changes the slope-area relation
the solve exists to produce, and what it is for is somebody deciding in a file that they want the look more
than they want the statistic. It is not a substitute for B2, and it was not turned on to get B2's result.
### A. Composition — parked, but recorded
All three are one-or-two-constant changes. They are listed so they are not rediscovered, not because they are
next.
**A0. ~~One class is one landscape~~ — closed (D-55), and it was not a tuning item at all.** This entry used
to be absent and the defect it names is the one a person spotted by looking at the bake: every landmass came
out *uniformly* dissected, coast to summit, with no flat ground on any of them. `n` is 1, a class was one rate,
and D-49 says the rate alone fixes the hillslope angle — so one painted colour was one landscape, at whatever
angle its rate named, over every cell of it. A class now carries `massif: {floor_mm_yr, fraction}` and cuts one
planet-wide upland fabric, so a painted lowland is a plain with hill masses standing out of it. See `Terrain.md`
for the threshold problem, which is the part with a wrong answer available: a percentile of the region would
have made two regions disagree along every boundary.
Two numbers from it worth keeping here. **The fabric wavelength has to sit well below a landmass** — 12.5 km
against islands of 20–45 km put one island entirely above the cut, which is this same defect one size down; 7 km
is what the current template uses. And **`internal/stats`' "plain below 0.1 mm/yr" is a reporting bucket, not a
description of terrain**: 0.1 mm/yr is a fourteen-degree hillslope, and reading that line as guidance is how the
legend's plains were set ten times too hot. The buckets are unchanged — they are an axis with a run of measured
numbers behind them — but `terrain plan` now prints what a class reads as from its *angle*, which is the number
an author is really choosing.
The three below are one-or-two-constant changes on the *procedural* path. They are listed so they are not
rediscovered, not because they are next.
**A1. Half the continent is mountain** — 51 % of land on seed 7, 44 % on seed 9342, against nothing like that
in reality. The cause is arithmetic:
@@ -356,7 +691,20 @@ property the percentile ramp exists to provide. Note also that the manifest key
the spec's "20–40 % of the map at low uplift" constraint, which is satisfied trivially and always has been;
the constraint that actually binds is what fraction is *high* uplift, and nothing names it.
**A2. Fault traces are drawn curves with stamped ends** — visible in `map_uplift.png` as straight-edged
**A2a. ~~A fault is a welt with a cliff down the middle~~ - closed (D-62).** The cross-fault profile put
the whole throw either side of the trace one cell apart (89 degrees) inside a 600 m flank - narrower than the
1.1 km hillslope the drainage density implies, so nothing could dissect it and the uplift profile printed
onto the surface as a smooth ruled ridge. Continuous, kilometres wide and bell-tapered along strike now; see
Terrain.md. The general lesson is in section 6.
**A2. ~~Fault traces are drawn curves with stamped ends~~ - closed on the painted path (D-58), still open on
the procedural one.** The painted implementation is a separate file written against this list rather than a
port of the code below: a walked heading-perturbed trace, a throw tapered over the last sixth at each tip,
en-echelon segments past twelve kilometres, and no clamp to a fraction of a global rate. The procedural
`buildFaults` is untouched and still has all four. What follows is that list, kept because it is what the new
one was written against.
*The original entry, against the procedural `buildFaults`:* visible in `map_uplift.png` as straight-edged
polygonal facets and an abrupt cut across a summit. Four causes, all in `buildFaults`: the trace is a single
8-point parabola (`const segs = 8`, one `wander` bow); `signedDistance` over 8 straight segments gives a
piecewise-linear distance field, hence polygonal contours; beyond the last segment `inside` is false and the
@@ -364,7 +712,13 @@ influence stops dead; and `if r > convergent*1.6` flattens the strongest throws
fBm-perturbed heading, `ThrowM` tapered to zero over the last ~15 % of length instead of cut at the tip, and
long faults broken into 2–3 overlapping en-echelon segments.
**A3. Range grain runs as straight parallel bands** — chains run NW–SE like corduroy on seed 9342.
**A3. ~~Range grain runs as straight parallel bands~~ - closed on the painted path (D-58).** The painted
fault set takes its strike from a grain field sampled as a vector through `atan2`, so traces are sub-parallel
within a province and the set swings across the world. Sampled as an *angle* it would have been worse than one
global angle: a value lattice runs 0..1 and jumps a whole turn along its own wrap. The procedural path below
is unchanged.
*The original entry, against the procedural path:* chains run NW–SE like corduroy on seed 9342.
`bv.Data[i] = float32(0.5 + across*2.2 + float64(wy.Data[i]-0.5)*0.32)` stretches the band 2.2× along one
angle with a single mild warp octave. Raise the warp, or warp with two octaves at different scales so chains
bend and bifurcate.
@@ -373,20 +727,14 @@ bend and bifurcate.
## 5. Suggested order
1. **Adopt the two seam rules from §3.3 now** — world-coordinate indexing for all noise and hashes. It is a
small change today and a pervasive one after the detail passes exist.
2. **Build the detail passes (§4.C), and put the coastal detail in with them (§4.E3).** Port from the numpy,
keep every brake, profile before any GPU work. This is the blocker for everything else and the only work
that changes how the ground reads to a player — and the shore is where a player will stand first.
3. **Decide the scale question (§3.3 and §4.D.3)** with the bake-time table in front of you: fixed geology
cell and tiled detail, or critical area with cell-scaled diffusivity. The first is free; take it unless
the bake times are unacceptable.
4. **Wire the painted-map source (§3.2).** Mask first, then uplift, then erodibility — each independently
useful, each falling back to the procedural field. Get the blend rule right from the start. Two of them now
have consumers that did not exist before: the mask is the coastline the coastal pass works on, and the
uplift is what decides whether the shore is a plain or a cliff (§4.E1).
5. **Then composition (§4.A)**, which by then can be judged against a real painted world rather than against
noise.
1. ~~**Make `internal/coast` wrap.**~~ Done (D-60). The mass-balance tests still pass unchanged on a flat
grid and have cylinder twins.
2. ~~**Fix `internal/stats` for planet scale.**~~ Done (D-59). A bake prints the whole block, pooled from
its regions.
3. **Then the coastal detail (§3.2, §4.E3)**, which by then has a shelf and a shore platform to refine and is
where a player will stand first.
4. **Then composition (§4.A)**, which by then can be judged against a real painted world rather than against
noise — and on a painted world two thirds of it is the author's job, not the generator's.
Build-order steps 1 and 2 in `Terrain.md` — the editor viewport and the `Generated` edit layer — remain open,
remain first in that list, and are worth doing whatever happens here: a generator whose output cannot be seen
@@ -394,8 +742,6 @@ in the editor cannot be iterated on, and sculpting that does not survive a rerun
one-shot. They matter *more* under this direction, not less, because an authored world is one somebody will
want to touch up by hand.
---
## 6. Do not redo these
Each was measured, not guessed.
@@ -421,11 +767,107 @@ Each was measured, not guessed.
- **One blur kernel for both the sediment and the carried per-shore values.** The sediment balance needs a
symmetric kernel, which means zero padding; a carried value needs edge clamping, or every shelf near the map
border shrinks to nothing. There are two, and they share their arithmetic on purpose.
- **Giving each region enough margin to run the coastal pass inside it.** It needs `shelf_km` plus `slope_km`,
4.6 km, which nearly doubles every region; and it buys nothing while costing the fetch across every strait,
the sediment budget's conservation proof, an ocean-ownership rule where two margins overlap, and pooled
coastal statistics. The pass is 26 ns a cell. Run it once on the cylinder.
- **Clustering landmasses by overlapping dilated bounding boxes.** Box overlap is transitively closed and one
long thin landmass has an enormous box; on the first real template — where one landmass is 70 km of a 100 km
circumference — it collapses the planet into a single region. Dilate the mask itself with the distance
transform and connected-component the result, which groups exactly those landmasses within a margin of each
other and is the same code the stroke fill already needs.
- **Hanging a world origin on `field.Field`.** 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.
- **Taking the massif threshold as a percentile of the region.** It is the obvious implementation and it is
the one thing D-53's decomposition forbids: `uplift.Build`'s percentile band is a global operation over the
grid it is given, and two regions taking quantiles of their own extents put the same physical hillside on
different sides of the cut, so the planet disagrees with itself along every region boundary. The threshold is
a quantile of the **planet**, from a fixed probe, identical in every region by construction.
`TestTwoFramesAgreeAboutTheSameGround` is the negative control.
- **A massif fabric per class.** One field for the whole planet, cut at a different level by each class. A
fabric per class makes a highland belt and the hills in the lowland beside it two unrelated noises meeting at
a painted edge, when what they should be is the high and low parts of one structure.
- **A massif wavelength near the size of a landmass.** Measured: 12.5 km against islands of 20–45 km put one
whole island above the cut, which is the defect the fabric exists to fix, one size down. Several blocks per
continent, so well under a landmass.
- **Reading a hypsometric preview as a statement about scale.** The ramp is normalised to the world in
front of it, and it has to be - absolute over a world with no mountains is a green shape with nothing
legible on it. `palette.land_top_m` is there for when absolute is what you want, and the run summary
names the ceiling either way. The judgement of steepness belongs to `map_slope.png` and the plan's
`typical` column.
- **Porting `uplift.Build`'s lithology or faults as they stand.** The first ends in `f.Percentile()` of the
grid it is handed and the second places trace centres at `Float()` pairs read as fractions of it, so on a
decomposed planet both give a different answer in every region. D-58 rewrote both against world coordinates
and a planet quantile; `TestTwoFramesAgreeAboutTheSameRock` and `TestTwoFramesAgreeAboutTheSameFaults` are
the negative controls.
- **Blurring a rock boundary to soften it.** A blur is a neighbourhood operation and one near a region's edge
reads cells another decomposition would not have given it. The softening is pointwise, in rank space, which
needs only the cell's own value and grades the boundary by the fabric's own gradient.
- **Cutting a fault's influence off where the exponential is still worth something.** At three gentle lengths
it is 5 % of peak, which on a 400 m throw is a fifth of a lowland's whole uplift rate - a step at a line
six kilometres out that the solve carves into a straight scarp. Superseded by D-62, which drops the
exponential for an envelope that reaches zero *with zero gradient* at its own width - the cut-off is then
the support of the function rather than a truncation of it, and there is nothing to renormalise. The
lesson is the general one and it is why the old profile's near end was worse than its far end: **any step
left in the uplift field, anywhere, is a straight scarp the solve cannot undo.**
- **Reading the divide angle as the landscape.** It is exact and it is the steepest ground a rate can make,
because `A` is smallest at the top of a catchment; the median is a third of it in tangent and almost none of
a map is divide. Both print now (D-57). This is the same class of mistake as reading `internal/stats`'
"plain below 0.1 mm/yr" as terrain, and it cost the same thing: every rate in the first legend set two or
three times too hot.
- **A reserved background colour for the overlay's blank.** Alpha already says it, every editor produces it,
and a colour would be spent on nothing and lost the moment somebody exported with a white matte.
- **Snapping an unmatched overlay pixel to its nearest mark.** That is right on a class template, where every
pixel must become something, and wrong here, where most of the sheet is nothing: it turns a JPEG halo round
a road into road. Drop it and count it.
- **One mask image per overlay mark.** Marks cannot overlap - one painting, one colour a pixel - so an 8-bit
index raster holds 254 of them in the space one boolean mask would take.
- **Treating an unmarked cell as `coast_jitter` 1.** A mark lands on whichever side of the waterline the
author's hand was on, so an uninstructed cell has to take its instruction from the far side or a stroke on
the land is overruled by the water beside it.
- **Importing a painted *heightmap* as the terrain.** See §3.2. The solve will erode it into something else
and the drainage network — the reason the generator exists — is thrown away. Paint the uplift.
## 7. Traps
- **`map_flow.png` at planet scale is aliased, and it looks exactly like broken drainage.** It point-samples
every fourth cell (3000 px on a 12500 grid), so a one-cell channel survives about a quarter of the time and
the network reads as disconnected yellow stubs, while the divides - which are broad - come through whole.
Bake_001 shows the identical pattern, so it is the diagnostic and not the terrain. Judge the network from
the preview's drawn rivers, or crop the map at 1:1.
- **Max elevation cannot see a change to the *distribution* of uplift.** D-55 dropped the lowland continent
from 71 m to 41 m while leaving the rate its massifs reach untouched, because relief is the integral of
slope along the whole flow path: a trunk crossing a plain climbs where the massifs are and nowhere else.
The statistic to read is the slope distribution, which `internal/stats` still cannot produce at planet
scale - it had to be taken off the heightmap by hand for that measurement.
- **Rendering a diagnostic map smaller does not make `plan` quicker.** Measured: prepare is flat at about
6.5 s from a 400 px map to a 2400 px one, because the cost is `region.Build` at **2.68 s** over the 76 M
cell planet grid - classify 0.09, dissolve strokes 0.93, despeckle 1.52, the coast mask 1.62, project 0.05.
Neither the size of the painting nor the size of the maps touches the big one. What does work is caching:
the studio reuses the whole prepare when only the legend's *numbers* changed, 7.0 s to 0.37 s.
- **`terrain plan`'s `divide` column is not the ground.** See §6. Read `typical`.
- **`preview.png`'s tint is relative and says nothing about scale.** The ramp's top is a percentile of
the world being drawn, so a 47 m lowland continent whose median slope is 0.61° comes out with the bare
rock and white caps of an alpine massif. That is where "the lowlands are hilly" came from, twice. Every
run prints the ceiling it used; read that line, or read `map_slope.png`, or set `palette.land_top_m`.
- **A manifest key with a default is not a feature.** `coast_jitter_px` was declared, documented, defaulted
and *never read by anything* from D-53 until D-56, and the only reason it was found is that somebody asked
why the coastlines looked drawn. A grep for a key's own name is three seconds and it is worth doing before
tuning one.
- **A planet bake is not a rerun-while-you-judge loop.** It is about two hours at 100 km round. `terrain plan`
is four seconds and settles the two decisions that can waste those two hours — how the legend read the
painting, and how the planet was cut up. `--only <id> --steps 200` is the loop for tuning the legend's
numbers; the elevation range is confirmed from that measurement, not guessed before it.
- **The seed alone no longer names a painted world.** The ocean margin and the minimum landmass size decide
how the planet is cut into regions, and the priority-flood's epsilon ladder across a flat depends on the box
it is flooding. All three are in `Planet.json` and all three are recorded in `meta.json`; a change to any of
them is a change to the world.
- **A noise period that does not divide the circumference breaks every noise field at the seam.**
`noise.Lattice.Sample` wraps modulo its cell count and `WorldUV` divides world metres by the period, so `u`
returns to the same lattice point at `x = W` only when the circumference is a whole number of periods.
`world.Planet.Validate` refuses anything else, and `TestNoiseBreaksWhenThePeriodDoesNotDivide` is the
negative control that keeps the positive test honest.
- **`RawContent/World/World.json` is still pre-D-48**: 4081 vertices at 350 cm, elevation −460…2800, and it
still carries the legacy `erosion` block the tool warns about on every run. The Go defaults implement D-48
(7141 at 200 cm, −512…1536) and the manifest overrides them straight back. Migrating it is build-order step