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
+72
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -11,6 +11,8 @@ Two layers, kept apart on purpose.
| [Ideas.md](Ideas.md) | The ideas carried over from the two earlier projects as features a world could hold, with what each needs. None scheduled. |
| [Decisions.md](Decisions.md) | One line per decision with a pointer, the open decisions that block steps, and what is deliberately deferred. |
| [Worklog.md](Worklog.md) | One terse line per step closed or wall hit. The memory. |
| [World-Pipeline.md](World-Pipeline.md) | Off the ladder: how a painted world map becomes ground in Unreal, in order — the Go generator, the browser twin, the tiles, the level, the map view. The orientation document for all of it; owns no decisions of its own. |
| [World-Dressing.md](World-Dressing.md) | Off the ladder: the plan for what the ground wears — biome paint layers, the landscape material, sky and light, grass and trees. Five decisions taken in D-74, then five phases. |
**For an implementer that lifts code**, which today means Claude. Long, C++-shaped, and meant to be built from.
@@ -27,6 +29,7 @@ Two layers, kept apart on purpose.
| [Spec/Telemetry.md](Spec/Telemetry.md) | The sink, the envelope, the catalogue. |
| [Spec/UI.md](Spec/UI.md) | The HUD, the prompt, the theme, world-space text, localisation, the menu. |
| [Terrain.md](Terrain.md) | Off the ladder: the world's heightmap generator, the canvas, the passes, the Go core, the editor bridge. Not in `Spec/` because it never runs in a game; only the determinism rule governs it. |
| [Terrain-Next.md](Terrain-Next.md) | The generator's working brief: how to run it, what each output map is for, what still looks wrong, what has been measured and rejected. Anything settled here folds back into `Terrain.md` and is deleted from it. |
## Reading order
+1
View File
@@ -133,6 +133,7 @@ agree. A row is added to a spec and here in the same pull request as the emit ca
| Stats | `status_applied`, `status_blocked`, `status_removed` |
| Combat | `enemy_spawned`, `enemy_killed`, `ability_used`, `player_damaged`, `player_downed`, `player_revived`, `player_died`, `party_wiped`, `grief_action` |
| Crafting | `substance_processed`, `piece_shaped`, `piece_enchanted`, `item_assembled`, `assembly_rejected`, `activity_completed`, `characteristic_discovered`, `weapon_equipped` |
| World | `world_map_opened` (layer, metres_per_pixel, opened_by) |
| Technical | `perf_sample`, `error_logged`, `net_correction` (count of visible movement corrections per 30 s, from `p.NetShowCorrections`) |
### The rows that carry weight
+68
View File
@@ -161,12 +161,80 @@ Hooks now, content later; none of the later work touches a widget.
| Rebinding | prompts read the live binding | the rows ship in step 3 through engine settings |
| Hold-to-confirm | on irreversible verbs already | an option to extend it to every verb |
## The world map
```
[DECIDED] The map is a picture of the world with a linear transform on it. There is no capture. (D-73)
```
`L_World` is a whole planet map imported with no crop (`RawContent/World/Region.json`), so the art and the ground
are the same rectangle at two scales and the whole projection is one multiply and one add per axis. Everything
else about a map view falls out of that and most of it is absence: no scene capture, no render target, no minimap
actor, no per-tile bookkeeping. A capture would also be *wrong* rather than merely expensive — the level is
world-partitioned, so a capture only ever sees the streamed-in region, and a map is exactly the thing that must
show ground nobody is standing on.
```
Source/SaltyCore/World/
└── WorldMapProjection.h // pure: world cm <-> 0..1 across the art, the seam, distance on the ground
Source/Salty/World/
├── WorldMapDefinition.h // UPrimaryDataAsset: the projection, the layers, which level it is a map of
└── WorldMapSubsystem.h // world subsystem: finds the definition, holds the markers, emits the event
Source/Salty/UI/
├── SWorldMap.h // the map itself: drawing, panning, zooming, markers, scale bar
└── WorldMapWidget.h // UWidget wrapping it, so a Blueprint can drop one into a screen
Source/SaltyEditor/WorldMap/
└── WorldMapTab.h // the same SWorldMap in a dockable tab; a click moves the viewport camera
```
**Why Slate and not a `UUserWidget`.** The map has two hosts with nothing else in common, and the editor tab has
no `UWorld` at all — anything that needed one to exist could not be shared, and a second implementation is where
two maps quietly start disagreeing about where things are. `UWorldMapWidget` is the UMG wrapper; `WBP_WorldMap`
is what owns the frame around it, which is the usual C++/Blueprint line.
**Markers are registered, never searched for.** An actor that wants to be on the map adds one in `BeginPlay` and
removes it in `EndPlay`. The local player's own body is deliberately not a registered marker: it moves every
frame, and a registry entry rewritten every frame is a registry being used as a variable. It is queried each
paint instead, through `GetLocalPlayerMarker`, which also means it is drawn last and can never end up hidden
under a waypoint that happens to be on top of it.
**Following is sticky in both directions.** The map opens centred on the player and tracks them. Panning by hand
turns the follow off, because a map that snaps back the moment you let go cannot be read — and because that
would otherwise be a one-way door, a left double-click (or `bs.WorldMapFollow`) goes back to the player and
resumes it. `FocusOnWorld` deliberately does *not* re-arm the follow: a "go here" jump and a "go back to me"
are different intents and only one of them means "and keep up".
**The art is data.** `RawContent/World/MapArt/layers.json` says which planet images become layers; `Tools/MapArt`
renders them (including the derived shaded-relief layer) and `Scripts/Authoring/create_world_map.py` imports them
and writes the definition. The projection is copied out of `Region.json` rather than typed, because a map that
disagrees with the landscape about how big the world is is the one bug here that still looks like a plausible map.
**The key is a debug bind, deliberately.** `M` opens the map and `N` cycles its layers, both `DebugExecBindings`
in `Config/DefaultInput.ini` pointing at the `bs.*` commands. That is the engine's own mechanism for putting a
key on a console command: development builds only, no Input Action, no mapping context, no asset. It is the
right shape *because* it is not the input map — step 3 designs that, and a map key invented here would be a
guess at an `Input.*` action that step has to live with. When the map gets a HUD it gets a real action and
those two lines go away.
**Not yet:** there is no HUD, so the only ways in are that key, the console and the editor tab. It does
not read the theme asset either, because there is not one yet; its colours are local and will move to `UUITheme`
when that lands. Overlay marks — the towns, roads and forests `overlay.json` already carries in world metres —
are not drawn; they transfer by the same normalised coordinates and are the obvious next thing.
## Telemetry
`settings_changed` with `setting_id` as the dotted path (`camera.fov.first_person`, `input.bindings.interact`) and
the new value. Nothing else: prompt visibility and menu opens are high frequency and low value, and
`prop_interacted` already answers whether players find things.
`world_map_opened` with the layer, the zoom in ground metres per pixel and what opened it. One event, because
opening the map is the act worth counting and which layer and how far in say what a person wanted from it.
Every way of opening it goes through `UWorldMapSubsystem::NoteMapOpened`, so the count is of maps opened and not
of the ways of opening them.
## Open questions
- **Q1. World-space widget legibility.** A `UWidgetComponent` at bench distance in both camera modes has to be
+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
+1168 -7
View File
File diff suppressed because it is too large Load Diff
+766
View File
@@ -5,6 +5,772 @@ the reasoning lives in the specs, this is the memory.
## Done
- 2026-09-20 - Phase 2 closed: seven layers, end to end. `ULandscapeAuthoringLibrary::CreateLayerInfo` built
and the four layer infos created with the right `LayerName`. Verified per layer - layer info, parameter
prefix and substance - plus `weightmap_entries` resolving **7 of 7**, which is the call a level build makes
per tile.
**The verification earned itself twice.** `LandscapeLayerInfoObject::LayerName` turned out to have a public
`SetLayerName` (the member is deprecated and goes private next release), so the first build's two C4996
warnings became a one-line fix rather than code that breaks on upgrade. And the check caught that **the rock
swap had silently done nothing**: the pack prefixes its parameters by how it *displays* a layer, so
Base_Layer's parameter is "Base Texture" and not "Base_Layer Texture" - and setting a parameter that does
not exist is not an error, the instance just stores an override that drives nothing. The prefix is now read
off each layer function rather than derived from the layer's name, and only three of them - the pack's - have
to be written down at all, because `LayerBlendInput.layer_input` is exposed to MCP but not to Python.
- 2026-09-20 - Phase 2, most of it: the material blends seven layers now. `build_ground_material.py` extends
the pack's material rather than replacing it - a new layer is a *copy of an existing layer function* with its
six parameters renamed, which inherits the camera-distance colour blend that D-69a found load-bearing at
71 km and that a freshly built material would have thrown away. Verified: the `LandscapeLayerBlend` carries
**7 layers, all wired**, `MF_Ground_Beach` and friends carry uniquely prefixed parameters so they do not
collide in the instance, and 20 texture parameters point the layers at the Fab substances.
**Run through the editor's own console over MCP**, which is worth writing down: `SlateInspectorToolset` can
type into the Output Log's command box, and `py <one-liner>` there runs the authoring script in the *running*
editor - same code path as a commandlet, but no second process fighting over assets and no stale editor
afterwards. `sys.argv` and `__file__` are set explicitly in the one-liner because `py` guarantees neither.
**Two engine limits found the hard way, both silent.** `ObjectTools.set_properties` over MCP **does nothing
on a Texture2D** - returns success, changes nothing, no error - which nearly passed review because the
read-back looked right: a `_Normal` suffix makes UE set TC_Normalmap and sRGB off by itself, so only the
Roughness maps exposed it. And `ULandscapeLayerInfoObject::LayerName` is **read-only**, so the obvious
duplicate-and-rename produces a layer info that silently keeps the name it was copied from and paints that
substance wherever the new layer should be. That one is now `ULandscapeAuthoringLibrary::CreateLayerInfo`,
in the editor module for the same reason `CreateLandscapeFromHeightmap` is - and **it needs a build**, so
the four layer infos are still owed and the script says so rather than pretending.
- 2026-09-20 - The Fab substances, sorted and extracted (D-76). Eight Quixel sets arrived dropped straight into
`Content/Terrain/Textures/`: 805 MB of 4K JPGs, **no `.uasset` among them**, so the Content Browser could not
see any of it. Moved to `RawContent/Terrain/Source/` and untracked - raw input belongs there, `Content/Terrain`
is a product (D-69a), and they are re-downloadable. All eight were still untracked when found, so nothing had
reached LFS yet; a commit first would have made this expensive.
`mapart substances` now pulls three maps of nine at 2048 instead of 4096: **52 MB against 805**. The
resolution argument is not about the screen - a UTexture2D stores its source inside the uasset, so 4K would
have been hundreds of megabytes of LFS for ground seen at grazing distance. Normals are renormalised after
the downsample and written as PNG; colour and roughness stay JPEG.
**Reading the physical size from the metadata rather than typing it paid for itself immediately**: every set
is a 2 m scan except Snow, which is **0.30 m**. One tiling number for every layer would have made the ice
cap's grain nearly seven times too big, and nothing about that would have looked like a units bug.
Seven layers now, one spare. `Jungle` was dropped rather than dressed with the mossy rocky ground, which is
temperate where a tropical floor is leaf litter - the equator wears the remainder grass, generic but not
wrong, and bringing it back is one manifest entry plus a substance. The rock is swapped for
Layered_Rock_Cliff, which repaints every steep slope in the same pass. Re-verified after: seams **0
differing vertices**, sums **255..256**.
Not done, and it needs the editor: nothing is imported yet. `collect_terrain_assets.py` only duplicates
assets already in `/Game` and has no import-from-disk path, so that is the next thing, along with the five
new material layers and their layer infos.
- 2026-09-20 - Phase 1 of the dressing: the biome reaches the tiles (D-75). `Region.json`'s `layers` block now
carries `biomes` and an ordered `paint` list - eight layers, five of them not enabled until there is a
substance - and `mapart biomes` writes one blurred mask per biome that `generate_region_tiles.py` samples at
the same global coordinates as the height.
**Three things had to be measured rather than assumed, and two of them changed the design.** Orogen's class
render is *not* a usable class source: it is the legend's colours double-encoded to sRGB, so exported
`desert` sits 53 from legend `ice` and 60 from legend `desert` - nearest-colour matching against it is wrong,
not approximate. The painting is exact, worst distance **0.0** over 29 M pixels, so it is the source.
Tropical came out of `koppen.js`, which turned out to hold a real classification: the same sRGB encode
reproduces every observed colour to **1.4/255** with **0.423%** of pixels unmatched, which is the
anti-aliased ring at class boundaries and nothing more - so jungle is Af+Am+Aw, 8.11% of the planet, rather
than the latitude band I was going to fall back to. And the painting registers to the heightmap by
**identity** in u,v despite 7738x3761 against 8192x4096: 98.09% land/sea agreement against 96.14% for the
scaled alternative, winning in every latitude band including the polar ones.
The blur is global rather than per tile, which is what keeps the seams: per-tile would need a 200-vertex
margin at 400 m and 2 m quads, a third more area on all ninety-eight. Measured after: **0 differing
vertices** across all nine files on a shared column, weights **255..256 and never under**, and the unchanged
three-layer path reproduces the built world with height, rock and high rock **bit-identical** and meadow
differing in **6 of 6 507 601** vertices by one - the deliberate change that lets the remainder absorb the
rounding residual, which took the sum from 253..257 to 255..256.
The classification is Go for the same reason Tools/MapArt is Go at all. Left for phase 2: the Megascans
substances, `rocky_meadows.LAYER_INFOS` (which now refuses a layer it has no info for rather than dropping
it silently), and the beach rule, which wants tuning against a real coast once there is sand to see it with.
- 2026-09-20 - `Docs/World-Dressing.md`: the plan for what the ground wears (D-74). Asked for as "materials
works for the deserts, coasts, tropical, maybe even crater. Also skybox, ligthing and a selection of trees".
**No classification had to be invented**, which is the finding that shaped the whole plan: `Map5.legend.json`
is already `ocean, deep, ice, lowland, highland, desert, crater` with an RGB each, and that map exists at
planet resolution in `Plan/`, in every bake and in `Orogen Gens/`, registered to the world by the same `u,v`
as the heightmap. Tropical is the only one on the list that is not painted - it comes from the climate export
- and `forest` is already a generated overlay mark. So this is a carrying-and-authoring job, not a design one.
**Eight layers is the budget and the budget is the design**: Unreal packs four paint layers per weightmap
texture per component and there are 9800 components, so the fourth is free and the fifth doubles the
weightmap memory. Rock, grass, high rock, sand, beach, jungle floor, ice, regolith; anything past that
displaces something. Trees are `LandscapeGrassOutput` rather than foliage actors (the only thing that scales
to 2549 km², at the cost of no collision), and one planet gets one sky.
Decided: dress **Route A now** rather than wiring Route C first - nothing authored for it is wasted, because
Route C changes the layer rules' *inputs* and not the material, sky, grass or trees - and **Megascans** for
the substances and trees. Phase 1, the biome carry into the tiles, needs no assets and is the next thing to
build; Phase 2 onward waits on the Fab assets being added to the project.
- 2026-09-20 - `Docs/World-Pipeline.md`: the world pipeline written down in order. Asked for as "a docs file
about how this all supposed to work, what order ... so terrain tool, orogen, what do we have to do to get it
into unreal". The knowledge existed and was spread over CLAUDE.md, `Terrain.md`, `Terrain-Next.md` and two
READMEs, none of which says what happens first. It owns no decisions - every one is linked to where it was
argued - and the thing it makes visible is that **there are three routes into Unreal and only one is live**:
Orogen's whole-planet PNG through the Python cutter (what `L_World` is), Orogen's direct tile export
(built, better, unused), and `terrain bake` -> `terrain tiles` (the point of the generator, not wired).
Writing it turned up **two stale claims in `RawContent/World/README.md`**: its "what it is today" table still
described the 6 x 6, 30.60 km, 22.583 m/px window, where `Region.json` has been 14 x 7 over the whole
8192 x 4096 export at 8.7158 m/px since D-72 - 71.40 x 35.70 km, 9800 components. Corrected. And the second
correction is not cosmetic: the "read flat" paragraph's 9.6% east-west stretch was true of a 1355 px window
at latitude -24, and reading the **entire** cylinder flat stretches by 1/cos(latitude) at every row, which is
unbounded at the poles. `L_World`'s polar strips are smeared and always have been; the README now says so.
- 2026-09-20 - A world map, in the game and in the editor, from one widget (D-73). Asked for as "can we make a
map view or something we can use either ingame or as its own window in editor, based of either the generated
world in unreal, or the python ones, or then we have also orogens map". **The three sources are one.**
`Region.json`'s window is the whole 8192 x 4096 export with no crop, so `L_World` *is* the planet map and
world-to-map is `u = (X/100 + 35700) / 71400`, one multiply and one add. No scene capture, no render target,
no minimap actor - and a capture would have been wrong as well as expensive, because a world-partitioned
level only ever has the streamed-in region loaded and a map is the thing that must show ground nobody is
standing on. The numpy pipeline's `L_Canvas_Proto` shares nothing with this and was not targeted.
**Checked that the colormap is the same planet**: `orogen-colormap-14733759` against
`orogen-heightmap-7945` reads as two worlds because Orogen numbers each export rather than each planet, and
it is one - 97.5 % land/sea agreement over a 16 px grid, against satellite's 96.8 % and climate's 87.7 %
(ice scores lower without being wrong). `mapart check` is that test kept, and it is agreement rather than a
hash because every layer is a different render of one planet.
**One `SWorldMap`, three ways in.** Slate rather than a `UUserWidget` because the editor tab has no `UWorld`
at all; `UWorldMapWidget` wraps it for UMG, the tab hosts it directly, `bs.WorldMap` puts it over the
viewport with no content asset needed. Wrapping, pan, zoom about the cursor, a scale bar and a cursor
readout are in the widget, so all three get them.
**`Tools/MapArt` renders the art**, in Go, because the engine's Python cannot decode 33 megapixels of RGB -
`heightmap_io.py` is greyscale-only with a per-byte unfilter loop. Four layers in 6.0 s: relief 2.1 s,
colour 1.4, satellite 1.3, climate 1.0. `relief` is derived rather than exported - hillshade plus a
hypsometric tint off the heightmap the landscape was cut from - and is the default, because a colormap shows
biome where a player needs landform. Land tops at 3810 m, deepest -850 m, 4096 x 2048 at 17.43 m a pixel.
Two real bugs `go vet` caught in the manifest struct: `X, Y int \`json:"x"\`` gives *both* fields the tag,
so `window.y` and `window.height` would never have been read.
**Built, tested and imported.** `SaltyEditor` compiles clean across all three modules; 10 of 10
`Salty.Core.*` tests pass; `build_world_map.sh` writes four 4096x2048 textures and
`DA_WorldMap_L_World` into `Content/World/Maps/` and reads them back.
Three things cost time and are worth knowing.
**`EditDefaultsOnly` cannot be written from Python**, because it means edit-on-default-only and a
`UDataAsset` is an *instance* - so every property on the definition is `EditAnywhere`, which is right for an
asset a script authors: the flag guards a placed actor's copy of something and there is no such copy here.
Direct `setattr` is not a way round it; it routes to the same checked setter, so the fallback that looked
like a fix was deleted rather than left in place implying a path that does not exist.
**An `FSoftObjectPath` reprs as `{}` in Python whether it is set or not**, because its fields are not
`UPROPERTY`s. A first verification pass reported `level` empty and it was never empty - `export_text()` says
`/Game/Maps/L_World.L_World` and the asset registry lists `/Game/Maps/L_World` as a dependency of the
definition. Comparing a Python enum with `str()` lied the same way: `str(TA_WRAP)` is
`"<TextureAddress.TA_WRAP: 0>"`, not the name.
Both traps are now inside `create_world_map.py`: it reads every property back and **refuses to save** if one
did not take, because a property set that silently does nothing looks exactly like one that worked, and a
save is not evidence that the value is there. My first test also failed for its own bad arithmetic -
`-3569900 cm` is 1 m from the map edge, not 100 - so it asserted 200 m where the answer is 2.
- 2026-09-20 - One world level: `L_World` is the region now (D-72). Called as "we shouldn't have a secondary
level anyway, it all should be working on L_World including the world we built".
`Region.json`'s `level` is `/Game/Maps/L_World`; `World.json`'s is `/Game/Maps/L_Canvas_Proto`. Nothing
else was entangled - `L_World` was named only by `World.json` and `world_manifest.py`'s default, no
gameplay code refers to either level, and both default maps are `L_Gym` - so this was two keys and a
rebuild. Rebuilt rather than renamed: a world-partitioned level owns 475 external actor packages plus two
HLOD assets, the editor fixes those up on a Content Browser rename and headless is not a path walked here,
and a half-fixed rename is worse than an hour of unattended batches.
**Three things that had to move with the name.** The tile PNGs are named after the level (`tile_name` is
its last segment plus the coordinates), so changing `level` made all 98 tile sets look missing and would
have regenerated 208 MB that already existed - the 392 files were renamed instead. `build_region.sh` reads
the level out of the manifest now, the way it already read the grid, because a name written into the driver
goes stale the moment the manifest changes and the lock probe would then guard the wrong file. And the
numpy pipeline's own PNGs became `L_Canvas_Proto_*.png`, because `L_World_Height.png` in `Heightmaps/`
beside `L_World_x0_y0_Height.png` in `RegionTiles/` is two files differing by a tile suffix.
The old canvas is repointed, not deleted, and the different name is the point: `create_world.py` empties
whatever level it is handed, so a manifest still saying `L_World` would replace 98 landscapes with a 14 km
square on one run. Kept because it is still the only path carrying the erosion pass's flow, wear and
deposit maps into Unreal. Verified on the rebuild: `L_World` emptied to 9 actors and the sweep removed
**exactly 256** stale packages, the old landscape's 16 x 16 proxies, and nothing else.
- 2026-09-20 - The sea wears a placeholder grey. Reported as "the sea material is abit bugged right now, make
it a testing grey". `/Game/World/M_Sea_Proto` - opaque, default-lit, base colour 0.18 - replaces the
engine's single-layer water on `World_Sea_Proto`. The water material is a lake shader stretched over a whole
planet and reads at every scale as something it is not; `rocky_meadows.SEA_GREY` is the switch back and the
water path is unchanged behind it. Authored on demand rather than picked out of `/Engine`, because nothing
there is the right value: `BasicShapeMaterial` is the near-white that once read as an ice sheet to the
horizon (2026-09-16) and `WorldGridMaterial` puts a metre grid on a plane 70 km across.
The part worth keeping: **`ensure_dressing` spawns the sea only when the level has none**, which is the
right rule - a rerun must not leave two suns - and it means a material change cannot reach a world that
already exists. `ensure_sea` now re-applies the material on every run, and `fix_sea_material.py` repaints a
finished level and saves it, so changing one material reference does not cost a rebuild of 98 landscapes.
Applied to `L_Region` and read back out of the asset to confirm the value landed rather than defaulting.
- 2026-09-20 - Off the ladder: World Orogen exports Unreal landscape tiles directly (D-71), and a locked
level is refused before it is emptied (D-71a).
**The export.** Asked for as "can you make it so we can export from orogen as intended … its the 2m/px
type exports, whatever unreal needs". Orogen's two exports are a picture and one flat 8192 x 4096 PNG with
no scale on it, which is why D-69's `metres_per_pixel` had to be invented. The new **Unreal Landscape**
panel (in the export card on both pages) renders a window of the planet straight into the tile set the
importer wants: a 16-bit height at 255*N+1 vertices and three 8-bit weightmaps per tile, plus the
`Region.json` that describes them, written into a folder through the File System Access API - so the
files land in `RawContent/World/RegionTiles/` and `create_region_world.py` reads them unchanged.
The window is sampled **once** into a float raster and every tile is cut out of it by global vertex
position, which is what makes the seams exact: measured 0 of 1021 vertices differing on both the
east-west and the north-south seam, heights and paint. The paint only closes because tiles carry a
one-vertex margin for the slope's central difference - the same defect D-69 hit in numpy, hit again here.
Heights ride `heightmapColor`'s -5..6 km ramp but are read out of the float target rather than quantised,
so precision is a millimetre against the 16-bit PNG's 17 cm.
**What the panel is for is the number you have not typed yet.** It re-plans on the keystroke and prints
ground, components, file count, sample spacing and the flat reading's cost. It earned that immediately:
D-69's own numbers - 936 km2 on a 100 km planet - are a window **110 degrees on a side, stretched 74.7 %
at its edge**, because a 100 km circumference is a 3183 km2 world and 936 km2 is 29 % of it. The
projection is cosine-corrected at the centre latitude so the error splits between the two edges instead
of landing on one, and the defaults are 4 x 2 tiles: 208 km2 at 5.4 %, which a sphere this small can hold.
**Not `<input type="number">`** - it parses in the browser's locale, so on a comma-decimal machine 0.17
shows as "0,17" and `.value` returns empty, making the setting NaN and the export a tile set of nothing.
Caught in the first screenshot. An existing `Region.json` is **never replaced** - most of that file is the
reasoning behind its numbers, and D-56 already settled that a generated save must not eat an author's
commentary - so a second one lands as `Region.generated.json` and the status line says so; the tiles are
data and are overwritten. Verified in a real browser over the DevTools protocol, 28 checks including the
encoder's own scanlines inflated back out and the button wiring itself up on both pages with no console
errors. This does **not** retire `generate_region_tiles.py`, which is the path that will read
`terrain tiles`.
**The locked level.** Reported as "currently the map IS BUGGED OUT somehow, super glitchy, nothing left".
Nothing was corrupt and nothing was lost. A full rebuild had been run with the editor holding `L_Region`
open: batch 0 emptied the level, batches 0-3 built twelve tiles, and batch 4's save died on
`MoveFile … (Error Code 32)`, a sharing violation. The run aborted loudly and correctly - but the wipe
had already happened, and the twelve survivors were all of row y=0, the northern polar strip, which on
this planet is nearly all ocean. The level opened on 71 km of sea. A failure detected only *after* the
destructive step is indistinguishable from data loss, so both the script and the driver now probe the
`.umap` with `open(path, 'r+b')` before anything is emptied. Refilled with `--append`; the twelve good
tiles were kept.
- 2026-09-20 - L_Region: the spawn was 180 m underground and the fog was the pack's 8 km fog on a 30.6 km
world. Reported as "when spawning in the world i am under the landscape" and "i cant see the world in the
editor, its just some random fog on the ground".
**The spawn.** `ensure_player_starts` traced straight down for the ground. In a commandlet the landscape's
collision is not reliably present but the sea plane at Z 0 **is** - it keeps collision so a walk off the
coast is a walk - so the trace hit the sea and returned `0.0`, which is not `None` and so went straight past
the guard written to catch exactly this case. The starts were saved at **1.20 m with the ground at 181.83 m**.
The pad height is read out of the heightmap now: the window's centre vertex is the [0, 0] pixel of the middle
tile, because half of six tiles is a whole number of them. Existing starts are moved rather than skipped, so
a rerun repairs a level instead of leaving it wrong. No trace anywhere in the path.
**The fog.** `PACK_FOG` is Rocky Meadows' demo tuning for an 8 km map; density is per unit distance, so on
30.6 km it is opaque - a capture looking straight down from 25 km was pure white. `rocky_meadows.scale_fog`
divides the density by how much bigger the world is than the 8 km demo (0.027143 -> **0.0071** here, 0.0152
for L_World) and sets the height falloff to the engine's 0.2 so fog thins with altitude instead of reaching
the top of the sky. PACK_FOG is left as read; the scaling returns a copy.
**What is still not visible, and it is not the fog.** Measured: `trace_world` straight down at the origin
hits at **Z = 0**, the sea plane. The 36 `Landscape` actors are always-loaded and carry **zero components**;
`ChangeGridSize` moved every component onto **144 LandscapeStreamingProxy** actors, which are spatially
loaded. World Partition knows all 190 actor descriptors and reports the right world bounds (+/-16384 m), so
nothing is lost - the proxies are simply never loaded, in the editor or (apparently) in PIE at the spawn.
Everything in the diagnostic captures was the **sea plane** lit through the cloud-shadow light function,
not terrain. **L_World has the same symptom** (open item 2), and it also splits into proxies, so this is the
project's landscape path rather than anything about this world. Measured on one tile: grid size 0 leaves
**100 components on the Landscape actor**, grid size 5 leaves **0**. Unresolved; the choice is to load a
region in the World Partition window, to build HLODs so unloaded ground still draws, or to stop splitting.
- 2026-09-20 - Off the ladder: **Generate marks** is a button on the studio's overlay tab (D-70).
`internal/studio/overlaygen.go` + a handler in `page.html`. Every press is a fresh seed, so it is a
re-roll; the server remembers what the last generation put down and clears exactly that first, so drafts
replace each other instead of silting up and hand-painted work is never touched. It runs **before the first
bake** on the painting alone (flat height, nil drainage) and says which path ran, because a sketch that
knows nothing about rivers or slope must not be read as one that does. Two defects found by pressing it:
settlement placement had no seed dependence at all, so two presses gave byte-identical drafts - fixed with
a seeded score jitter, since the forest count is a quantile and therefore invariant by design; and Map3's
bake was silently accepted for Map5, because `CheckBake` compares only how a heightmap is *encoded* and two
paintings of one planet agree on all of it - `BakeIsOfThisPainting` now compares the recorded template and
falls back with the reason printed. The sheet is replaced rather than stroked, so undo history is dropped
and the status line says so. Verified headless over the DevTools protocol: hidden on the class sheet, shown
on the overlay, two presses differ, wrong bake named and ignored, zero page errors. Tests:
`TestASecondSeedMovesTheSettlements`.
- 2026-09-20 - Off the ladder: 900 km2 of ground in Unreal, cut from the Orogen planet export and tiled
(D-69). `RawContent/World/Region.json` + `Scripts/Authoring/region_manifest.py`,
`generate_region_tiles.py`, `create_region_world.py`, `build_region.sh`; the Rocky Meadows kit moved out of
`create_world.py` into `rocky_meadows.py` so both worlds are dressed by one set of numbers.
6x6 landscapes of 2551 vertices at 200 cm, 10x10 components of 255 quads each: **30.60 km a side, 936 km2
of map holding 905 km2 of land**, -510 to 2972 m, nothing clipped. Tiles in 134 s and 208 MB (untracked).
**The planet's scale had to be invented.** The export carries none, and at `Planet.json`'s 100 km
circumference the planet is 31.8 km across, so a flat 30 km square does not fit on it. Searched the land
mask over scale and position: 22.583 m a pixel (a 185 km circumference) is the finest reading whose best
window still clears 900 km2 of land. Read flat rather than unprojected, which at latitude -24 stretches the
ground 9.6% east-west; cos-correcting the crop would stretch its own edges by +/-35% across the latitudes
it spans. Heights are Orogen's normalised metres, not the generator's - `terrain bake` makes this same
continent a 116 m plain - so the relief is art; `sea_scale` 0.17 is the only correction, and only to the sea.
**Seams are exact by construction**: every vertex is sampled from its global position in the window, so a
shared column is computed twice from the same coordinates and comes out bit-identical, heights and all three
weightmaps. That did not hold for slope until the tiles were sampled with a one-vertex margin - `np.gradient`
takes a one-sided difference at an array edge, and every tile boundary was a one-vertex line of different
paint. Resampling is Catmull-Rom clamped to its two central taps, because an 11.3x upsample of a source whose
steps are its coastlines rings otherwise.
Three walls hit, all in the engine rather than the data:
1. *Memory.* A landscape of a hundred components costs about a gigabyte the editor never gives back, so one
process asked for all thirty-six reached 14.7 GB by the ninth tile with 0.3 GB of commit left on a 31.9 GB
machine. Killed twice. The builder is incremental now - it adds the tiles it is told to, saves and exits -
and `build_region.sh` runs it in batches of three. Six peaked at 13.6 GB and left 1.2 GB of commit, which
is why the default is three.
2. *Arguments never reached the script.* `UPythonScriptCommandlet::Main` reads `-Script=` as one quoted
string and hands the whole thing to the Python plugin, which splits it into a filename and arguments;
anything after it on the command line is the engine's. Passed the wrong way the script saw no arguments
and silently built all thirty-six - the exact run the batching exists to prevent.
3. *A commandlet with the editor open and a relative project path writes nothing at all* and exits zero,
which reads as success. Absolute paths and `-abslog` throughout.
`UnrealEditor-Cmd` also exits non-zero whenever anything logged an Error, and this project logs three on
every start (no GameFeatureData asset rule; the open editor already holds MCP's port 8000), so the driver
gates on the script's own "saved" line instead of the exit code.
Fixed on the way past: the sea plane's material is `/Engine/EngineMaterials/WaterMaterial.WaterMaterial`,
the full object path - the package path alone resolves in the editor but not under `-run=pythonscript`, so
`L_World`'s sea has been a grey shape material rather than water. Not yet re-verified by a rebuild.
- 2026-09-20 - Off the ladder: `Content/Terrain/` collects the ground out of the asset packs (D-69).
`RawContent/Terrain/ground.json` names fourteen assets - six textures, three layer functions, the master
material, its instance and three layer infos - and `Scripts/Authoring/collect_terrain_assets.py` copies them
in, idempotently. Copies rather than moves, so a pack stays as it shipped. A copy is not ownership: a
duplicated material function still samples the pack's textures, because the reference is inside the graph,
so every copy is walked and repointed at its siblings and the asset registry is then asked what still points
outside `Content/Terrain` - that report is the answer to whether a pack can be deleted. Only Rocky Meadows
has real ground today; HouseForge's stone is architecture and its grass is a foliage card.
Getting there took four corrections, each found by the report rather than reasoned: `Expressions` is
protected on UMaterial and absent on UMaterialFunction, so it has to come from
`MaterialEditingLibrary`; a function needs `get_material_function_expressions`, since
`get_material_expressions` refuses one outright, which is what left all three layer functions still
sampling the pack; the registry must be rescanned before the report, or the new assets come back with
no dependencies and **an empty answer was being read as clean** - the first run claimed the folder
stood on its own while the master was still calling the pack's functions; and a material caches the
textures it and its functions reference, so every copy is recompiled and force-saved at the end or the
stale cache outlives the repointing. Final state: 21 graph references and 13 instance references
repointed, **one left** - `MI_Ground_RockyMeadows` still names the pack's `T_Rock_Shade_Variation`,
which is not one of its parent's parameters and is not in its override array, so neither pass reaches
it. Everything renders from `Content/Terrain`; the pack cannot be deleted until that one is cleared.
- 2026-09-20 - Off the ladder: `terrain overlay` proposes an annotation layer from a bake (D-68).
`internal/overlay/generate.go` + `generate_roads.go` + `internal/planet/overlaygen.go`, and a `generate`
block per mark in the overlay legend. Four kinds: forest (noise-broken, treeline from a quantile of the
land's own heights), settlement (scored on drainage, flat ground and distance to the sea, one spacing rule
across tiers), road (minimum spanning tree on least-cost paths, water impassable so each landmass has its
own network), coast (supported, deliberately not shipped enabled - `coast_jitter` changes the next bake).
**Generation fills blanks and never touches a painted pixel**, so the round trip runs both ways.
Three defects found by running it: the no-overwrite rule blocked every kind after the first (a coast band
took 21 % of the world and the towns inside it painted nothing); settlements were scored onto painted
ground where they could not be stamped, so a city and six villages were placed, reported and then dropped
by the feature pass; and forest grew on both ice caps until `not_classes` existed. Measured on Map3 against
Bake_022: 17 s, 3.88 M painted px kept, 2.48 M added, and `terrain plan` reads it back with 0 unmatched
pixels and counts matching the run exactly. Tests: `internal/overlay/generate_test.go` - painted pixels
survive, marks without a block are never generated, nothing lands at sea, spacing holds across tiers, roads
never cross water, two runs are identical, and a generated sheet classifies back to itself.
- 2026-09-20 - Off the ladder: World Orogen reads the planet's own files (D-67). Three ports from
`Tools/Terrain`, each one a question an author has while typing a number. (1) `js/painted-report.js` is
`plan.go`'s angle functions exactly - the class table shows the **typical** median hillslope and what it
reads as, with the divide angle and P90 in the tooltip and a "clamped" flag past the angle of repose;
verified against `RawContent/World/Plan/plan.json` on Map3, five land classes and the clamp ceiling all to
1e-9. (2) `js/painted-overlay.js` + `painted-overlay-view.js` carry the annotation layer: same classifier
rule (alpha is blank, no-match dropped and counted), drawn as a **texture** on the globe's triangles and on
a map quad rather than voted onto regions, with an Overlay Sheet toggle, an Overlay inspect layer and
export type, and the mark named in the hover. Marks are voted only for `coast_jitter`, which pins or
roughens the shore. (3) `Planet.json` is read directly and outranks a legend's `planet` block, bringing the
pipeline constants the angles need. Plus `Tools/Terrain/internal/studio/share.go`: the studio serves the
painting, both legends and the manifest with a CORS header **on GET and HEAD only** and no preflight, so
"Load from studio" brings the whole planet in 4.0 s and no page can ever start a bake. Measured headless:
overlay painted px identical to `plan.json` (3,875,832, 0 far), 200 steps in 7.3 s, marks on 45,745
regions, zero page errors; mobile at 390 px has no horizontal scroll and 44 px targets. Tests:
`internal/studio/share_test.go` (CORS is read-only on five methods; the served file is the text on disk).
Not carried, same reason as D-66: faults, craters, the coast pass, the detail passes, the slope histograms.
- 2026-09-19 - Off the ladder: World Orogen (`Tools/Orogen/`, GPL, plain ES modules) has a **Painted Map**
source on its import page that reads the same painting and `*.legend.json` as `terrain plan` and solves
the uplift into terrain on its sphere mesh (D-66): `js/painted.js` (legend, nearest-colour classify with
a 6-bit colour cache, majority vote per region, stroke dissolve / pole strokes to `edge_class`, coast
roughening on the signed hop distance, massif and rock fields cut at planet quantiles, coastal-plain ramp,
swell, Braun-Willett implicit solve with a per-step priority flood, relief scaled to a Peak Height
slider), `js/painted-layers.js` (class, uplift, erodibility, drainage, slope, basin colours for globe,
map and export), worker handler, six inspect layers and export types, an editable legend table with
Download, a `planet` block in the legend for the Planet.json numbers, `assets/painted-legend.json` and a
quarter-size `assets/painted-demo.png` made from Map3. Verified headless (Chrome over the DevTools
protocol from node): Map3 at 204K regions classifies in 0.45 s, solves 200 steps in 12.3 s, 25 s with
climate, no page errors; dendritic networks with trunk rivers on every landmass, massifs standing out of
the lowlands, 16-bit heightmap and the six painted PNGs export. Not carried: faults, craters, overlay,
plates, repose clamp, detail passes (44 km cells). Uncommitted, with the rest of the working tree.
- 2026-09-19 - Off the ladder: the laser-carved flanks were D8's, and drainage area is multiple-flow now
(D-65). Reported as "streaks going down the side of the mountains making them look like laser carved",
against the x4 bake. `Terrain-Next.md` 4.B3 blamed the ridged-fBm initial relief; it was wrong, and the
test that ruled out grid locking - "the ribs are oblique, not axis-aligned" - was not a test, because D8's
parallel-flow grooves run in whatever direction the slope faces. The grooves are channels: present in
`map_flow.png`, absent from `map_uplift.png`, and the network is pinnate rather than dendritic - 25 sources
and no confluences in a 12.8 km window. The pitch is 18 cells in both `Bake_x4` at 32 m and `Bake_020` at
8 m, which no mechanism fixed in metres can produce. Isolated with no erosion at all on a planar ramp:
D8 gives the most-drained cell in a contour band 769x the median and leaves 29.5 % of the grid draining
nothing, where the true answer on a plane is 1 and 0. Freeman MFD for `Accumulate` only, D8 receivers kept
for the implicit update, Kahn order rather than an elevation sort: 1.34 and 0.4 %. 101 ns a cell against
D8's 17. Three repairs alongside - the repose clamp jitters its pop order and allowance, `DiffuseNonlinear`
goes to the isotropic nine-point stencil because the clamp cuts across eight faces and a five-point
smoother cannot transport across a diagonal one, and `field.SmoothEdgePreserving` is ported from the
World Orogen browser generator, off by default. Tests: `flow_test.go`, `smooth_test.go`, the clamp isotropy
and bucket-order pair, and a benchmark - which is what caught the first version of the MFD precondition
returning a silently wrong area on a second call (D-65a).
- 2026-09-19 - Off the ladder: the ocean was thirty metres deep (D-64). Reported as "it is just a
landmass and no oceans really", against the exported heightmap. `Bake_020` measured: the legend paints
`ocean` and `deep` at 512 m over 55.9 % of the planet and 17 % of it gets there, while 40 % of the world
is water between 0 and 30 m - one 26.8 % spike at -20 m. At -1024..2048 m that is 1 % of the 16-bit ramp
from sea level, so shelf and land encode to the same grey and the shelf halos fuse the continents.
**One number, taken from the other canvas.** `coast.Build` wants a `BreakM`, the depth at the shelf
break, and both call sites read `-pipeline.continent.sea_floor_m.hi()` = 30 m - a square-canvas default
whose own comment says it is not a shelf break, since on a 14.28 km canvas a real one does not fit.
`AbyssM` has been per-cell from the painting since D-60; the break never was.
**What hid it:** the derived margin reaches `shelf_km.hi() + slope_km` = 4.6 km from every shore, and
1069 km of shoreline against a 3111 km2 sea is 4917 km2 of margin over a smaller ocean, so the painting is
never consulted in any strait. The profile is monotone and correct at any break depth, which is why the
profile tests all passed and this had to be found in a histogram.
`pipeline.coast.break_m` is a key; `ShelfBreakM()` falls back to the old reading so the square canvas is
unchanged (verified, still 30 m), and a planet defaults to 130 m - the depth the template's own `shelf`
class is painted at. The 4.6 km margin is unchanged: 512 m over it is a 6.3 deg slope, which is right.
Two more of the same read in `tiles.go`, both commented "the shelf break": `restoreSeaFloor` would have
staircased the new shelf and the tile hillshade would have flattened it.
Also: a bake prints its sea floor's three numbers, and how much of the 16-bit ramp the world used -
28 %, land 7 %. Too *wide* a range clips nothing, so `clip_fraction` never saw it. `Planet.json` untouched;
~-576..320 would be 3.4x the contrast, and that is the author's call.
Measured at unit scale, 9.6 km of sea painted at 512 m: 33 % -> 8 % of it left shallower than 50 m, and a
sea painted at 20 m still 20 m deep. **The whole-planet re-bake was killed by memory pressure (the coastal
pass peaks near 8.3 GB) before it wrote anything - its numbers are still owed.**
- 2026-09-19 - Off the ladder: a fault set saturates, and the initial relief stops reading the faults
(D-63). Reported as "the mountains seem to be streaking horizontally like someone just cut the mountains
apart with a knife", in `Bake_018`, bottom right. Both mechanisms are D-62's, and D-62 missed them
because it was verified on region 8, which two traces reach.
**Faults stacked.** `FaultDelta` accumulates with `+=`. That was harmless at a 600 m reach because two
faults almost never met; at 6 km they meet constantly, and a set is sub-parallel *by construction* -
traces within one cell of the orientation grain share a strike, a belt fault takes its from the margin -
so where they meet they all push the same way. Region 11 is 22 km across with 13 traces at strikes
spanning 14 degrees. Measured on a 200 m grid: **75 % of the faulted ground had two or more faults on
it**, the sum a median **1.77x** the largest single contribution and up to 4.46x, 13 % of it over the
repose ceiling on its own - so the hard clamp fired on **160 289 cells, 4.1 % of the region**, against
0.15 % planet-wide before, and a hard clamp makes plateaus.
Now `softStack`: a soft knee per cell keyed to the **largest single contribution at that cell**. A
planet-wide throw would not bite - the biggest throw on this template is 744 m and the biggest single
contribution in the region is 209 m once taper and falloff have had it. Identity below the knee, so a
lone fault is untouched and D-62's "the step across a fault is its throw" still holds; above it the
excess bends onto 1.6x the knee. Continuous (a max of continuous functions, gradient 1 either side of
the join) and frame-independent, which is what keeps `TestTwoFramesAgreeAboutTheSameFaults` true - and
it runs unconditionally, because skipping it when one trace reached a frame would make a cell's value
depend on which frame asked.
**And the initial relief was reading the finished rate.** `painted.go` scaled the symmetry-breaking
noise by `rate/maxClassRate` with the fault delta in it and no bound, so D-62 took the stamped amplitude
from ~39 m on unfaulted foreland to ~166 m on a 6 km footwall, on a landmass whose whole relief was
221 m. A thousand steps cannot erase initial relief the size of the landscape, so the ridged fBm stopped
breaking symmetry and became the texture - the ribs are 250-300 m, octave five of a 4.2 km ridged fBm.
Pre-fault rate now, bounded at one.
Measured, region 11 re-baked at the same seed and steps, `Bake_018` -> `Bake_D63`: max **221.4 ->
115.6 m**, repose-clamped cells **160 289 -> 2 865**, median slope **6.26 -> 1.79 deg**, slope-area
exponent **-0.938 -> -0.599** (theory -0.500), drainage 0.58 -> 0.60 /km, stacking median **1.77 ->
1.50** and p90 2.87 -> 1.60. The corduroy is gone. The wall time went 1m34s -> 12m56s and that is the
right direction: the old run was cheap because unbounded negative stacking had driven whole aprons to
zero uplift, and dead flat ground costs one hillslope sub-step instead of twenty-four.
Not fixed: the residual ribbing is Terrain-Next 4.B3, which is visible in `Bake_013` too and is now
diagnosed there rather than fixed. `ClampToRepose`'s raster-order facets were checked and ruled out as
the cause - its facets are grid-aligned and these ribs are oblique - and recorded as 4.B4.
- 2026-09-19 - Off the ladder: a fault is a range front rather than a welt (D-62). Raised as "each fault
line makes a rough line of mountains that just doesn't look realistic", and asked as "is it a json setting
that is too low". It was not: the legend says how many faults, how long and how much they throw, and
nothing in any manifest said what shape one is. The shape was `faultSteepM = 200` and `faultGentleM = 2000`
in `painted_faults.go` with a **step** between them - the whole throw one side of the trace, the whole
throw negated the other, one 8 m cell apart. Measured on the designed field: 2 x throw across one cell,
**89 degrees**, inside an upthrown flank that reached zero 600 m out.
Two things are wrong with that and they are the same thing twice. A discontinuity in the *rate* is a
painted cliff - the surface can only put it into a scarp at the angle of repose, so the trace facets at
any throw and turning `throw_m` down just lowers the artefact. And 600 m is **narrower than one
hillslope**: `Bake_013`'s drainage density is 0.45 channels per km, so a divide sits 1.1 km from its
channel, and nothing can cut a valley into a block that width. The uplift profile is therefore printed
onto the surface rather than eroded into a landform, which is exactly what the hillshade shows - a smooth
ruled ridge with no drainage on it at all, running through terrain dissected everywhere else.
Now: an odd saturating ramp across the trace (`d/sqrt(R^2+d^2)`, R = 900 m, about one hillslope) times a
flank envelope with finite support and zero gradient at its edge (`(1-u^2)^2`, 6 km footwall, 4 km hanging
wall). Zero *on* the trace, which is the honest reading - a rate difference across a line says one side
rises relative to the other and the two average to the regional rate at the line. Algebraic rather than
transcendental on purpose: it runs at a few hundred million cells a planet, and the reach is unchanged so
the box is too. `tipTaper` was a flat top over the middle two thirds, which extrudes the cross-section
along most of every trace; it is a bell now. And `throw_m` is normalised to mean the whole step across the
fault rather than a full throw on each side, which is what the old one built - the normaliser is measured
off the profile so changing a width cannot silently change what the legend's number means.
Measured, 400 m throw: steepest cell in the rate field **89 -> 17.4 degrees**, step across the fault 400 m
over 2.66 km (8.5 degrees mean) against 800 m over one cell, footwall above half its crest for 3.7 km
against 0.6 km. Solved for 1000 steps on the same synthetic landscape, old against new: a ruler-straight
cliff with a dead apron and 43 m of relief (45 m with no fault at all) against a dissected range front
with its own valleys at 97 m. `TestAFaultIsSolvableRatherThanPrinted` is the three properties as
assertions - continuous, zero on the trace, and wide enough for three hillslope lengths of footwall.
- 2026-09-18 - Off the ladder: the studio's canvas becomes a GPU texture (D-61). The template is 7738 x 3761
and every pointer event pushed all of it through the 2D canvas: a full-width `putImageData` band, then a
high-quality downsample of the whole image once per repetition across the seam. Neither cost is a function
of what the stroke touched, so a 24 px brush and a 400 px one both measured **about 105 ms an event** at
fit zoom, worst case a 1.4 s p90 at 1:1 from the canvas read-back stall. Now: one WebGL2 texture per sheet,
a stroke uploads only its own rectangle through `UNPACK_ROW_LENGTH` with no copy, drawing is one quad, and
the seam is `REPEAT` rather than a tiling loop - which also fixed the blur at the wrap, where each
repetition's own texture coordinates gave the wrong derivative and so the wrong mip. Measured after on the
same machine: **6.0-6.3 ms flat** at fit zoom with a 400 px brush, at 1:1, at 4x and panning. Input is off
the drawing path (one rAF loop, `getCoalescedEvents` keeps every sub-frame position), mips rebuild at most
ten times a second while the brush is down, both offscreen canvases are gone (230 MB, and the sheets are
encoded only when pushed), and a lost GPU context re-uploads instead of going black. Added: a brush ring in
the brush's colour, eased zoom anchored under the cursor, `f` to fit, `1` for 1:1, space to drag, rendering
at device pixels, and shortcuts that stay out of input fields - `o` had been swapping the sheet under a
half-written number. Verified live over the DevTools protocol against the running studio: sheet and GPU
agree on a painted pixel, a seam stroke uploads as two rectangles and both ends take the colour, all 200
control columns and both sides of the wrap map exactly at 1:1, the eraser restores alpha, plan round-trips
both sheets, and no GL error on any path. The 12.1 % of rows that disagree across the seam are the
painting's own, which `plan`'s SEAM check already reports.
**And `ctrl+z` takes back a stroke** (`ctrl+shift+z` or `ctrl+y` puts it back). Same constraint, same
answer: a sheet is 116 MB so a stack of snapshots is not a stack, and the unit is one stroke rather than
one frame. A step is copy-on-write over a 256 px tile grid - a tile is kept the first time a stroke writes
into it - so a dab costs one tile and 256 KB, a 60 px drag two and 512 KB, a 400 px brush dragged 800 px
42 tiles and 9.1 MB, against a 192 MB cap that bounds both stacks. `keepTiles` sits at the top of `stamp`,
the only writer, so nothing can be painted that undo has not recorded; undo and redo are one swap in
opposite directions. **The test caught a bug reading would not have:** 7738 is not a multiple of 256, so
the tile grid does not line up with itself across the seam, and cutting the stamp's unwrapped rectangle
into tiles before wrapping the indices kept columns 30 and 0 for a brush that had also written into 29 -
every sampled pixel restored, only a whole-sheet hash disagreed. Wrap into runs first, then cut, which is
`pushRect`'s own order. Verified live: bit-for-bit restore of sheet and texture, four undo/redo cycles
exact, four strokes back in order, a seam stroke whole again, one sheet's undo leaving the other alone, the
eraser undone back to its mark, a new stroke dropping the redo future, mid-stroke ctrl+z ignored and the
stroke still undoable after, and ctrl+z in a text field still the field's own.
- 2026-09-18 - Off the ladder: a planet has a shore (D-60). `internal/coast` ran on a flat grid, so a bake
laid the painted sea floor and stopped - no shelf, no surf platform, no beach, no sediment, no exposure
anywhere. Four primitives to wrap, three of them one loop: `boxBlur`'s running sum, `fetch`'s ray march,
`shelfWidth`'s inland march, and the distance-field gradient both marches take their direction from.
**The abyss is a field now**, because a painted planet's sea classes carry their own depths and a derived
slope bottoming out at one global number would step to the painting wherever they disagreed. It also fixes
the shallow case honestly: the break cannot be deeper than the water it is a break in, so a strait painted
at 20 m against a 30 m break is shelf all the way across rather than a trench.
**Memory.** `Geometry.Ref` holds a waterline *slot* rather than a cell index, so the sediment supply is a
few hundred thousand entries instead of 76 million - 608 MB gone. `Measure` holds one distance transform at
a time instead of two: the waterline comes straight off the mask, and a sea cell's shore is the shore its
nearest land cell already found, so the second pass reads `Ref` rather than the first pass's index.
`boxMean`'s coverage is separable (`cx(x)*cy(y)`, exactly, any pass count) so it is two vectors rather than
a field plus a second blur, and the before snapshot is taken into the change map and subtracted in place.
**A latent NaN fell out of the last of those.** `deposit` does `math.Pow(1-exposure, 1.5)`, so an exposure
over 1 by 5e-5 - which the new float64 divisor produced where the float32 one had not - is NaN, and one NaN
spreads through the drift kernel into the whole budget. 1720 cells of a 200x40 test. Clamped at the point
of use; relying on a smoother a hundred lines away to bound its output is not an invariant.
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 (worst 523 m) to **0.32 m**, which is what an ordinary interior column is - checked against six of
them. What is left is the template's own 9.4 % wrap disagreement, which is an author's to fix. The pass is
now the memory peak of a bake at about 8.3 GB working set; the solve's was 3.6.
- 2026-09-18 - Off the ladder: a planet can be judged now (D-59). `internal/stats` sorted a copy of every
land cell in four different places, so a planet bake printed its elevation range and nothing else - no
slopes, no per-uplift-class breakdown, no drainage density, no slope-area fit. It cost twice today alone:
the lowland slope distribution and the fault scarp measurement both had to be taken by hand in Python off a
PNG, because the tool could not answer questions about its own output.
**Fixed-bin histograms replace all of it**, and the property that matters is not the speed but that they
**add**: two regions' bins summed and quantiled give exactly what one pass over both would. A median of
medians would not. Each region accumulates while its own grid is alive - the composited planet has no uplift
field or flow topology left to recover them from - and they merge in *region order*, because the bins are
integers but the sums are floats and float addition is not associative.
**Extent and ground are measured in different places on purpose.** Regions carry overlapping ocean margins,
so pooling "cells" double-counts water and reports a meaningless land fraction; `AddExtent` runs once on the
composited cylinder, `Add` runs per region over the land that region owns. Found the consequence by running
a partial bake: drainage density came out an order of magnitude low because it divided three islands' worth
of channels by a planet's worth of land. It divides by the land actually walked now and says PARTIAL.
`field.SlidingMin` and `field.LocalRelief` join `SlidingMax`, so local relief stops being 1.1e11 comparisons
at a 500 m window on 28 M cells. Measured: **1.09 s for a 9 M cell region**, ~120 ns a cell, a few seconds
for a planet at the end of a two-hour bake. `generate` and `bake` share the code now, so their numbers are
comparable - which they were not before, and nobody had noticed because one of them printed none.
- 2026-09-18 - Off the ladder: the seed re-rolls what the painting does not fix (D-58). Asked for as "different
faultlines and stuff". Half of it already worked and was worth measuring before building anything: seed 7
against 9342 on one painting moves **13.6 %** of the uplift map, because the massifs, the swell, the initial
relief, the crests and the coast jitter are all seeded. The other half did not exist - **faults and lithology
were procedural-path only** - so `map_erodibility.png` was a recolour of `map_class.png` and two seeds
differed on it only where the coastline had moved. It is 24.9 % now.
Neither ported as it stood. The old lithology takes `f.Percentile()` of the grid it is given and the old
fault centres are `Float()` pairs read as fractions of it: both are exactly what D-53's per-landmass
decomposition forbids. The rock cut is a quantile of the **planet** now, from the probe the massif fabric
already uses (`measureFabric` went generic), and the fault set is drawn once in world metres with each region
filtering to the traces that reach its frame. A class says `faults: {per_1000km2, throw_m, length_km}` and
`lithology_mix`, so an author says which ground is faulted and which rock shows through.
Wrote the faults fresh rather than porting: 4.A2's four defects (polygonal parabola, stamped tips, no
en-echelon step, strongest throws flattened by a global clamp) and 4.A3's single strike angle are all fixed
in the new one. The softening of a rock boundary is **pointwise in rank space** and not a blur, because a
blur near a region edge reads cells another decomposition would not have given it.
**One defect found by arithmetic rather than by looking.** A hard cut-off at three gentle lengths leaves 5 %
of the peak - 0.013 mm/yr on a 400 m throw, a fifth of a lowland's entire rate - as a step at a line 6 km
from every fault, which the solve would have carved into a straight scarp nobody placed. Subtracting the
floor and renormalising fixes it and makes the box a real optimisation: with per-segment boxes hoisted out of
the cell loop, 5.2 s to 1.5 s on a 6.3 M cell region.
`--seed` on plan, bake and tiles (`fs.Visit`, not a sentinel - every sentinel is a seed somebody wants), and
a seed box with a Re-roll button in the studio. Faults cost 1.5 s on a 6.3 M cell region and the rock field
nothing measurable; the trace geometry goes into the bake's meta.json.
**Baked region 15 to check the faults survive the solve** (Bake_008, 12 min): five traces with throws of
139..399 m left scarps of 2.7..50 m and **five of five face the side the fault raises**. A small fraction of
the throw is the right answer - the rivers cut it down about as fast as the rate rebuilds it, which is the
whole reason a fault is a rate and not a painted step. `TestAFaultLeavesAScarpAfterTheSolve` is that in
miniature, nine seconds, because everything in internal/uplift tests the rate field and none of it says the
solve leaves anything behind.
- 2026-09-18 - Off the ladder: a second painting, for everything that is not geology (D-57). Asked for as
"select which coastlines not to give jitter to" and "a layer for forest generation, where a city/town/village
is, spline roads" - two requests with one shape, so one mechanism. `internal/overlay` is a sheet the same
size as the template with a legend of *marks*; blank is **alpha**, not a reserved colour, and an opaque pixel
matching no mark is dropped and counted rather than snapped to the nearest, which is the class legend's rule
inverted on purpose.
**One mark property is read by anything.** `coast_jitter` scales D-56's waterline roughening per pixel: 0
pins a hand-drawn shore exactly as painted, 2.5 makes a fjord coast out of one brush stroke. An *unmarked*
cell is uninstructed rather than 1 and takes its instruction from the far side of the waterline, or a stroke
painted on the land would be overruled by the water beside it. Checked exactly rather than by eye: pinning
every pixel gives a `map_class.png` byte for byte identical to `--coast-jitter 0`.
Everything else is inert. Marks come out as an 8-bit index raster beside every detail tile (indexed, not one
mask each - marks cannot overlap on one painting, so 254 fit in one file) and as features in world metres in
`overlay.json`: areas get a centre, an area, a radius and an extent; paths are thinned to their geodesic
diameter and get an ordered polyline, because what is built from a road on the other side is a spline. A blob
across the seam is one feature with a **circular** centroid; a tile samples through world metres, which is
rule 1 for a raster. A fork is one path and loses its third arm - stated, and `plan` prints the piece count.
The studio grew a second tab (`o`), the marks as brushes, an eraser, and the sheet composited over the
painting at full strength while you are on it and dimmed while you are not.
**And the class table stopped lying.** It printed the divide angle - the *steepest* ground a rate can make -
as if it were the landscape. Measured on 600² of 8 m cells at 1000 steps with the manifest's own numbers:
median slope is a third of the divide angle in tangent, and the ratio is flat (0.34, 0.33, 0.33, 0.32) across
0.012 to 0.250 mm/yr; P90 drifts 0.59 to 0.40. Both columns print now and `reads as` comes from the median,
so `highland` is hill country at 11.7° rather than alpine at 32°. D-55's defect one level up.
**And the picture lies the same way, which is where the complaint actually came from.** Baked the real
continent to settle it - region 12, 45.9 x 19.8 km, 9.0 M land cells, 27 min - and it is **0..47 m** with
a median slope of **0.61°**, 4.4 % over three degrees and nothing over eight. A plain. What made it look
like an alpine massif is that `preview.png`'s hypsometric ramp tops out at a *percentile of the world
being drawn*, so green-to-snow was stretched over its 32 m and the 40 m hills got white caps. Redrawn at
a fixed 400 m ceiling it is flat green with four pale massifs. `palette.land_top_m` is that ceiling, and
every run now prints which one its preview used. The percentile stays the default - absolute over a
world with no mountains is a green shape with nothing legible on it - so what was added is the
sentence, not the option.
- 2026-09-17 - Off the ladder: the coastline stops being a drawn line, and the template gets a tool (D-56).
**`coast_jitter_px` was in the manifest, documented and defaulted, and nothing read it** - three grep hits,
all in manifest.go - so every painted shore went to the solve exactly as drawn. It is now a mask on the
signed distance to the waterline: add fractal noise to the distance, re-read the sign, and land juts out
where it is positive and the sea reaches in where it is negative. A domain warp was built first and binned:
a smooth warp of a smooth boundary cannot cut a bay. A cell that changes sides takes its class from the
distance transform's nearest-feature index; the amplitude is capped per cell at two thirds of the widest
land within reach, or a wavelength bigger than an islet takes the islet whole - measured, 2 of 12 gone
without the guard, and `field.SlidingMax` (monotonic deque, O(1) a cell) exists because the naive window is
seven billion comparisons at planet scale.
**The mask exposed a classifier bug and made it load-bearing.** A JPEG blend of surf and lowland is
(186,219,174), which is 53.8 from `desert` and 77.9 from either parent - so every temperate coast carried a
1 px ribbon of desert, 1607 px of it, and the mask made those strays the nearest *land* to open water and
gave their class to an 11 px band of new shore. The colorimetric fix was built, measured and thrown away:
it moved 943 000 real `shelf` pixels, because `shelf` sits 10 units off the ocean-surf line. The spatial one
works - 5x5 majority, kills a 1 px ribbon, leaves a 2 px band - and moves 0.068 % of the map, 1607 -> 0.
**`terrain studio`**: a painting tool on loopback where the brushes are the legend's classes and the panel
shows the divide angle each rate buys as you type it. Hard-edged exact colours only (an antialiased brush
manufactures the blend the despeckle pass exists to remove), the canvas wraps at the seam, `plan` is a
button (7.5 s round trip), and it saves by patching the *text* of the legend and manifest so their
commentary survives - `MarshalIndent` over a map returns the file alphabetised.
Then two notes from using it. **Saves are versioned and never overwrite** - `Map3_001.png`, `Map3_002.png`,
as `Bake_NNN` already is, with the manifest repointed; the base map is a hand-made input with no undo
outside the process, and writing back over a JPEG would recreate the blend artefacts on every save.
**And plan is cached**: 7.0 s cold, **0.37 s** when only a legend number changed, because the
classification, despeckle, coast mask, projection and region cuts depend on the painting and the class
*colours* alone. Low-res maps were the obvious guess for making it quick and they do nothing - prepare is
flat at 6.5 s from a 400 px map to 2400 - because the cost is `region.Build` at 2.68 s on the 76 M planet
grid (classify 0.09, strokes 0.93, despeckle 1.52, coast 1.62, project 0.05). The four maps have keys now,
served from the code that drew them rather than reimplemented in the browser.
**And a Bake button that can be watched.** `BakeOptions.OnRegion` fires as each region's land is
composited, holding a new composite lock so the hook can read the whole planet without racing the workers
still solving; the studio draws a preview there, so the world fills in a landmass at a time and the first
continent out answers "is this what I meant" an hour before the last one. Zero-copy views over
`res.Height`/`res.Flow` rather than `Painted()`, which allocates 300 MB a call. `fluvial.Grid.SetCancel`
checks once a step, so a bake can be stopped - and a cancelled run is **written** to `Partial_NNN`, not
thrown away: the solve is per landmass, so a region that finished is finished, and losing fifteen of
eighteen because the last three were slow is not what a cancel button should do. Outside the `Bake_NNN`
namespace on purpose, since `tiles --bake` takes the newest of those by default.
- 2026-09-17 - Off the ladder: a painted class becomes two rates and a fraction (D-55). The first planet's
landmasses came out uniformly dissected - every divide on a continent at the same angle, coast to summit,
no flat ground anywhere - and that is D-49 read one step further: a class was one rate, n is 1, so one
class was one landscape. `lowland` 0.08 mm/yr is 11.3 degrees on every divide it touches. Now
`massif: {floor_mm_yr, fraction}` per class, cutting **one** upland fabric for the whole planet
(`planet.massif_wavelength_km`, 7 km against landmasses of 20-45 km; 12.5 put a whole island above the
cut). The threshold is a quantile of the *planet*, never of the region - a percentile of the grid is
exactly what `FromTemplate` exists not to do, and two regions would have disagreed along every boundary -
so it is a fixed 1024-column probe binned into a histogram, ~10 ms, identical everywhere by construction;
the ramp is cut in probability, so `fraction` means what it says whatever the noise's distribution is.
`map_uplift.png` had to learn the fabric too, at the image's resolution, or the one diagnostic that would
show this would have gone on drawing a flat continent. Legend retuned: lowland 0.08 over a sixth with a
0.012 plain, highland 0.25 over three tenths with a 0.045 foreland, desert 0.10 over a seventh with a
0.015 sand sea. Also corrected the misreading that set the old numbers - `internal/stats` calling under
0.1 mm/yr "plain" is a reporting bucket, not terrain, and 0.1 is a 14 degree hillslope - so `terrain plan`
prints what a class reads as from its angle, and `coastal_floor_mm_yr` defaults to 0.02 not 0.06.
Five tests in `internal/uplift`, and the first version of the fraction test measured nothing: the test
planet was narrower than the probe, so both sampled the identical grid and every number came out exact.
Measured on Bake_004 against Bake_001/003, same region boxes: lowland continent median slope 3.7 -> 0.7
deg and 7% -> 93% of it under 2 deg; highland island 6.7 -> 2.0 deg and 4% -> 49%, keeping a p90 of 8.9
and a 167 m core. Peak elevation fell 71 -> 41 m on the lowland although the massifs still reach the same
rate, because relief is the integral of slope along the whole flow path - so **max elevation cannot see
this change** and nearly reported it as a regression. `map_flow.png` point-samples every fourth cell at
3000 px on a 12500 grid, so channels read as disconnected stubs and only the divides survive: it looks
like broken drainage and is not, and Bake_001 shows the identical pattern.
- 2026-09-17 - Off the ladder, three things the legend could not say (D-54), all three from looking at the
first whole planet rather than from the plan. **coastal_plain_km**: for n=1 the uplift rate alone fixes the
hillslope angle, so a uniformly painted island is at the angle of repose right down to the water - fjords
end to end. The rate now ramps from a floor at the waterline up to the class rate over a stated distance,
smoothstepped so the plain meets the range without a crease. This is D-52 read carefully, not reversed:
that removed a *hidden* taper that went to zero and flattened the strip the surf works in; this is opt-in
and the waterline keeps a real rate. **A crater cannot be an uplift rate** - the priority-flood raises every
depression to its spill level every step, so a basin built from negative uplift is filled within a hundred
steps, and an impact is an event rather than a rate anyway. It is stamped on the finished terrain after the
solve, shape derived from the painted blob: distance in from its own boundary normalised by its widest
point, so four numbers describe every crater whatever size it was drawn, and one across the seam is one
crater. **A desert is not a low uplift rate** - a wet lowland has one too, and at the geology grid the
only lever is k_mult. A class can now override what the *detail* passes do on it (droplets_per_cell,
strata_contrast, amplitude_m), which is where the difference actually lives: measured over identical
terrain, 43634 droplets moving 84 km of material against 2148 moving 4.1 km - a dendritic gully network
against a few isolated wadis. **snow** is a display and material hint no pass reads: the hypsometric ramp
tops out by elevation, so a polar cap fifty metres above the water was coming out the same green as a
meadow. **And the thing chasing "too mountainous" turned up:** the mountains were flat polygonal facets -
the repose clamp doing *all* the shaping, because highland at 0.9 mm/yr is 66 degrees at a divide against a
35 degree repose angle. The ceiling is U = tan(talus)*K*cell = 0.280 mm/yr at an 8 m cell; `terrain plan`
prints the angle per class now. **Did not work, in order:** blamed the strata hardness (turned it off,
nothing changed), then the tile hillshade (`WriteThumbnail` saturates to black and white at 2 m a cell - a
real bug, now a proper DEM hillshade, but not the cause). What settled it was `tiles --no-detail`, which
writes the geology upsampled and nothing else, so "the solve or the detail passes" is a twelve-second
question. Bakes are versioned Bake_NNN now, because ninety minutes is too long to spend on a change you
cannot then compare. **And the preview palette is a file**: the ramp, the water, the rivers, the ice and the
light, pointed at from the planet manifest, defaulting to what was hard-coded. Separate from the legend
because the legend is about the world and a palette is about the picture - it changes no height, so two
bakes under two palettes are the same terrain. Written by hand rather than by MarshalIndent, which
re-indents a custom marshaler's output and will not keep a colour on one line.
- 2026-09-17 - Off the ladder, the first whole planet baked: 18 regions, 49 M of 76 M cells, 44m41s of wall
time (8805 s of solve) at 1000 steps with four regions in flight, land 0..741 m, nothing clipped. The
painted classes do exactly what they say - the lowland island is 45 km of 31 m plain, the highland islands
are 741 m of mountain with dendritic drainage to both coasts - which is the legend's numbers to tune rather
than the tool's. **Measured, and it is the input rather than the tool:** the template's left and right edges
are the same meridian and **disagree on 9.4 % of rows, 261 of them land against water**. The crater island
crosses the seam perfectly; islets drawn touching x=0 with nothing to meet them at x=W-1 do not, and a JPEG
halo two pixels wide on the outermost columns classifies as shelf and puts a 400 m ledge down the whole
height of the map. `terrain plan` measures the wrap now, 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. **Also measured:**
cost per cell varies eighteen-fold with the painted uplift rate (72 s per million cells at 0.08 mm/yr, 350 at
0.9, 1278 at 1.6), because the nonlinear hillslope sub-steps up to its budget of 24 on steep ground and once
per step on a plain - so raising an uplift rate changes the bake time as much as it changes the terrain, and
the wall time is set by the slowest single region rather than by the total.
- 2026-09-17 - Off the ladder, the detail passes and tiles (D-53 continued). Passes 8 to 12 and 14 in
`internal/detail` and `internal/tile`, ported from `heightmap_erosion.py` with every brake by name, plus
`terrain tiles`: 5 km tiles of 2500 samples at 2 m, about twelve seconds each, heightmap plus hillshade plus
flow, wear and deposit. **The thing worth remembering:** the tile margin is *measured*, not reasoned. Taken
literally it would be `rounds x lifetime`, 640 cells against a 2500-cell tile; measured against the same
ground in one whole run it is 7.97 m of difference at the cut edge, 0.72 at eight cells, 0.03 at 24 and zero
by 32, so three lifetimes plus the brush is the margin - 122 cells, five per cent of a tile. At one round the
match is bit-for-bit, which is the test. **Did not work, and none of it would have failed loudly:** the
droplets' round count derived from the droplet total, so a droplet landed in a different round in a tile than
in the whole map and the seams never closed (it is a manifest number now); the derivative maps normalised by
a percentile of the tile, which is a statistic of the piece being looked at and the same mistake the coastal
exposure had already had withdrawn; the detail noise on the world period, which wants a 12500-squared lattice
and a gigabyte and a half for one octave (a kilometre period instead, and what repeats has no shape); the sea
left in place through the upsample, which rings at every coast and lets thermal pour the shore into the water
- the mirror image of the first coast run eroding land to 174 m below sea level; and the droplet stencils
straddling the waterline, quietly deleting the sediment that should have built a beach. **Also:** the
per-band scatter was partitioned by core count, and floating-point addition is not associative, so
determinism failed by one ulp - `field.FixedBands` is a partition fixed by the grid rather than the machine.
- 2026-09-17 - Off the ladder, painted planets (D-53). The generator's source stops being a seed: a hand-painted
flat cylindrical world map plus a JSON legend saying what each colour means in uplift mm/yr and erodibility,
and the simulation makes the terrain. X wraps, Y does not. New `internal/world` (the cylinder and the frame),
`internal/dt` (the exact distance transform moved out of `coast` and given a cylinder), `internal/template`
(image, legend, classifier), `internal/region` (the partition) and `internal/planet` (the driver), plus
`terrain plan` and `terrain bake`. First template: 7738x3761, nine classes, 100 km round at the 8 m geology
cell = 12500 x 6076, 18 regions, 49 M cells of 76 M, largest 14 M at 0.8 GB, about two hours at 1000 steps.
**The thing worth remembering:** the fluvial solve cannot be tiled but it *can* be decomposed per landmass,
exactly, because ocean cells are fixed at sea level and nothing in the solve can move them, so no flow path
crosses open water - `TestOceanCellsAreUntouchedByTheSolve` now asserts that premise directly. The coastal
pass is deliberately *not* decomposed: it 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 whose conservation
is the one thing in it not derived from something already measured. Decompose the solve, not the map.
**Did not work:** clustering landmasses by overlapping dilated bounding boxes - transitively closed, and one
70 km landmass collapses a 100 km planet into a single region; dilating the mask itself and
connected-componenting it is exact, wrap-aware and the same code the stroke fill needs. Also rejected:
hanging a world origin on `field.Field`, because a Field is used for masks and scratch that have no position
and would all quietly claim to sit at the origin. **Also:** the router jitter moved from a hash of the grid
index to a hash of the world position (rule 1 of the tiling plan), done first and on its own because it
re-baselines every measured number on the square canvas; and every image writer turned out to be silently
square, which is invisible at 1:1 and squashes a 2:1 planet.
- 2026-09-17 - Off the ladder, the coast, part two: the uplift field stops being multiplied by the continent
mask (D-52), so a range that reaches the water rises at range rates right up to the waterline instead of
being tapered to nothing across the shore. Surf cut 23.3 → 38.9 Mm3 on seed 7 and 22.4 → 30.4 on seed 9342;
+222
View File
@@ -0,0 +1,222 @@
# Dressing the world: substances, sky, light and things that grow
A plan, not a specification. It covers what has to happen for `L_World` to read as deserts, coasts, tropics and
craters rather than as one grass-and-rock material stretched over 2549 km², and for there to be a sky and trees
worth looking at.
[`World-Pipeline.md`](World-Pipeline.md) is how the ground gets made and imported; this is what it wears
afterwards. Everything here is **off the ladder** (D-47) and blocks no step in [`Steps.md`](Steps.md).
---
## What we already have, which is more than it looks
The single most important fact: **the biomes are already painted.** `Map5.legend.json` is not a set of
erodibility numbers that happen to have names — it is a per-pixel classification of the planet.
| Class | RGB | Is |
| --- | --- | --- |
| `ocean` / `deep` | 91,175,185 / 66,165,180 | sea, two depths |
| `ice` | 250,250,250 | ice cap |
| `lowland` | 150,200,105 | plains |
| `highland` | 71,175,100 | hill and mountain country |
| `desert` | 226,215,145 | arid |
| `crater` | 124,116,111 | impact ground |
That map exists at planet resolution in three forms already registered to the world by the same normalised
`u,v` as everything else: `Plan/map_class.png`, `Bake_NNN/map_class.png`, and
`Orogen Gens/orogen-painted-class-7945.png`. There is also `orogen-climate-7945.png`, Orogen's own climate
bands, which is where **tropical** comes from — it is the one biome you asked for that is *not* a painted class.
The overlay legend already carries `forest` (generation enabled), plus `city`/`town`/`village` and
`road`/`track`. `terrain overlay` and the studio's **Generate marks** button already place them.
And what we do **not** have:
| | |
| --- | --- |
| Substances | **Three.** `Base_Layer` (rock), `Layer_02` (meadow grass), `Layer_03` (high rock) — all from Elite_RockyMeadows, collected into `Content/Terrain/` (D-69a). No sand, no beach, no jungle floor, no ice, no crater regolith. |
| Trees | **None, in any pack.** HouseForge has one mushroom and a cover-plant material; Medieval_Weapons and RPGEnvironmentVFX have no ground cover at all. |
| Grass | **None.** `M_Ground_Landscape` has no `LandscapeGrassOutput` node, so nothing grows anywhere. |
| Sky | Elite_RockyMeadows' skybox mesh, sun with cloud shadows, sky light, exponential fog — scaled for world size in `rocky_meadows.py`. Serviceable; not authored. |
| Biome weightmaps | **Not carried.** `region_manifest.LAYER_SUFFIXES` is a fixed dict of three, and `Region.json`'s `layers` block derives them from slope and altitude alone. |
---
## Five decisions that gate the work
**All five are now taken** (D-74); the reasoning is kept because it is why the phases are shaped as they are.
The two that were a genuine fork went: **dress Route A now** rather than wiring Route C first, and **Fab/Quixel
Megascans** for the substances and the trees.
**1. Where the substances come from. `[DECIDED]` Fab/Quixel Megascans.** Free for Unreal use and has every
surface on the list — sand, shingle, jungle litter, regolith, ice — plus trees for Phase 5. Each goes into
`RawContent/Terrain/ground.json` exactly as the Rocky Meadows set did, so `collect_terrain_assets.py` keeps
owning `Content/Terrain/` and no pack is ever modified. **This is the one thing the plan needs from a person:**
the assets have to be added to the project from Fab before Phase 2 can start. Phase 1 does not wait on them.
**2. The layer budget. `[DECIDED]` eight layers.** This is the real constraint and it is not obvious. Unreal packs **four paint layers per
weightmap texture, per component**. We have three, so one texture each across **9800 components**. A fourth is
free; the fifth doubles it. The shader cost is linear too — every layer is a full material function sampled and
blended whether or not it contributes.
So the honest budget is **six to eight layers**, and that is the whole design:
| Layer | Rule | Substance |
| --- | --- | --- |
| `Base_Layer` | slope | **Layered_Rock_Cliff** (D-76) |
| `Layer_03` | altitude | the pack's high rock |
| `Layer_02` | remainder | the pack's meadow grass |
| `Beach` | near sea level, low slope | **Thai_Beach_Sand** |
| `Sand` | class `desert` | **Bright_Desert_Sand** |
| `Ice` | class `ice` | **Snow** |
| `Regolith` | class `crater` | **Desert_Western_Ground_Gravel_Coarse_04** |
**Seven, with one spare in the budget.** A `Jungle` layer reading Köppen `Af`+`Am`+`Aw` was specified and then
dropped (D-76): the nearest substance to hand is a mossy rocky ground, which is temperate and damp where a
tropical forest floor is leaf litter, and the wrong green over the whole equator is more misleading than no
biome at all. The equator wears the remainder layer instead — generic, but not wrong. Everything needed to
bring it back is still in place; it is one manifest entry and one substance.
**3. Route A or Route C first. `[DECIDED]` Route A, now.** Today's ground came from Orogen with **no erosion
pass**, so there is no wear, flow or deposit map to read — which is why the paint rules are slope and altitude
alone. `terrain tiles` already produces those maps at 2 m. Dressing Route A means sand where the *class* says
desert; dressing Route C means sand where sediment actually accumulated, and beaches where the coast pass
actually laid them.
Route C is better ground and is the point of the generator, but it is a whole world rebuild in front of the
dressing rather than after it, with nothing to look at until it finishes. Route A gives a visible result in
Phase 1 and **nothing authored for it is wasted**: when Route C lands it changes the *inputs* to the layer
rules, not the material, the sky, the grass or the trees. The two costs accepted are that beaches are
approximated from altitude and slope rather than taken from where the coast pass put them, and that no rule can
read sediment or wear.
**4. Whether trees need collision. `[DECIDED]` no, for now.** `LandscapeGrassOutput` is the only thing that scales to 2549 km² without
authoring actors — GPU-instanced, distance-culled, driven straight off the layer weights we are about to add,
and it costs nothing to author once the material has the node. But grass-output instances **have no collision**.
Trees you can walk into mean PCG or foliage actors, which is a different and much larger job at this scale.
Recommendation for a prototype: grass output for everything including trees, and revisit when a step needs to
collide with one.
**5. One sky or many. `[DECIDED]` one.** It is one planet, so it is one sun, one sky light and one skybox. "Desert lighting" and
"tropical lighting" as distinct looks means per-region post-process volumes, which at 98 landscapes is a lot of
actors to place and keep in step. Recommendation: one authored sky, and let the *ground* carry the biome.
---
## The plan
Each phase says what exists afterwards and what proves it. They are ordered so that nothing waits on anything
later.
### Phase 1 · Carry the biome into the tiles — **done**
The class map and the climate map become per-vertex layer weights, sampled at the same global coordinates as the
height, so they are seam-exact for the same reason it is.
- `Region.json`'s `layers` block gained `biomes` (the two sources and the blend width) and `paint`, an ordered
list of layers each with a rule: `slope`, `altitude`, `beach`, `class`, `climate` or `remainder`.
- `region_manifest.py` models the list; `LAYER_SUFFIXES` survives as what a manifest with no `paint` block means.
- **`mapart biomes`** classifies the two sources and writes one blurred greyscale mask per biome.
- `generate_region_tiles.py` samples the masks and composes every enabled layer, `--all-layers` to preview the
ones nothing can import yet.
**Three things it turned up.**
**The class source is the painting, not Orogen's class render.** The render is the legend's colours
double-encoded to sRGB, which puts exported `desert` nearer to legend `ice` than to legend `desert` — nearest
colour matching against it is simply wrong. The painting is made of the legend's own colours and nothing else:
measured, worst nearest-colour distance **0.0** over all 29 M pixels. One is data; the other is a picture of data.
**Tropical is Köppen, not latitude.** `Tools/Orogen/js/koppen.js` has a real classification with exact colours,
and the climate export decodes against it cleanly once the same sRGB encode is applied — every observed colour
within **1.4/255**, and only **0.423 %** of pixels not close to any class, which is the anti-aliased ring at
class boundaries and nothing else. Jungle is `Af`+`Am`+`Aw`, 8.11 % of the planet.
**The registration is measured, not assumed.** The painting is 7738 × 3761 and the heightmap 8192 × 4096; the
two candidate mappings were tested against each other on land/sea agreement and identity won, **98.09 % against
96.14 %**, in every latitude band including the polar ones where a vertical scale error shows first.
**Proved by:** seams **0 differing vertices** across all nine files on a shared column; weights **255..256 and
never under**, so no ground is unpainted; and the default three-layer path still produces the built world —
height, rock and high rock **bit-identical**, meadow differing in **6 of 6 507 601** vertices by 1, which is the
deliberate rounding change and nothing else.
**Left for Phase 2:** `rocky_meadows.LAYER_INFOS` still knows only three layers and now refuses loudly rather
than dropping one silently, which is the hook the substances plug into. Underwater ground gets the remainder
layer, because no rule claims the sea floor; the sea plane covers it, so it costs nothing but it does inflate
the meadow's share in any per-tile number. And the beach rule wants tuning against real coastline once there is
a sand substance to see — it lands at 0.1–0.2 % of a coastal tile today.
### Phase 2 · The project's own landscape material — **all but the layer infos**
Built by `Scripts/Authoring/build_ground_material.py`, which **extends** the pack's material rather than
replacing it: a new layer is a copy of an existing layer function with its six parameters renamed, so it
inherits the camera-distance colour blend D-69a found load-bearing at this scale. Verified: the
`LandscapeLayerBlend` carries seven wired layers, the new functions' parameters are uniquely prefixed so they
do not collide in the instance, and twenty texture parameters point the layers at the Fab substances.
The layer infos are done too. `LayerName` is read-only from Python and there is no LayerInfo factory in the
bindings - duplicate-and-rename produces an asset that silently keeps the name it was copied from - so it goes
through `ULandscapeAuthoringLibrary::CreateLayerInfo`, in the editor module for the same reason
`CreateLandscapeFromHeightmap` is. Verified per layer: layer info, `LayerName`, parameter prefix, substance,
and `weightmap_entries` resolving 7 of 7.
The notes below were the plan and are kept because they are still what the phase is for.
`M_Ground_Landscape` is already our copy (D-69a), so extending it modifies nothing of the pack's.
- Add the five new layers and their material functions, one per substance, following the three that exist.
- Keep the existing distance-blend structure — it is the reason the world does not read as tiling mush from the
air, and `MI_Ground_RockyMeadows`'s far-colour corrections are load-bearing at this scale.
- New layer infos in `Content/Terrain/Layers/`, and the `LayerName` on each must match what the material blends.
**Proves it:** fly the world; deserts are sand, the ice cap is ice, the crater floor is not grass.
### Phase 3 · Sky and light
Authored rather than inherited. One pass, and it is mostly numbers.
- A sky worth having: Sky Atmosphere plus Volumetric Clouds, or keep the pack's skybox mesh if the atmosphere
costs too much at this scale — decide by looking, not in advance.
- Exposure that works **both** standing on the ground and looking down from 25 km, which is the case that has
already bitten twice (the white sea material read as an ice sheet; the fog at demo density was opaque).
- Fog density stays scaled by world size — `scale_fog` already does this and the reason is in `rocky_meadows.py`.
- Sun angle and colour chosen once and written down, because "the sun is wrong" has already cost one commit
(positional `unreal.Rotator` is `(roll, pitch, yaw)`).
**Proves it:** a screenshot from the ground and one from 25 km, both legible, no blown highlights on the sea.
### Phase 4 · Ground cover
- Add a `LandscapeGrassOutput` to the landscape material, fed by the layer weights from Phase 1.
- One `ULandscapeGrassType` per layer that should have cover: grass on lowland, scrub on desert, none on rock,
ice or sea.
- Density and cull distance tuned at this scale, not the demo's.
**Proves it:** ground cover appears where the layer says and stops where it does not, and the frame time at
ground level is still sane.
### Phase 5 · Trees
- Tree meshes from the chosen source (decision 1), as grass types placed by the same output.
- Density driven by the layer weight and, where it exists, by the overlay's `forest` mark.
- Treeline: the overlay generator already takes its treeline from a quantile of the land's own heights, because
metres mean nothing until a world is baked (D-68). Use the same rule here.
**Proves it:** forests are where the overlay says, thin out with altitude, and are absent from desert, ice and
open water.
---
## What this does not cover
- **The overlay carry into Unreal is still unbuilt.** `Region.json` reserves an `overlay` block and
`region_manifest.py` has `marks_path`; nothing writes one and nothing reads one. Phase 5 can work off layer
weights alone without it — that is the cheaper path and the one to take first. Reading actual `forest` blobs
means building the carry.
- **Water.** The sea is `M_Sea_Proto`, a placeholder grey, deliberately. Real water is its own job and the
engine's single-layer water read as a lake shader stretched over a planet.
- **Rivers.** The bake knows where they are (`map_flow.png`); nothing in Unreal does.
- **HLODs.** Needed for any of this to be visible from the air at all, and they have to be rebuilt after the
material changes. See the sculpting note in `Terrain.md` §5.1 before hand-editing anything.
+272
View File
@@ -0,0 +1,272 @@
# The world pipeline: from a painted map to ground in Unreal
**Read this when you want to know what order things happen in.** It is the orientation document for
everything between "an author paints a world map" and "a player stands on it", and it owns no decisions of its
own — every one of them is argued somewhere else and linked from here.
| If you want | Read |
| --- | --- |
| Why the generator exists and how it works | [`Terrain.md`](Terrain.md) — the specification and the decision record |
| What is being worked on right now, what looks wrong | [`Terrain-Next.md`](Terrain-Next.md) — the working brief |
| Every manifest key, explained | `RawContent/World/README.md` and `Scripts/Authoring/region_manifest.py` |
| How to paint a world | `RawContent/World/Templates/README.md` |
| Why a thing is the way it is | [`Decisions.md`](Decisions.md) — D-47 onwards is all world |
All of this is **off the ladder** (D-47). No gameplay code reaches into it and nothing here blocks a step in
[`Steps.md`](Steps.md).
---
## The shape of it
```
RawContent/World/Templates/Map5.png + Map5.legend.json
a painted flat cylindrical world map what each colour means: uplift mm/yr, erodibility
|
| terrain studio paint it, 127.0.0.1:8099
| terrain plan 4 s: cut the planet into regions, solve nothing
v
+----------------+----------------+
| |
terrain bake Tools/Orogen (the browser twin)
~2 h, 8 m geology grid ~12 s, 204K-region sphere mesh
| |
v v
RawContent/World/Bake_NNN/ "Export Map"
planet_height.png RawContent/World/Orogen Gens/
map_flow / class / uplift ... orogen-heightmap-7945.png (8192 x 4096)
overlay.json, meta.json orogen-colormap / satellite / climate ...
| |
| +------------+-------------+
| | |
terrain tiles generate_region_tiles.py Tools/MapArt (Go)
5 km tiles at 2 m cuts the window into tiles `biomes` -> biome masks
| ^ `build` -> 4 map layers
| | |
| | |
X NOT WIRED v v
(this is the RawContent/World/ RawContent/World/MapArt/
future; see RegionTiles/*.png map_relief / colour / satellite / climate
"What is not | |
wired yet") create_region_world.py create_world_map.py
via build_region.sh via build_world_map.sh
| |
v v
Content/Maps/L_World Content/World/Maps/
98 landscapes DA_WorldMap_L_World + 4 textures
71.40 x 35.70 km |
\ /
\________________________/
|
the map view, in game and in the editor
```
Two generators sit side by side and they are **not** interchangeable. The Go tool in `Tools/Terrain/` is the
one that makes ground a player can stand on: an 8 m geology grid, a coastal pass, faults, craters, detail
tiles. World Orogen in `Tools/Orogen/` is a browser twin that reads *the same painting and the same legend* and
solves them on a sphere mesh in about twelve seconds — for looking at a painting's rivers on a globe and tuning
the legend's numbers before committing two hours to a bake. A 44 km cell cannot show a fault or a crater, and
it never will (D-66).
**The ground in `L_World` today came from Orogen, not from the Go tool.** That is a deliberate shortcut: it
put 900 km² of ground in the engine long before the generator's detail passes reach Unreal, and the relief you
see is therefore *art* rather than a solve. `terrain bake` makes that same continent a 116 m plain; the Orogen
export makes it 2972 m (D-69).
---
## The three routes
Only one of these is live. Knowing which is which saves an hour of confusion.
| Route | Status | What it builds | How the tiles are made |
| --- | --- | --- | --- |
| **A. Orogen → Python cutter → Unreal** | **live; this is what `L_World` is** | `Content/Maps/L_World` | `generate_region_tiles.py` cuts a window out of a whole-planet PNG. `Region.json`'s `source.kind` is `planet_map` and its `metres_per_pixel` is **a number somebody chose** |
| **B. Orogen → direct tile export → Unreal** | built, not the one in use (D-71) | the same level | Orogen's **Unreal Landscape…** export renders the tiles itself and writes `Region.generated.json`. `source.kind` is `orogen_render`, and the scale is a *consequence* rather than a guess |
| **C. `terrain bake` → `terrain tiles` → Unreal** | **not wired** | the same level, eventually | This is the point of the whole generator. `terrain tiles` already writes 5 km tiles at 2 m; nothing carries them into Unreal yet |
Route B is strictly better than A on the one thing that matters most — the scale stops being invented — and the
only reason A is what is in the level is that A came first. Route C is where this is going, and when it lands,
`generate_region_tiles.py` is the only thing that changes.
---
## Route A, step by step
This is the sequence that produced what is in the project now.
### 1. Paint the world
```bash
cd Tools/Terrain && go build -o bin/terrain.exe ./cmd/terrain
Tools/Terrain/bin/terrain.exe studio # 127.0.0.1:8099
```
The brushes *are* the legend's classes. The panel prints the hillslope angle each uplift rate buys as you type
it — read the **`typical`** column, not `divide`: almost none of a map is divide, and reading the divide angle
as the landscape is how a legend gets set two or three times too hot (D-59). `o` switches to the overlay sheet,
`ctrl+z` undoes a stroke, and **Re-roll** changes the seed, which moves the massifs, the rock, the faults and
the coastline detail without touching a painted pixel (D-58).
**Paint the uplift, never the height.** A solve handed a painted surface erodes it into something else and
throws the drainage network away, which is the reason the generator exists at all.
### 2. Check the painting before committing to a bake
```bash
Tools/Terrain/bin/terrain.exe plan # four seconds, solves nothing
```
`Plan/map_class.png` and `Plan/map_regions.png` are the two pictures that decide whether a bake is worth
starting. `plan.json` carries the class table.
### 3. Solve it — in the browser first
Serve the repo and open the import page:
```bash
npx serve Tools/Orogen # any static server
# then open /import, choose the Painted Map source, and Load from studio
```
**Load from studio** pulls the painting, the legend and `Planet.json` straight out of the running `terrain
studio` in about four seconds, strokes included (D-67). The studio answers `GET` and `HEAD` only and no
preflight, so no browser tab can ever paint, save, plan or bake.
Twelve seconds later there is a globe with rivers on it. Tune the legend's numbers in the table here, not after
a two-hour bake.
### 4. Export the planet
Press **Export Map**. That writes the whole-planet PNGs into `RawContent/World/Orogen Gens/` — the heightmap
(8192 × 4096, a fixed −5000…6000 m ramp), plus colormap, satellite, climate, landmask and the painted-layer
debug maps.
> **Do not use "Export All" for the heightmap.** Its list is
> `{biome: Satellite, koppen: Climate, landheightmap: Heightmap, landmask: Land Mask}` — the entry *labelled*
> "Heightmap" is the **land** variant, whose `landHeightmapColor` returns black for `elevation <= 0`, so every
> ocean pixel is 0 m. Import that and the sea floor sits flat at exactly sea level: z-fighting with the sea
> plane across 64 % of the world, no shelf and no shore. The file `Region.json` reads is the *absolute*
> heightmap, −5 km to +6 km, which has to be exported as a single layer. Both are 8192 x 4096 greyscale and
> neither says which it is, so the tell is the filename: `orogen-heightmap-*.png` against
> `orogen-land-heightmap-*.png`.
>
> Orogen also numbers each **export**, not each planet. `orogen-colormap-14733759.png` and
> `orogen-heightmap-7945.png` look like two different worlds and are one. Nothing inside a PNG says which
> planet it is, so if you are ever unsure, `cd Tools/MapArt && go run . check` measures land/sea agreement
> against the heightmap — 97.5 % is the same planet, 50 % is not.
### 5. Render the biome masks
The painting's classes and the Köppen climate become one blurred greyscale mask per biome, which is what the
paint layers are built from. Go, because the painting is 29 megapixels of RGB and the engine's Python cannot
decode it; blurred once globally so a tile can read it without carrying a margin the width of the blend.
```bash
cd Tools/MapArt && go run . biomes # a few seconds, into RawContent/World/Biomes/
```
It prints how far the worst pixel was from any legend colour. On the painting that is **0.0** — a painted map
is made of its legend's own colours. A number much above that means the image is a *render* of a
classification rather than the classification itself, which is the difference between data and a picture of
data, and is exactly why Orogen's own class export is not the source here.
### 6. Cut the window into landscape tiles
`RawContent/World/Region.json` is the contract: which level, how many tiles, what a heightmap value means in
metres, and — the number that matters most — `source.metres_per_pixel`, because the export carries no scale of
its own.
```bash
# measure a window and print what it holds, writing nothing. Try scales here, not by rebuilding.
D:/UE_5.8/.../python.exe Scripts/Authoring/generate_region_tiles.py --scout
D:/UE_5.8/.../python.exe Scripts/Authoring/generate_region_tiles.py # ~2 min, 208 MB, untracked
```
### 7. Build the level
```bash
Scripts/Authoring/build_region.sh # the whole grid, a few tiles per process
Scripts/Authoring/build_region.sh --append # add whatever is still missing
```
One process per batch, because a landscape of a hundred components costs about a gigabyte the editor never
gives back: thirty-six tiles in one process reached 14.7 GB by the ninth (D-69).
### 8. Build the map view
```bash
Scripts/Authoring/build_world_map.sh # renders the art, imports it, writes the definition
```
Independent of the level — it reads the same `Orogen Gens/` images and `Region.json`, and writes
`Content/World/Maps/`. See [`Spec/UI.md`](Spec/UI.md) for the map itself; `M` opens it in game, **Window → World
Map** in the editor.
---
## What each artefact is, and whether it is tracked
| Path | What | In git? |
| --- | --- | --- |
| `RawContent/World/Templates/` | The painting, its legend, its plates. **The real source.** | tracked |
| `RawContent/World/Planet.json` | The planet's manifest: circumference, cell size, pipeline constants | tracked |
| `RawContent/World/Orogen Gens/` | Orogen's whole-planet exports | tracked — they came out of a browser session and cannot be regenerated headlessly |
| `RawContent/World/Plan/` | `terrain plan` output | ignored, 4 s to rebuild |
| `RawContent/World/Bake_NNN/` | `terrain bake` output: `planet_height.png`, the data maps, `overlay.json`, `meta.json` | ignored, gigabytes, ~2 h to rebuild |
| `RawContent/World/RegionTiles/` | 98 tile sets: height + 3 weightmaps each | ignored, 208 MB, 2 min to rebuild |
| `RawContent/World/MapArt/` | `layers.json` (tracked) and the rendered map layers (ignored, 6 s) | mixed |
| `Content/Maps/L_World` | The level: 98 landscapes and ~500 external actor packages | tracked, through LFS |
| `Content/World/Maps/` | `DA_WorldMap_L_World` and four map textures | tracked, through LFS |
The rule is: **an input is tracked, a product is not.** The one exception is `Orogen Gens/`, because a browser
session is not something a script can redo.
---
## Traps
Every one of these has cost real time.
- **Never run a level build while the editor holds that level.** `--rebuild` empties the level *first* and
saves *last*, so a lock detected late is indistinguishable from data loss — that is exactly what happened on
2026-09-20, leaving twelve of ninety-eight tiles, all of them the polar ocean strip, so the level opened on
71 km of sea and read as corrupted. Both `build_region.sh` and `create_region_world.py` now probe the file
before anything is destroyed (D-71a).
- **Run the long ones detached.** `create_world.py` and `terrain bake` must never go under a tool timeout. A
killed bake is two hours.
- **A script's arguments go *inside* the quoted `-script=` value.** Anything after it is parsed by the engine
and silently never reaches Python — which looks exactly like a script that ignored its arguments.
- **`create_world.py` empties whatever level it is handed.** This is why the numpy canvas is named
`L_Canvas_Proto` and not `L_World`: a manifest still pointing at the latter would replace 98 landscapes with
a 14 km square on one run, with no prompt (D-72).
- **Tile files are named after the level.** Change `Region.json`'s `level` and all 98 tile sets look missing;
rename the PNGs or 208 MB regenerates.
- **The heights out of Orogen are art.** Fixed −5000…6000 ramp, land normalised to a browser preview's peak
setting. Only `sea_scale` corrects any of it, and only the sea.
- **A freshly opened `L_World` can look empty.** The landscapes are split into world-partition streaming
proxies and none is loaded; what you see is the sky, the fog and the sea plane at Z 0, which from above looks
convincingly like soft terrain. Load a region, or build HLODs.
- **Do not trace for the ground in a commandlet.** Landscape collision is not reliably there, the sea plane's
is, and a trace that hits the sea returns `0.0` rather than failing. Read the heightmap instead.
---
## What is not wired yet
Stated plainly so nobody goes looking for it.
- **Route C.** `terrain tiles` writes 5 km tiles of 2500 samples at 2 m with hillshade, flow, wear and deposit
maps beside each one, and **nothing carries them into Unreal**. This is the path that ends with ground worth
standing on; `generate_region_tiles.py` is the piece that changes.
- **The overlay in Unreal.** `terrain overlay` proposes woodland, settlements and roads, the studio has a
**Generate marks** button, and `overlay.json` carries every feature in world metres. `Region.json` reserves an
`overlay` block and `region_manifest.py` has the slot for a per-tile mark map — **neither is implemented**. No
forest, road or settlement is placed from one. It is left unbuilt rather than written blind because a carry
that has never carried anything is a guess about a file format.
- **Overlay marks on the map view.** They are in whole-cylinder normalised coordinates, exactly like the map
art, so they transfer by the same `u,v`. This is the cheapest useful thing left in the list.
- **Erosion-derived paint layers in the region.** Route A has no erosion pass and therefore no wear, flow or
deposit map, which is why its paint rules are slope and altitude alone. `L_Canvas_Proto` — the legacy numpy
pipeline — is still the only path that carries those maps into Unreal, and that is the only reason it is kept.
- **The coastal detail pass**, which the shelf and shore platform have just unblocked. See `Terrain-Next.md`.