Files
2026-09-25 17:02:24 +03:00

890 lines
69 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Terrain: next steps
A working brief for the next session on the heightmap generator. It exists so a cleared session can pick the
work up cold. [`Terrain.md`](Terrain.md) is still the specification and the decision record; this is only
"where it stands, what looks wrong, and what to do about it". **Fold anything settled back into `Terrain.md`
and delete the corresponding section here.** Do not let the two drift.
Everything below is off the ladder (D-47). No gameplay code may reach into the generator, and nothing here
blocks a step in [`Steps.md`](Steps.md).
---
## 1. Read this first
The generator is a Go CLI in `Tools/Terrain/`. It turns a seed and `RawContent/World/World.json` into a
heightmap, by building an **uplift rate field** and letting a stream-power erosion solve produce the terrain
from it. The noise is not the terrain; the noise is the tectonics.
```bash
cd Tools/Terrain && go build -o bin/terrain.exe ./cmd/terrain && go test ./...
# a preview run: ~4 minutes at 1400², the iteration loop
Tools/Terrain/bin/terrain.exe generate --size 1400 --out RawContent/World/Try1 --quiet
# another continent
Tools/Terrain/bin/terrain.exe generate --size 1400 --seed 9342 --out RawContent/World/Seed_9342 --quiet
```
`--size` keeps the map's physical extent and samples it more coarsely, so metres, uplift rates and the
stream-power constants all still mean what they mean. **Do not tune on `--size 512`**: the geology cell there
is 28 m against 8–10 m at full size, and steady-state slope goes as `U/(K*A^m)` with `A = cell²` at every
divide, so a 512 preview shows gentler ground than the real run for reasons that have nothing to do with the
change being judged.
Every `bs.*`-style knob in the manifest has a `--flag` override so an experiment does not need a file edit:
`--intraplate`, `--intraplate-swell`, `--convergent`, `--k`, `--diffusion`, `--talus`, `--critical-m2`,
`--critical-slope`, `--slope-cap`, `--hillslope-substeps`, `--lithology-types`, `--fault-scale`, `--steps`,
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 |
| --- | --- |
| `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.** 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 - 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²:
```
slopes: 54% under 15 deg, 77% under 30, 0.3% over 50, median 11.2 deg
slope-area: exponent -0.384 (expect -0.500), R2 0.225 over 6 bins
hypsometric integral 0.107 (concave: over-eroded); drainage density 0.48 /km
by uplift class:
plain 0.00..0.10 mm/yr 42% of land slope 0.8 deg median, 2.8 P90 relief 9 m/490 m at talus 1%
rolling 0.10..0.50 mm/yr 7% of land slope 7.5 deg median, 23.1 P90 relief 85 m/490 m at talus 1%
mountain 0.50.. up mm/yr 51% of land slope 28.5 deg median, 36.0 P90 relief 195 m/490 m at talus 33%
```
The coast prints its own block after it, and the numbers to read first are the sediment budget — it is the one
part of the pass not derived from something already measured — and the exposure percentiles, which say whether
the shoreline has any bays for the shelter to work with:
```
coast: 95 km of shoreline, 37% sea, shelf 86% of it; surf planed 2.8 km2 and cut 21.23 Mm3,
16 river mouths delivered 3.87 Mm3, 25.04 Mm3 laid (0% unplaced) as 2.92 km2 of new beach;
mean cliff 3 m, 0.00 km2 drowned; shore exposure 0.00 / 0.90 / 1.00 (p10/p50/p90)
```
An unplaced fraction above a few per cent means the sediment has nowhere to go and the deposition gates are
wrong; a median exposure of 1.00 means the outline has no bays at all and neither the surf reach nor the
shelter is doing any work.
**The slope–area fit cannot judge one change on one seed, and it was nearly used to reject a good one.** It is
the number this document calls the proof that closes the work, and at five or six bins on a 1400 grid its
seed-to-seed spread on *identical code* is larger than most changes: seed 7 gives −0.480 at R² 0.317, seed 9342
gives −0.698 at R² 0.704, and seed 67914 gives −0.575 at R² 0.944. Use it paired — the same seed before and
after — and across at least two seeds, or raise the bin count before leaning on it.
---
## 2. What was just built, in one paragraph
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.
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 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 at player scale.** Three things, in order.
### 3.1 Finish the planet's own passes
Two things the planet needed that the square canvas did not, and neither was optional for a bake to be judged.
Both are closed:
- ~~**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.
And a third, smaller, which is both a cost and a correctness wart:
- **`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.
### 3.1a The painting has a tool now
`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.
**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 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.
**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.
### 3.1b The first template does not wrap, and that is the input rather than the tool
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 |
| --- | --- | --- | --- | --- |
| 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 |
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 — built, and what it left behind
~~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:
- ~~**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
**D.3 Relief is resolution-dependent, and multi-scale makes that a blocker rather than a wart.** Measured
earlier: 1020 m relief at 512² against 2605 m at 1786² on one seed. The cause is that with
`critical_area_m2` at 0, stream power is applied down to a single cell, so the divide slope is
`U/(K·cell^2m)` — halve the cell and every divide steepens, for ever. As long as there was one canvas this was
a wart. The moment the same painted map is meant to produce worlds at different sizes or resolutions, it
means **the same input gives a different landscape depending on grid size**, which is fatal to the whole idea.
The textbook fix is a critical area, and it was re-measured this session and still fails (§6) because the
hillslope it creates has no transport law strong enough to shed its uplift. So this is genuinely open, and
the two candidate directions are:
- Fix the pairing: critical area *plus* a hillslope diffusivity that scales with cell size (effective `D`
grows roughly as cell², which is the standard sub-grid argument). Principled, and it makes the critical
area work rather than fail.
- Sidestep it: **always solve the geology at one fixed physical cell size** and never vary it, per §3.3. Free,
correct by construction, and it costs the ability to trade resolution for runtime on a big world.
The second is the recommendation for now because it is free and unblocks everything; the first is what to
build if the bake times in §3.3 turn out to be unacceptable.
**D.4 Two open questions that are design, not defects.** The hypsometric integral is 0.10 against 0.4–0.6 for
a mature landscape — no longer a bimodal uplift field, now simply that 42 % of the land is a near-sea-level
plain, which is a question for the `continent` block and will move when composition is tuned. And drainage
density is 0.41–0.48 /km, right on the 0.5 floor set as do-not-cross; it is measured at a 1 km² channel
threshold so it is not directly comparable to a field value, but if it drops further while tuning, raise the
intraplate rate to 0.05 before touching anything else.
### E. The coast — what it built, and the four things it cannot do yet
**E1. ~~There are no sea cliffs~~ — withdrawn, and the metric that said so is replaced.** This entry read "mean
cliff height is 2 to 3 m, and the surf has nothing to cut". Both halves were wrong, and the way they were wrong
is the part worth keeping: *the statistic measured the drop from a cell to its seaward neighbour*, which is a
gradient. One cell of a 10 m grid at the angle of repose is 7 m, so the number could never have exceeded 7
whatever the coast did — it read 2 m on a plain coast and 3 m on a cliffed one because it could not tell them
apart. It is now backshore height: the land's elevation one to two surf reaches inland, median and P90. On that
metric the coast has always had cliffs (seed 7 P90 88 m, seed 9342 108 m, seed 67914 120 m); the median, 3 to
9 m, says the ordinary coast is a plain, which it should.
What the mask taper was actually doing is narrower and is now fixed anyway (D-52): it flattened the ~100 m
strip the surf works in, so the cliff began a hundred metres inland instead of at the water. Removing it is
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 — 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
shelter carries most of the deposition, which is deliberate — measured with no floor, 73 % of the sediment
budget came back unplaced, because real exposed coasts do have beaches, they just have less sand than the bay
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
generator that is set by physics rather than by the canvas, so it does *not* scale with the map: at the 2 m
detail grid it is 55 cells, which is enough for a real profile. The coastal detail pass is a natural addition
to the list in §4.C rather than a separate piece of work.
**E4. Deltas are placed, not tuned.** `river_m3_per_km2` is 1.2e5 with an exponent of 0.6, and both are guesses;
on seed 7 they put 3.9 Mm³ through 16 mouths against 21 Mm³ from the cliffs. The mechanism is right — the supply
concentrates at the mouth and the drift kernel spreads it into the shallows — but nothing has yet asked whether
what comes out reads as a delta. `map_coast.png` at a crop is where that is judged.
**E5. Unverified: speckle at the finer canvas.** The outline gain went from 0.50 to 0.62 on measurements taken
at `--size 1400` (10.2 m cells). A finer grid resolves more of the threshold's wander, so the D-48 canvas at
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
`slope_cap` (costs runtime, buys real stiffening); raise `diffusion_m2_yr` (cheap, but past ~0.05 it smooths
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~~ - 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 %**.
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
**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:
```go
// internal/uplift/uplift.go
rangeMask := percentileMask(band, cfg.Plates.LowUpliftFraction.Hi()*100, 86) // ramps 40th → 86th percentile
r := base + (convergent-base)*float64(rangeMask.Data[i])
```
With `base` ≈ 0.055 and `convergent` 2.0 a cell clears the 0.5 mm/yr mountain threshold at `rangeMask` 0.229,
which the smoothstep reaches at the **54th percentile** — so 46 % of the map is mountain-class by
construction, matching the 44–51 % measured on land. Computed options:
| percentile ramp | exponent on the mask | mountain class, % of map |
| --- | --- | --- |
| 40 → 86 | 1 — today | 46 % |
| 40 → 86 | 2 | 38 % |
| 40 → 86 | 3 | 34 % |
| 60 → 92 | 2 | 24 % |
| 70 → 92 | 2 | **19 %** |
| 75 → 95 | 2 | 15 % |
Squaring the mask is *not* the same as narrowing the ramp: the foreland stays continuous, which is the
property the percentile ramp exists to provide. Note also that the manifest key `low_uplift_fraction` encodes
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.
**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
influence stops dead; and `if r > convergent*1.6` flattens the strongest throws into plateaus. Fix: an
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~~ - 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.
---
## 5. Suggested order
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
in the editor cannot be iterated on, and sculpting that does not survive a rerun makes the whole tool
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.
- **`critical_area_m2` above 0, on its own.** Re-measured after the uplift fix and it still fails: 1e4 sends
the plains back to 7.0°, pins 49 % of the rolling class and 79 % of the mountains against the clamp, and
collapses the slope-area fit to R² 0.001. The hillslope it creates has to shed its uplift by diffusion and
at D 0.02 it cannot. It is not a tuning knob — it needs a transport law to pair with. See §4.D.3, where it
comes back as a real candidate for a real reason.
- **Running the repose clamp only once at the end.** A thousand steps of growth arrive together, it cuts
deeply, and nothing runs afterwards to soften it. The grid facets came back in the summits.
- **`fill_every` above 1.** The 50 was written to protect the time budget and it silently destroys the solve.
- **Raising the intraplate uplift rate to give the plains relief.** That is the mistake this round undid. For
n = 1 the uplift rate alone fixes the hillslope angle; `U` sets how high summits get, not how steep ground
is. If plains need texture it comes from the detail passes or from lithology, never from U.
- **An elevation-gated talus** ("below 150 m, don't dissect"). It would work immediately because the clamp
binds everywhere, but elevation is the *output* of the solve, so gating a process on it is circular, it
produces a visible shelf at the threshold, and it hides the cause.
- **A percentile stretch for shore exposure, and casting fetch in every direction.** Both measured, both
wrong, and both recorded in `Terrain.md`: a percentile collapses on a coast that does not vary and is a
global statistic two tiles would disagree about, and an all-directions fetch counts the land behind the shore
as shelter, which made a straight open coast score as more sheltered than a bay.
- **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
8 and it changes every measured number in this doc, so either do it deliberately and re-baseline, or leave
it alone.
- **Never run an authoring script while the editor has `L_World` open**, and run `create_world.py` detached,
never under a tool timeout — a timeout killed one mid-import before.
- **The elevation ceiling is a hard clip** in the 16-bit encoding, so a run reporting a clip fraction above
0.1 % is a failed run, not a rounded one. `U/K` is the one relief knob. Note this gets harder with painted
uplift, where an author can ask for more relief than the range holds.
- **Determinism from the seed is cross-cutting rule 12 and it applies here completely.** Anything random must
be a hash of (seed, position), never a stateful source: the value for a cell must not depend on how many
cells were visited first, which goroutine ran, or how many steps have passed.
`TestDeterministicAcrossGOMAXPROCS` hashes the output at five values of `GOMAXPROCS` and requires one hash.
Under §3.3's rule 1 that becomes a hash of (seed, *world position*).
- **A test on a grid whose edge is an outlet must say which cells it is asking about.** Two of the four new
hillslope tests passed while measuring nothing: one read the fixed border cells back and called them the
result, the other wrote its initial condition across the fixed border, which then re-injected it into the
interior for ever.