Files
UnrealPrototyping/Docs/Worklog.md
T
2026-09-25 17:02:24 +03:00

1006 lines
98 KiB
Markdown

# Worklog
One line per item, newest at the top of each section. What was done, what worked, what did not. Keep it terse;
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;
high ground now meets the water in the detail crop where it previously had a uniform low fringe in front of
it. New `TestBorderIsAlwaysOcean` in `internal/check`, because the margin that keeps land off the map border
was relying on that same multiplication as a side effect: the border is still all ocean on three seeds, so
there is no frozen rim. **Did not work, and it was mine:** the "mean cliff" statistic measured the drop from
a cell to its seaward neighbour and called it a cliff. That is a gradient - one cell of a 10 m grid at the
angle of repose is 7 m - so it could not exceed 7 whatever the coast did, and it read 2 m on a plain coast
and 3 m on a cliffed one. It is why yesterday's brief claimed there were no sea cliffs anywhere; the
replacement, backshore height at one to two surf reaches inland, shows the *old* build already at 88 m P90.
A cliff is how far you fall, not how steep the first cell is. **Also measured:** the slope-area fit is too
noisy at five or six bins to judge one change on one seed - the same code gives -0.480 on seed 7 and -0.698
on seed 9342 - and 14 to 15 % of every coastline is drawn by the map margin rather than by the noise, which
is a straight line parallel to the map edge and is pre-existing.
- 2026-09-17 - Off the ladder, the coast. `internal/coast`, running after the fluvial solve, on the terrain
the solve produced. An exact signed distance transform with a feature index (Felzenszwalb, two 1-D passes)
is the coordinate everything is written in; on it sit a continental shelf whose width is read off the relief
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 a drift length along the shore and lays it in sheltered shallow water, with river mouths delivering
their own load. `uplift.Result.Bathymetry` is gone: the coast pass owns the sea floor, and ocean cells stay
at sea level for the whole solve. New: `map_exposure.png`, `map_coast.png`, a coast block in `meta.json`
and in the summary, `--no-coast` and six other overrides, nine tests, and the determinism test now covers
the pass. The measured finding was the coastline *outline*: at five octaves its finest feature is 450 m,
which is a blob, and the fetch called the median stretch of coast fully open. Swept, seed 7 at 1400:
shoreline 64/81/96/114 km and median exposure 1.00/0.98/0.84/0.51 at outline gain 0.50/0.58/0.62/0.66. D-51
sets 8 octaves at 0.62. Whole pass 83 ms. **Did not work:** a percentile stretch for exposure (degenerate on
an even coast, and a global statistic two tiles would disagree about); casting fetch in every direction
rather than seaward only (it counts the land behind the shore as shelter, so a straight coast scored more
sheltered than a bay); a cut fraction read as a fraction of the height above the platform (15 % of a 120 m
headland is 18 m, which is not a platform); `dep = blur(supply) * want / blur(want)`, which looks like a
normalised convolution and lost 68 % of the sediment budget - the supply blurred onto land and deep water
where nothing wants it was silently dropped, and the conserving order is to divide by the blurred want
*first*, then blur; and one kernel for both jobs - the sediment needs zero padding to stay symmetric and a
carried value needs edge clamping, and using the mass-preserving one on the shelf width shrank every shelf
near the border to nothing.
- 2026-09-17 - Off the ladder, the plains problem. The lowlands read as shrunken mountains because they were
being uplifted at mountain rates: `S = U/(K*A^m)` applied down to one cell puts a 0.25 mm/yr divide at 28
degrees and the 0.9 mm/yr swell past the 35 degree repose clamp, so the clamp was the surface of the
continent (81 % of land in the >0.5 mm/yr class, the plain class 1 % and all sea cliff). Built
`stats.UpliftBuckets` first, to measure per uplift class rather than map-wide - that is what made it
visible. Then D-49 (intraplate 0.25 to 0.03, swell 0.9 to 0.08, relief lo 50 to 15 m) and D-50
(hash-jittered flood epsilon and D8 tie-breaks, for the flat ground the fix exposed). Plains are now 42 %
of land at 0.8 degrees and 9 m relief; slope-area R2 0.06 to 0.46. Then Roering nonlinear hillslope
diffusion in `internal/fluvial/hillslope.go`, running *after* the repose clamp each step so the clamp's D8
pyramid facets get rounded off: mountains 31.1 to 28.5 degrees and 44 % to 33 % pinned at talus, at 8 %
more runtime. Four tests on it. A run now also writes uplift, erodibility, slope, relief, flow and basin
maps. **Did not work:** `critical_area_m2` 1e4, re-measured after the uplift fix and still failing (plains
back to 7 degrees, 79 % of mountains at the clamp) - it needs a hillslope transport law to pair with;
clamping once at the end of the run instead of in the loop (facets returned). **Still open:** half the land
is still in the mountain uplift class; fault traces and the range grain are still visibly straight lines;
MFD accumulation unbuilt.
- 2026-09-17 — Off the ladder, build-order steps 3 and 4 of [`Terrain.md`](Terrain.md): the Go core exists and
the stream-power solver works. `Tools/Terrain/` (module `salty/terrain`, `Scripts/build-terrain.sh`, binary
and `RawContent/World/Preview/` gitignored): `field` (the one array type, vertex-convention resampling,
Catmull-Rom upsample, greyscale PNG through `image/png`, hillshaded thumbnails, and `field.Rows` as the only
place goroutines are made), `manifest` (World.json over Go defaults, the same height contract as
`world_manifest.py`, and a validator that refuses a resolution the importer would turn into thousands of
components), `noise`, `uplift` (the continent, now producing an uplift *rate* rather than terrain), `fluvial`
(priority-flood, D8 receivers, stack, accumulation, implicit Braun & Willett update, sub-stepped diffusion),
`stats` and two integration tests. **The proof: the analytic check `K*A^m*S^n/U` measures 1.0000 over 822
channel cells, and on the continent the slope-area exponent is -0.498 against -0.500 at R2 0.996 with
drainage density 1.33 /km.** Determinism is asserted across five values of GOMAXPROCS, not assumed. Nothing
in the editor was touched (it was open), so steps 1 and 2 are still first and still open; the numpy pipeline
still builds `L_World` and is unchanged. Three bugs, all of which produced plausible-looking terrain and
none of which could have been seen by looking: slope-area measured with the topographic gradient instead of
the channel gradient read -0.78 where the truth was -0.50; cells at a local minimum were skipped entirely
including their uplift, which froze exactly the basins that differential uplift was forming; and the fix for
that then uplifted the outlets, because a map border is an outlet but is not ocean, so base level rose 2 m a
step and the steady-state ratio read 0.03. The last one was invisible in real runs, where the border *is*
ocean, and only the test caught it.
- 2026-09-17 — Off the ladder, no code: an incoming procedural terrain specification (tectonics, faults,
lithology, stream-power erosion, a Go core, an editor bridge into a Landscape edit layer) reconciled against
the built pipeline in [`Terrain.md`](Terrain.md), and the Go core taken as D-47. What the reconciliation
found: the spec's 7113 canvas is a trap, because 7112 divides by 127 and not by 255, so the importer's own
rule gives 3136 components rather than the spec's 784 — the 4033 mistake again (D-45); 7141 at 200 cm gives
28x28 components of 255 quads with the same 14.28 km side the manifest already has. The spec's Z scale 400
is exactly a 2048 m elevation span, so its canvas is expressible in the manifest's own metres contract, at
the cost of dropping the crests from 2800 m to 1536 m (Q2). The spec's uplift/K parameter ranges and its
elevation budget are not jointly consistent at their corners: steady state puts slope at U/(K*A^m), and the
aggressive corner overshoots 1536 m badly, so U/K is the one relief knob and the clip fraction is the check.
The spec has no coast at all and leaves base level open, where this world has one and a sea plane (Q1).
Lithology (plan view) and strata (vertical) are orthogonal and both kept. The spec's §8 DEM escape hatch is
already built and better specified than the spec's version. Deferred with triggers: the preset gallery
widget, `UTerrainPreset`, river splines, build-zone volumes, any GPU port. The five open questions were put
to the user the same day and all five went the recommended way (D-48): the coast stays and sea level is the
base level, the ceiling drops to 1536 m, the canvas goes to 7141 at 200 cm, and a full run holds the
five-minute bar. Go 1.25 turned out to be installed already, on 16 cores, so the toolchain cost was zero.
Nothing is built; the ladder is untouched.
- 2026-09-17 — Off the ladder, third pass: geological simulation in the generator (D-46). `heightmap_erosion.py`,
numpy only: particle hydraulic erosion in vectorised batches, a coarse pass on the 4x-downsampled map for the
valleys and a fine pass at full resolution for the gullies; mass-conserving thermal weathering at a 35° angle
of repose; strata hardness scaling the erosion; flow, wear, deposition and curvature maps written as PNGs and
used by the layer rules. The uplift gained Worley cellular crest lines through the same domain warp. The
manifest's `erosion` block drives it; `"enabled": false` for a real DEM. Two things the smoke test caught:
droplets stepping together in a batch and crowding one cell run away to infinity without a per-step cap, and
thermal weathering that sheds half the mean excess converges far slower than half the largest excess.
- 2026-09-16 — Off the ladder, second pass: the world's terrain is now a manifest, `RawContent/World/World.json`
(D-44). `world_manifest.py` derives the landscape's Z scale and Z offset from an elevation range in metres so
world Z 0 is sea level; `heightmap_io.py` reads 16-bit PNG and raw r16 with numpy alone and resamples;
`heightmap_noise.py` builds the continent in metres (retuned: 80 % of the land under 15°, ranges on a fifth
of the map); `generate_heightmap.py` takes either the noise or a real heightmap named in the manifest and
derives the three Rocky Meadows layers from the height by slope and altitude, so a real heightmap needs no
weightmaps. `create_world.py` dresses `L_World` with the pack's kit as `dump_level.py` (new, dumps any level's
actors to JSON) read it from `Rocky_Meadows_01`: sun with the cloud-shadow light function, skybox dome, sky
light, height fog, fixed exposure and saturation; plus `World_Sea_Proto`, a plane with the engine's water
material. Resolution moved from 4033 to 4081 so the engine's importer picks 16x16 components of 255 quads
instead of 64x64 of 63 (D-45): the import takes two minutes instead of forty.
- 2026-09-16 — Off the ladder, at the user's request: `L_World`, a world-partitioned ~200 km² landscape from a
seeded heightmap. `Scripts/Authoring/generate_heightmap.py` (numpy, in `Scripts/Authoring/.pylib` via
`bootstrap-pylib.sh`) writes 16-bit height and 8-bit weight PNGs to `RawContent/World/Heightmaps/`;
`create_world.py` rebuilds the level from them through `ULandscapeAuthoringLibrary` in the new `SaltyEditor`
module, because the engine exposes no landscape creation to Python. Elite_RockyMeadows' landscape material and
three layer infos. Swap the terrain by replacing the PNGs and rerunning. Nothing in gameplay references it.
- 2026-09-16 — Step 2: `ITelemetrySink` with null, log and JSON Lines sinks; `FTelemetryEvent`, the envelope and
`Telemetry::ToJsonLine`; `TelemetryEvents` with the four session names; `UTelemetrySubsystem`; `ASaltyGameMode`
minting the session id into `ASaltyGameState`; `bs.TelemetryTest`; the git hash in the build string (D-42);
four Core tests. Proved standalone, and with a headless `-server` plus `-game` client sharing one session id.
- 2026-09-16 — The editor's MCP: the engine's experimental plugin only listens when `bAutoStartServer` is set, so
`.mcp.json` refused until `DefaultEditorPerProjectUserSettings.ini` set it (D-43). Enabled the Editor,
AutomationTest, GameplayTags, ConfigSettings and LiveCoding toolsets; before that the only toolset was skills.
Reconnect with `/mcp` after the editor is up. Live Coding must be closed before a shell build.
- 2026-09-16 — Step 1: `Salty.uproject` (UE 5.8) at the root from the Third Person template, renamed from
`ProjectSomething` with class redirects; `SaltyCore` and `Salty` modules; `SaltyServer.Target.cs` (unbuildable on
the launcher, OD-04); `USaltyAssetManager` calling `InitGlobalData`; `Config/Tags/` with 23 root namespaces;
Gameplay Abilities, CommonUI and Python enabled; `Scripts/run-tests.sh`, `build.sh`, `Authoring/create_gym.py`;
Git LFS attributes; the placeholder test. Template variants kept as reference (D-41). Four Fab packs in `Content/`,
none used yet (D-39).
- 2026-09-15 — `Spec/Stats.md`: one stat block on every body, every outside influence an effect, counters on the
receiver (D-37, D-38). Replaced the movement component's own speed-multiplier map, which was the first strand
of the spaghetti this exists to prevent; enemies and kits now carry `FStatBlockDefaults`; step 5 builds it.
- 2026-09-15 — The documentation set written for Unreal from the two earlier projects' docs: `Design.md`,
`Steps.md` (fifteen rungs, the first six in full), `Ideas.md`, `Decisions.md` (D-01 to D-36, OD-01 to OD-08),
and the eight specs under `Spec/`. No code, no project yet; step 1 is next and waits on OD-01 and OD-02.
## Worked
- (nothing built yet)
## Did not work
- 2026-09-17 — `"fill_every": 50` in the terrain spec, written to protect the five-minute budget, silently
destroyed the stream-power solve. Uplift reaches 5 mm/yr, which at dt 1500 is 7.5 m a step, so fifty steps
is up to 375 m of differential uplift between floods: basins close and sit unrouted while everything above
them stops eroding. Measured at 512² over 3000 steps the fitted exponent goes -0.500 (R2 0.992) at every
step, -0.277 at every fifth, -0.121 at every tenth, noise beyond. Even every fifth step is broken, and the
saving was 93 s against 38 s. The budget came back from the step count instead: 1000 steps, not the spec's
5000, because at this K the trunk response time is about 45 000 yr and the exponent stops moving after 500.
- 2026-09-17 — The heap in the priority-flood was the wrong data structure. The flood pops in non-decreasing
elevation and never pushes below the current front, which is exactly when a monotone bucket queue is valid;
swapping the binary heap for one took 1.6x off the whole solve at identical output. 22 comparisons and as
many cache misses per operation, on two thirds of the runtime.
- 2026-09-17 — Relief from the stream-power solve is strongly resolution-dependent: the same seed and uplift
field give 1020 m of land relief at 512² and 2605 m at 1786², because finer grids resolve smaller drainage
areas near the divides and S goes as A^(-m/n). Tuning U/K on a `--size` preview will therefore overshoot the
elevation ceiling at full resolution. Judge shape on the preview; confirm the elevation budget at the real
geology grid, where the clip warning is the check.
- 2026-09-16 — The first `L_World` rebuild of the evening died at twelve minutes with no error: it was run under a
tool with a ten-minute timeout, which killed the commandlet mid-import and left an empty level. Long
commandlets are launched detached (`Start-Process`) and watched through their log.
- 2026-09-16 — A rebuild while the editor had ever loaded `L_World` ended in a nameless `/Temp/Untitled` world
whose save went nowhere: deleting and recreating the level resaves `L_World_HLODLayer_Instanced` and
`_Merged`, and the open editor keeps those two files locked. `create_world.py` now loads and empties an
existing level instead of recreating it, checks the world's package name before building, and sweeps
leftover proxy packages after the save. Check the editor's current level (`SceneTools.get_current_level`
over MCP) before a rebuild; never rebuild the level the editor has open.
- 2026-09-16 — Epic's recommended 4033 resolution gives the engine's importer 64x64 components of 63 quads, because
4032 does not divide by 127 or 255 and the importer prefers one section per component: 4096 components, over
8000 textures to build, forty minutes. 4081 divides by 255: 256 components, two minutes.
- 2026-09-16 — The noise stacked eight octaves at gain 0.5, so every octave was as steep as the last and a third of
the land stood above 50°. Lower gains, no octave finer than about 50 m, a percentile-thresholded range mask
and a 38° thermal pass brought it to 80 % under 15°; measured with a slope histogram, not by eye.
- 2026-09-17 — Cellular (Worley) crest lines at 30 % of the mountain height turned the ranges into a honeycomb of
polygon walls with flat floors in the hillshade. Kept at 12 % through a stronger warp; the ridged noise and the
erosion make the divides.
- 2026-09-17 — The first eroded map had the meadows brushed with rills: land under 15° fell from 64 % to 38 %.
A stage-by-stage slope histogram (uplift, coarse pass, fine pass, thermal) put most of it on the coarse pass:
the slope gate sat at 7°, the median lowland slope, and one droplet could cut 7 m per step at 14 m cells.
Gate at 14°, cut capped at a fifth of a cell per step. Attribute by measurement before turning knobs.
- 2026-09-17 — Spreading droplet deposits through a 3x3 brush built mounds: a pit's rim rises faster than its
floor, the pit never fills, and every droplet draining into it adds its load to the rim. Cuts go through the
brush (no one-cell rills), deposits land on the droplet's own cell, and a droplet's load is capped, since
capacity scales with the drop per step and a cliff hands a droplet a hundred cell-heights.
- 2026-09-16 — The first `L_World` with the manifest pipeline showed rock everywhere: Elite_RockyMeadows' layer
names mislead. Its `Base_Layer` function samples the rock textures, `Layer_02` the grass, `Layer_03` the high
rock, and the meadow weightmap had gone to `Base_Layer`. Read a pack's layer functions before mapping layers.
- 2026-09-16 — The retuned noise overshot into flat land with small hills; the user wanted aggressive ranges.
Ranges now come from an elongated, warped, percentile-thresholded band (about two fifths of the land with
foothills), ridged noise sampled through the same warp, crests to about 2600 m.
- 2026-09-16 — `AActor.set_is_spatially_loaded` does not exist in Python; the property is set with
`set_editor_property("is_spatially_loaded", False)`.
- 2026-09-16 — Positional `unreal.Rotator(a, b, c)` is (roll, pitch, yaw), not (pitch, yaw, roll). Both authoring
scripts pitched the sun 25 to 30 degrees upward, every map rendered as night, and the viewport's "cached lighting
is going to be clipped" warning was the auto exposure at EV -8.5 saying so. Keyword arguments from now on.
- 2026-09-16 — A landscape imported from a commandlet had collision but no visible surface: edit layers are always
on in 5.8, the render heightmaps come from a GPU merge, and a commandlet reports it can never render unless
launched with `-AllowCommandletRendering`. `ULandscapeAuthoringLibrary` now forces the full layer update and
`create_world.py` documents the flag.
- 2026-09-16 — The asset library cannot delete a folder of world-partition streaming proxies (their packages do not
load on their own); `create_world.py` removes the folder on disk instead.
## Open
- **World, next time (2026-09-17, the user's verdict: "still somewhat rough").** Three items, none started:
1. *Sculptable terrain.* Today `create_world.py` empties the level and re-imports the heightmap into a fresh
landscape, so any hand sculpting dies with the next rerun. Wanted: the generated height applied to the
level as its base, with sculpting on top that survives a regeneration. The engine's shape for this is edit
layers: import the PNG into a named `Generated` layer and leave a `Sculpt` layer above it for hand work, then
make a rerun re-import into `Generated` only (`ALandscape` edit-layer API, `FLandscapeEditDataInterface`, or
`ULandscapeEditorObject::ImportHeightmap` per layer) instead of destroying the actor. Needs a C++ change in
`ULandscapeAuthoringLibrary` (a `ReimportHeightmapIntoLayer` beside `CreateLandscapeFromHeightmap`), so a
rebuild of `SaltyEditor` with the editor closed; the weightmaps follow the same path. Check first whether
the proxies' edit-layer data survives `ChangeGridSize`.
2. *Not visible in the editor.* The rebuilt terrain shows in PIE but the user reports it not visible in the
editor viewport. Most likely cause, to verify: the level is world-partitioned and the 256 landscape proxies
are spatially loaded, so the editor loads none of them until a region is loaded in the World Partition
window; only the always-loaded dressing shows. Candidates: World Settings → World Partition → turn
"Enable Streaming" off for now (everything loads, editor and runtime; 256 components is fine for a
prototype), or save loaded regions with the level, or have `create_world.py` load the regions after the
build. Also confirm the editor reloads `L_World` from disk after a rebuild rather than a stale in-memory copy.
3. *No greenery on the floor.* The meadow layer is a flat grass texture; nothing grows. No pack in `Content/`
has a grass mesh (Elite_RockyMeadows ships textures only, HouseForge's foliage folder holds one mushroom
and a cover-plant material). Wanted: landscape grass on the meadow layer (`ULandscapeGrassType` fed from a
`LandscapeGrassOutput` node, which means a child or copy of `M_Landscape_Main` since the pack's material has
none), driven by the erosion's deposit and flow maps once the material samples them, plus a foliage pass for
trees and bushes from a pack still to be chosen. Distance culling matters at 200 km².
- Step 1's last proof is a person's: Play In Editor in `L_Gym`, two players, "Run Dedicated Server" on, two pawns
in the gym. Until it is ticked the step is `◐`.
- The default camera mode (OD-03) is deliberately undecided until step 8.
- The numbers in every spec are the earlier projects' guesses in centimetres and seconds. None has been played.