# Decisions
One line per decision, newest first, with a pointer to where it lives in full. A decision belongs here the moment
it is made, even if the doc it affects has not caught up. Never delete an entry; supersede it with a newer one and
say so.
Status: `Decided` · `Supersedes` (contradicts an earlier decision, names it) · `Deferred` (deliberately not now,
with a trigger)
## 2026-09-20 — How the world gets dressed
| # | Decision | Where |
| --- | --- | --- |
| D-76 | **The Fab downloads are raw source, so they live in RawContent and only a 2K extract is tracked; seven layers, no jungle, and the rock is swapped.** The substances arrived as Quixel `texture-set/high/..._extracted/` folders dropped into `Content/Terrain/Textures/` - 805 MB of 4K JPGs with no `.uasset` among them, which the Content Browser cannot see at all. Moved to `RawContent/Terrain/Source/` and **untracked**, because that is the side of the line raw inputs belong on (D-69a makes `Content/Terrain/` a *product* of `collect_terrain_assets.py`) and because they are re-downloadable from Fab by anyone with the account. What is tracked is `mapart substances`' extract: three maps of nine, at 2048 instead of 4096, **52 MB against 805**. The resolution is the decision that matters, and it is not about the screen - a `UTexture2D` keeps its source inside the uasset, so importing 4K would have put hundreds of megabytes through LFS to render ground seen at grazing distance across 71 km; the scans are 2 m across, so 4K is 2048 pixels per metre. Three maps because the pack's own ground samples colour and normal only: AO and Cavity are baked into a scan's colour at this distance, Displacement needs tessellation nobody has enabled, and Gloss is Roughness inverted. **Normals are renormalised after downsampling**, because averaging four unit vectors gives a shorter one and a normal map whose vectors are not unit length lights quietly flat everywhere - the kind of defect nobody finds later - and they are written as PNG where colour and roughness stay JPEG, since a second lossy pass over a vector field shows as blocky lighting on flat ground. **The physical size is read from each set's own metadata rather than typed**, which immediately earned itself: every substance is a 2 m scan except Snow, which is **0.30 m**, so a material tiling every layer with one number would have made the ice cap's grain nearly seven times too large. **`Jungle` was specified and dropped**: the nearest substance is a mossy rocky ground, temperate and damp where a tropical floor is leaf litter, and the wrong green over the whole equator is more misleading than no biome - the equator wears the remainder layer, which is generic but not wrong, and reinstating it is one manifest entry plus a substance. **The rock is swapped** for `Layered_Rock_Cliff`, a 2 m scan against the pack's tiling texture, accepting that it repaints every steep slope on the planet in the same change as the biomes and that `MI_Ground_RockyMeadows`' colour corrections were tuned to the old one. Seven layers, one spare in the eight-layer budget. Verified after: seams still **0 differing vertices**, sums **255..256**. | `RawContent/Terrain/ground.json`, `Tools/MapArt/substances.go`, `RawContent/World/Region.json` |
| D-75 | **The biome comes from the painting and the Köppen climate, classified in Go and blurred once globally.** Phase 1 of D-74. Three findings decided the shape of it, each measured rather than reasoned. **The class source is the painting, not Orogen's class render**: the render is the legend's colours double-encoded to sRGB, which puts exported `desert` at distance 53 from legend `ice` and 60 from legend `desert` — nearest-colour matching against it is not approximate, it is wrong — whereas the painting is made of the legend's own colours and nothing else, worst nearest-colour distance **0.0** over 29 M pixels. One is data and the other is a picture of data, and the same sRGB double-encode explains both renders. **Tropical is Köppen and not latitude**, because `Tools/Orogen/js/koppen.js` turns out to hold a real classification with exact colours: applying the same sRGB encode to its table reproduces every observed colour in the climate export to within **1.4/255**, with only **0.423 %** of pixels far from any class, which is the anti-aliased ring at class boundaries. The palette is parsed out of the JavaScript rather than copied beside it, because that is where it is used and two copies is how one goes stale; the parse refuses a table it does not recognise rather than matching every pixel to the wrong biome. **The registration is measured**: the painting is 7738 x 3761 and the heightmap 8192 x 4096, and the two candidate mappings were tested against each other on land/sea agreement, where identity scored **98.09 %** against 96.14 % and won in every latitude band including the polar ones, which is where a vertical scale error shows first. **The classification is Go** for the same reason `Tools/MapArt` is Go at all — the painting is 29 megapixels of RGB and the engine's Python decodes a byte at a time — and what it hands Python is 8-bit greyscale, which is `heightmap_io`'s fast path. **The blur is global, not per tile**, which is the one structural choice: a biome boundary has to be a gradient or the ground has a drawn line on it, and a blur computed per tile disagrees with itself across a seam unless every tile carries a margin the width of the blend — 200 vertices at 400 m and 2 m quads, a third more area on every one of ninety-eight tiles. Blurring once in the source's own pixels makes the field smooth *before* anything samples it, so a tile reads it with the same filter as the height and two tiles agree at a shared vertex by construction. Verified: seams **0 differing vertices** across all nine files on a shared column; weights **255..256 and never under**; and the unchanged three-layer path still reproduces the built world, height and rock and high rock **bit-identical** and meadow differing in **6 of 6 507 601** vertices by one, which is the deliberate change that lets the remainder layer absorb the rounding residual. | `Tools/MapArt/biomes.go`, `RawContent/World/Region.json`, `Scripts/Authoring/region_manifest.py`, `generate_region_tiles.py` |
| D-74 | **The biomes are painted already, so dressing is a carrying job; eight layers is the budget; Route A is dressed now.** Asked for as "materials works for the deserts, coasts, tropical, maybe even crater. Also skybox, ligthing and a selection of trees". The finding that shaped the whole plan is that **no classification had to be invented**: `Map5.legend.json` is `ocean, deep, ice, lowland, highland, desert, crater`, each with an RGB, and that map already exists at planet resolution in three places registered to the world by the same normalised `u,v` as the heightmap. Tropical is the only biome on the list that is not a painted class, and it comes from `orogen-climate-7945.png`; `forest` is already a generated overlay mark. **Eight paint layers, because Unreal packs four per weightmap texture per component and there are 9800 of them** — the fourth layer is free and the fifth doubles the weightmap memory, with shader cost linear on top, so the budget is the design: rock, grass, high rock, sand, beach, jungle floor, ice, regolith, and anything past that displaces something. **Route A is dressed now rather than wiring Route C first**: Route C is better ground and is the point of the generator, but it is a whole world rebuild in front of the dressing with nothing to look at until it finishes, and nothing authored for Route A is wasted — when Route C lands it changes the *inputs* to the layer rules, not the material, the sky, the grass or the trees. The two costs accepted are that beaches are approximated from altitude and slope instead of taken from where the coast pass laid them, and that no rule can read sediment or wear. **Trees are `LandscapeGrassOutput`, not foliage actors**, because it is the only thing that scales to 2549 km² without authoring actors — GPU-instanced, distance-culled, driven off the layer weights — accepting that its instances have no collision, revisited when a step needs to bump into a tree. **One sky, because it is one planet**: per-biome lighting means per-region post-process volumes across 98 landscapes, so the ground carries the biome and the sky is authored once, with exposure that works both on the ground and from 25 km up — the case that has already bitten twice. Substances and trees come from **Fab/Quixel Megascans**, free for Unreal use, added through `RawContent/Terrain/ground.json` exactly as the Rocky Meadows set was, so `collect_terrain_assets.py` keeps owning `Content/Terrain/` and no pack is ever modified. | [`Docs/World-Dressing.md`](World-Dressing.md) |
## 2026-09-20 — A map of the world, in the game and in the editor, from one widget
| # | Decision | Where |
| --- | --- | --- |
| D-73 | **The world map is a picture of the planet with a linear transform on it, and one Slate widget serves both hosts.** 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: D-69's `Region.json` imports the **whole** Orogen export with no crop, so `L_World` *is* the planet map, and the transform from a place in the world to a place on the art is `u = (X/100 + 35700) / 71400` and the same in Y - one multiply and one add per axis. Everything else follows from that and most of it is absence: **no scene capture, no render target, no minimap actor, no per-tile bookkeeping and no stitching**, none of which a linear map of a rectangle onto a rectangle needs. A capture would also have been wrong rather than merely expensive: the level is world-partitioned, so a capture only ever sees the streamed-in region and a map is exactly the thing that must show ground nobody is standing on. `FWorldMapProjection` is the whole of it, in `SaltyCore`, pure and tested. **The pasted colormap is the same planet**, which had to be checked because nothing inside a PNG says which world it is and Orogen numbers each *export* rather than each planet - `orogen-colormap-14733759` against `orogen-heightmap-7945` reads as two worlds and is not: 97.5 % land/sea agreement on a 16 px grid, and `mapart check` is that test, kept. **One widget, two hosts.** `SWorldMap` is Slate rather than a `UUserWidget` because the two hosts have nothing else in common - the editor tab has no `UWorld` at all, so anything needing one to exist could not be shared - and a Slate widget is the one shape both can take. `UWorldMapWidget` wraps it for UMG, the editor's World Map tab hosts it directly, and the console opens it straight into the viewport, so the map needs no content asset to be usable at all. **The seam is drawn, not avoided.** A whole cylinder wraps, so a view straddling the seam is two draws of two parts of one image and `ShortestDeltaU` puts a marker at U 0.99 next to a view centred on U 0.01 instead of most of a world away; `WrapU` uses floor and not `FMath::Fmod`, which keeps the sign of its argument and would leave a westward pan at -0.02 sampling nothing. `wraps_x` is derived from whether the window is the source's full width rather than assumed, because a crop of a cylinder has two real edges. **The art is rendered by a Go tool**, `Tools/MapArt`, because the engine's Python has no image library that can decode 33 megapixels of RGB - `heightmap_io.py` is greyscale-only and unfilters a byte at a time - and Go's `image/png` does all four layers in six seconds. It is not part of `Tools/Terrain` because that decides what the ground *is* and this decides what a picture of it looks like, downstream of every decision the generator has already made. **`relief` is the default layer and is derived, not exported**: hillshade and a hypsometric tint off the same heightmap the landscape was cut from, so the map and the ground are the same shape by construction rather than by agreement, and a colormap shows biome where a player needs landform. Exaggerated 4x, because a few hundred metres of relief over 17 m pixels is under two degrees and an honest hillshade of it is featureless grey. **4096 x 2048 rather than the source's 8192**: 17.4 m a pixel, still far finer than the couple of hundred metres the Orogen mesh resolves, and a quarter of the LFS. **The definition's projection is copied out of `Region.json`, never typed**, because a map that disagrees with the landscape about how big the world is is the one bug here that still looks like a plausible map. | `Source/SaltyCore/World/WorldMapProjection.*`, `Source/Salty/World/WorldMap*`, `Source/Salty/UI/SWorldMap.*`, `WorldMapWidget.*`, `Source/SaltyEditor/WorldMap/`, `Tools/MapArt/`, `RawContent/World/MapArt/layers.json`, `Scripts/Authoring/create_world_map.py` |
## 2026-09-20 — One world level, and it is the region
| # | Decision | Where |
| --- | --- | --- |
| D-72 | **`L_World` is the planet-map region. There is no `L_Region`.** Called as "we shouldn't have a secondary level anyway, it all should be working on L_World including the world we built". D-69 built the region into a level of its own because it was an experiment beside a working world; once it *is* the world, a second world level is a second thing to keep dressed, lit, streamed and in step, and the one that carried the name was the 14.28 km numpy square that D-47 had already frozen. The entanglement turned out to be nil - `L_World` was named only by `World.json` and `world_manifest.py`'s default, no gameplay code refers to either level, and the editor and game default maps are `L_Gym` - so this is two manifest keys and a rebuild. **Rebuilt rather than renamed**: a world-partitioned level owns its actors as 475 external packages plus two HLOD layer assets, the editor fixes those up on a Content Browser rename and a headless rename is not a path this project has walked, and a half-fixed rename is worse than an hour of unattended batches. It is also the idiomatic answer here, because a level in this project is a product of a script, a manifest and some PNGs and is never hand-edited. **The old canvas is repointed, not deleted**, at `/Game/Maps/L_Canvas_Proto`, and the name being different is the whole point: `create_world.py` empties whatever level it is handed before rebuilding it, so a manifest still saying `L_World` is a loaded gun - one run and ninety-eight landscapes are a 14 km square, with no prompt. It is kept rather than removed because it remains the only path that carries the erosion pass's flow, wear and deposit maps into Unreal, which the Go tool has not replaced there yet. **The tile files are named after the level**, because `tile_name` is the level's last segment plus the coordinates, so changing `level` made all ninety-eight tile sets look missing and would have regenerated 208 MB of PNGs that already existed; the 392 files were renamed instead, in a second. `build_region.sh` now reads the level out of the manifest the way it already read the grid - a name written into the driver goes stale the moment the manifest changes, and the lock probe would then have been clearing a file nothing was about to touch. The numpy pipeline's own PNGs are `L_Canvas_Proto_*.png` now for the same reason: `L_World_Height.png` in `Heightmaps/` beside `L_World_x0_y0_Height.png` in `RegionTiles/` is two files differing by a tile suffix. Verified on the rebuild: `L_World` emptied to 9 actors, the sweep removed **exactly 256** stale packages - the old landscape's 16 x 16 proxies - and nothing else. | `RawContent/World/Region.json`, `World.json`, `Scripts/Authoring/region_manifest.py`, `world_manifest.py`, `build_region.sh` |
## 2026-09-20 — Orogen exports the tile set Unreal actually wants, and a locked level is refused before it is emptied
| # | Decision | Where |
| --- | --- | --- |
| D-71 | **World Orogen exports Unreal landscape tiles directly, and the scale is recorded rather than invented.** Asked for as "can you make it so we can export from orogen as intended … its the 2m/px type exports, whatever unreal needs". D-69 built the Python cutter because Orogen gives two things Unreal can import neither of: a picture, and one flat 8192 x 4096 PNG with **no scale attached**, which is what forced `metres_per_pixel` to be a number somebody chose and wrote in a comment. The export now answers the question Unreal asks - per-tile 16-bit greyscale heights at 255*N+1 vertices, one Landscape actor each, an 8-bit weightmap per paint layer beside them, and the `Region.json` that describes the lot - at the quad size the game uses. **The window is sampled once and then cut**, not rendered per tile: every tile is resampled out of one float raster by its *global* vertex position, so a column two neighbours share is computed from the same source coordinates twice and comes out bit-identical. Rendering each tile under its own camera is one step shorter and would put a rasteriser's floating-point luck on every seam, where one 16-bit step is 11 cm of crack; measured, the shared columns and rows match at **0 of 1021 differing, 0 LSB**, heights and paint alike. The paint only closes because the tiles carry a **one-vertex margin**: the layers read slope, a one-sided difference at an array edge is not what the neighbour computes there, and without it every boundary is a one-vertex line of different colour - the same defect D-69 found in numpy, found again in JavaScript. **Heights come back through the ramp, not around it.** The vertex colours carry `heightmapColor`'s fixed -5..6 km ramp, which is code the 16-bit export already proves, and the float render target is read as floats instead of being quantised: float32 over 0..1 resolves about 1e-7, a millimetre over an 11 km ramp, against the PNG's 17 cm. Writing kilometres straight into the colour attribute was the obvious alternative and was rejected because negative values would then depend on three.js colour management staying out of the way. **The intermediate is the window, not the planet**, so its resolution is spent where the ground is; 8 m a pixel is the default and is already lossless, because the sphere mesh resolves a couple of hundred metres and nothing samples detail that is not there - the detail below that is still the Go generator's, exactly as D-69 says. **What the panel is really for is the number you have not typed yet.** Every field re-plans on the keystroke and the readout says how much ground, how many components, how many files, how finely sampled, and what the flat reading costs - the service `terrain plan` does for a legend. It immediately earned it: with the numbers D-69 settled on, **936 km² on a 100 km planet is a window 110 degrees on a side stretched 74.7 % at its edge**, because a 100 km circumference is a 3183 km² world and 936 km² is 29 % of the whole globe. That is not a regression, it is D-69's "fidelity to the globe was already gone the moment the scale was invented" stated in numbers at the moment somebody types it. The projection is equirectangular **cosine-corrected at the centre latitude**, which splits the error - the edge nearer the equator compresses, the edge nearer the pole stretches - rather than leaving it all on one side, and the defaults are 4 x 2 tiles for 208 km² at 5.4 %, a window a sphere this small can hold flat. **Not ``.** That control formats and parses in the browser's locale; on a comma-decimal machine a sea scale of 0.17 displays as "0,17" and `.value` comes back empty, so the setting becomes NaN and the export writes a whole tile set of nothing. Caught in the first screenshot of the panel. Text boxes with `inputmode="decimal"` keep the phone keyboard and leave the parsing where both separators are accepted. Verified in a real browser over the DevTools protocol, 26 checks: the plan arithmetic, the render's range, the file names and PNG headers, the scanlines inflated back out of the encoder, both seams, the weightmaps summing to 255, and the manifest carrying every key `region_manifest.py` requires. The PNG encoder moved to `png-write.js` because the export needs an 8-bit one beside the 16-bit one and a canvas gives neither. **An existing `Region.json` is never replaced**, only written beside as `Region.generated.json`: a hand-written manifest here is mostly the reasoning behind its numbers, and D-56 already settled that a generated save must not eat an author's commentary - the studio patches a legend's *text* for exactly this reason. Tiles are data and are overwritten; a manifest is an argument and is not. **This does not retire the Python cutter** and is not meant to: `generate_region_tiles.py` is what will read `terrain tiles` when the Go generator's detail reaches Unreal, and that is the path that ends with ground worth standing on. | `Tools/Orogen/js/unreal-export.js`, `unreal-render.js`, `unreal-ui.js`, `png-write.js` |
| D-71a | **A build refuses to start when the level is locked, because `--rebuild` is the first thing it does and the save is the last.** Reported as "currently the map IS BUGGED OUT somehow, super glitchy, nothing left". It was not corrupt and nothing was lost: a full rebuild had been run with the editor holding `L_Region` open, so batch 0 emptied the level, batches 0 to 3 built twelve tiles, and batch 4's save died on `MoveFile … (Error Code 32)` - a Windows sharing violation. The run aborted correctly and loudly; what made it look like a corrupted world is that the wipe had already happened and the twelve survivors were all of row y=0, which on this planet is the northern polar strip and is nearly all ocean. So the level opened on 71 km of sea. The ordering is the whole defect - a failure that is detected only after the destructive step is indistinguishable from data loss - so `create_region_world.py` probes the `.umap` with `open(path, 'r+b')` before `prepare_level` runs, and `build_region.sh` asks the same question once before paying for thirty editor start-ups. `r+b` is the probe rather than a test save because it asks the operating system exactly what a save asks and writes nothing; a level that does not exist yet is not locked, and a first build is what creates it. | `Scripts/Authoring/create_region_world.py`, `build_region.sh` |
## 2026-09-20 — Generating the overlay is a button, and a button is a re-roll
| # | Decision | Where |
| --- | --- | --- |
| D-70 | **The overlay generator is in the studio, it runs before the first bake, and every press is a different world.** Raised as "can we have a button to generate random overlays". D-68 built the generator as a command; the reason to put it behind a button is that generation is a *draft* rather than a step - press, look at where the towns landed, press again, keep the third - and that loop only works where the sheet is already on screen and editable. Four things the button needed that the command did not. **It re-rolls.** Each press picks a fresh seed, and because the generator never overwrites a painted pixel, after one press its own output *is* painted pixels; a second press would generate around the first and the sheet would silt up. The server remembers exactly which pixels the last generation put down (`ovGen`, one byte a pixel) and clears those and only those first, so hand-painted work is never in the set and is never touched. **It works with no bake at all**, from the painting alone: height is flat, slope is zero and drainage is *nil* rather than zero - the difference matters, because a score of zero everywhere is still a score and would silently reweight the coast against it - so what survives is everything the painting knows and nothing the terrain does. Which of the two ran is said in the status line, because reading a sketch as the other is how somebody concludes the generator ignores the terrain. **Settlement placement had no seed dependence at all**, found by pressing the button twice and getting two byte-identical drafts. The forest count is invariant on purpose, being a quantile, so the settlements were the only visible difference between drafts and they did not move; the score now carries a seeded jitter of 0.65, wide enough to reshuffle the ranking among comparable ground and narrow enough that a river mouth on a plain still beats a hillside every time. **And a bake of a different painting is refused.** `CheckBake` compares the numbers a heightmap is *encoded* with - elevation range, circumference, cell size, seed - and two paintings of the same planet agree on every one, so Map3's bake was silently accepted for Map5 and the first drafts were placed against rivers and coasts that are not there. `BakeIsOfThisPainting` compares `meta.json`'s recorded template and falls back to the painting-only path with the reason printed. Verified in a real browser over the DevTools protocol: the button is hidden on the class sheet and shown on the overlay one, two presses give different seeds and genuinely different placements, the wrong bake is named and ignored, and there are zero page errors. The sheet is replaced rather than stroked, so the undo tiles no longer describe it and the history is dropped - said in the status line rather than discovered. | `internal/studio/overlaygen.go`, `internal/studio/page.html`, `internal/planet/overlaygen.go`, `internal/overlay/generate.go` |
## 2026-09-20 — The overlay can be proposed from a bake
| # | Decision | Where |
| --- | --- | --- |
| D-68 | **The annotation layer has a generator, and it fills blanks rather than replacing anything.** Raised as "can we also get some generated overlays into terrain tool so we can build off it if needed". The overlay started blank and stayed blank until somebody painted it, which is right for an authorial layer and a bad starting point: woodland, settlements and the roads between them are all consequences of ground an author *cannot see* while painting - the classes are painted before the solve exists, and once it does it is a 76 M cell heightmap. `terrain overlay` reads a finished bake and proposes them. **Four rules are the design.** (1) *A painted pixel is never touched*: the sheet on disk is loaded first and generation fills around it, so the round trip runs both ways - generate, move the towns, regenerate the roads round the edits - and re-running is safe. (2) *Opt-in per mark*, via a `generate` block, so a legend written before this produces the blank sheet it always did. (3) *At the template's resolution*, never the geology grid's, because a resampled mark is a blend of two colours and the classifier reads exact ones. (4) *It proposes*; there is no economy, no history and no climate here, so "where would a city be" is answered with drainage, slope and distance to the sea, which is the part the terrain can answer. Four kinds: `forest` (noise-broken, below a treeline taken as a quantile of the land's own heights, because metres mean nothing until a world is baked), `settlement` (scored sites, one spacing rule shared across tiers), `road` (a **minimum spanning tree** on least-cost path distance, not every pair, with **water impassable** so each landmass gets its own network), and `coast`, which is deliberately **not** enabled in the shipped legend because `coast_jitter` is the one overlay property a pass reads and generating it would move the next bake's coastline everywhere. **Three defects found by running it rather than by reading it**, each fixed and each worth recording: a "never overwrite" rule that protected generated marks as well as painted ones meant the first kind painted blocked every kind after it - a coastal band claimed 21 % of the world and the settlements and roads inside it painted *nothing*, so the fix separates "painted before this run" from "painted by this run"; settlements were scored onto already-painted ground where they could not be stamped, so one city of three and six villages of eighteen were placed, counted, written and then dropped by the feature pass, making the run summary and `terrain plan` disagree with neither being wrong; and with no notion of class, forest grew across both polar ice caps, which `only_classes`/`not_classes` now prevent. Measured on Map3 against `Bake_022`: 17 s, the hand-painted 3.88 M px kept exactly, 2.48 M px added, and `terrain plan` reads the result back with **0 unmatched pixels** and counts agreeing with the run exactly - 3 cities, 10 towns, 17 villages (of 26 asked; the report says so), 57 woodland blobs and 14 road polylines. | `internal/overlay/generate.go`, `generate_roads.go`, `internal/planet/overlaygen.go`, `RawContent/World/Templates/README.md` |
## 2026-09-20 — Off the ladder, a planet map becomes ground in Unreal
| # | Decision | Where |
| --- | --- | --- |
| D-69 | **900 km² of ground in Unreal, cut from a planet map and tiled, with the scale chosen rather than inherited.** Asked for as "bringing the heightmaps into unreal ... goal is to have it 900km2 of land", against `RawContent/World/Orogen Gens/orogen-heightmap-7945.png`. Three facts had to be established before anything could be built. **The export is a whole planet, not a region**: 8192x4096 cylindrical, 16-bit, on Orogen's fixed -5000..6000 m ramp, and at `Planet.json`'s 100 km circumference it is a 3183 km² world with **1255 km² of land on it in total**, the largest continent 523 km². There is no 900 km² of land to crop, and worse, at 100 km round the planet is 31.8 km *across*, so a flat 30 km square is larger than the planet and no window exists at all. **The scale is therefore a manifest number, not a derived one.** The file carries no scale; `metres_per_pixel` is a choice, and it is the choice that decides how much land a window can hold. The land mask was searched over scale and position with a summed-area table: land fraction climbs from 81.7 % at a 150 km circumference to 100 % at 210 km, and **22.583 m a pixel - a 185 km circumference - is the finest reading whose best window still clears 900 km²**, at 1355 x 1355 source pixels holding **905.2 km² of land in 936.4 km² of map**. Coarser buys more land and blunter ground. **And the map is read flat, not unprojected**, which at the window's latitude of -24 stretches the ground 9.6 % east-west against Orogen's globe. That is deliberate and it is the cheap option: cos-correcting the crop stretches its own edges by ±35 % across the 59 degrees of latitude it spans, and an azimuthal projection of a patch two thirds the width of the planet distorts more still. Fidelity to the globe was already gone the moment the scale was invented. **The heights are Orogen's, not the generator's**: the twin normalises land so its 99.5th percentile stands at the import page's peak setting, so this continent comes out 2972 m tall where `terrain bake` makes it a **116 m plain**. The relief is art and the manifest says so. `sea_scale` 0.17 is the one correction and touches only the sea - the export's abyss is 3 km down on a whole-planet ramp, which over a 30 km window is either a clipped plateau with a cliff at every shore or an elevation range so wide the land loses its precision; at 0.17 the range is -512..3072 m, an exact Z scale of 700, and **nothing clips**. **The layout is thirty-six landscapes, not one.** 900 km² at the 2 m quad D-48 fixes is 234 M vertices, which no single Landscape actor will take in one import; 6 x 6 tiles of 2551 vertices give 10 x 10 components of 255 quads each, 3600 over the window. **Seams are exact by construction rather than by blending**: neighbours share their edge vertices and every vertex is sampled from its *global* position in the window, so a shared column is computed twice from the same source coordinates and comes out bit-identical - verified, heights and all three weightmaps. That did not hold for the paint layers until the tiles were sampled with a **one-vertex margin**: the layers read slope, `np.gradient` takes a one-sided difference at an array edge, and that is not what the neighbour computes for the same vertex, so every tile boundary came out as a one-vertex line of different paint. Resampling is **Catmull-Rom clamped to its two central taps**, because this is an 11.3x upsample of a source whose steps are its coastlines - 135 m over one pixel - and an unclamped cubic would put a raised lip on the land side of every shore and a trench on the sea side, while bilinear would crease along every source pixel boundary. **Three walls, all in the engine and none in the data.** *Memory*: a landscape of a hundred components costs about a gigabyte the editor never gives back while the level is open, 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, and was killed twice. The builder is incremental now - it adds the tiles it is told to, saves and exits, skipping any already in the level, which it reads out of the asset registry rather than the world because in a fresh commandlet almost none of them is loaded - and `build_region.sh` runs it in batches. Six peaked at 13.6 GB and left 1.2 GB of commit; three is the default for that reason. *Arguments*: `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 and sets `sys.argv`; anything placed after it on the command line is parsed by the engine and never reaches Python. Passed the wrong way the script saw no arguments and **silently built all thirty-six** - the exact run the batching exists to prevent, and it looked like progress. *Silence*: a commandlet launched with the editor open and a **relative** project path exits zero having written nothing at all, no log and no output, which is indistinguishable from success. Absolute paths and `-abslog` throughout. And `UnrealEditor-Cmd` exits non-zero whenever anything logged an Error, which this project does three times on every start (no `GameFeatureData` asset-manager rule; the open editor already holds MCP's port 8000), so the driver gates on the script's own saved line rather than on the exit code. **What is missing is the detail, and it is not missing from here.** The source resolves about 200 m, so below that the ground is smooth and an 11x upsample cannot invent what is not there; there is no erosion pass and therefore no wear, flow or deposit map, which is why the paint layers are slope and altitude alone against L_World's richer rules. The detail is the Go generator's and `terrain tiles` already writes 5 km tiles of 2 m samples over a bake: when those replace the window as the source, `generate_region_tiles.py` is what changes and nothing downstream of it does. The overlay (D-57) is **reserved and not implemented**: `Region.json` keeps the key and `region_manifest.py` the place a tile's mark map would go, nothing writes one, and no pass reads a mark - no forest, no road and no settlement is placed from one, by request. It is left unbuilt rather than written blind because there is no overlay to run it against yet, and a carry that has never carried anything is a guess about a file format. | `RawContent/World/Region.json`, `README.md`, `Scripts/Authoring/region_manifest.py`, `generate_region_tiles.py`, `create_region_world.py`, `build_region.sh` |
| D-69a | **The ground is collected into `Content/Terrain/`, and a copy is not ownership.** Asked for as "a folder where we start collecting all the ground materials and textures into instead of being separate in different asset packs". Copies rather than moves, chosen so a pack stays exactly as it shipped and can be updated or deleted without taking the ground with it; the cost is about 200 MB through LFS. The set is data - `RawContent/Terrain/ground.json`, fourteen assets today - so adding a substance is a line and a rerun, and the script is idempotent. The part that is not obvious: **duplicating a material function gives a function that still samples the pack's textures**, because the reference lives inside the graph and duplication does not rewrite it, and the ground here is three layer functions under a master material under an instance. So every copy is walked after the copying and repointed at its siblings - a texture sample's `texture`, a function call's `material_function`, an instance's parent and a texture-parameter override for anything still pointing into a pack - and then **the asset registry is asked what still references outside `Content/Terrain`**, because that report, and not the fact that the files are in the folder, is the honest answer to whether a pack can be deleted - and it earned that four times over. `Expressions` is protected on UMaterial and absent on UMaterialFunction, so it has to come from `MaterialEditingLibrary`; a function needs `get_material_function_expressions`, because `get_material_expressions` refuses one outright, which is what left all three layer functions still sampling the pack after a run that reported success; the registry has to be rescanned before the report, because the newly copied assets are not in it yet and **an empty dependency list was being read as clean** - the first run announced that the folder stood on its own while the master material was still calling the pack's three functions, which is the worst possible failure for a report whose whole job is to be believed; and a material caches the textures it and its called functions reference, so every copy is recompiled and force-saved at the end, or the stale cache outlives the repointing and no rerun can clear it because a rerun has nothing left to change. 21 graph references and 13 instance references repointed; **one remains and is reported** - the instance still names the pack's `T_Rock_Shade_Variation`, which is not one of its parent's exposed parameters and is not in its own override array, so neither pass can reach it. Everything renders from `Content/Terrain`; the pack stays until that one is cleared by hand. Only Elite_RockyMeadows has ground worth collecting today: HouseForge_01's `Textures/Stone` is architecture - walls, stairs, a doorway - its Viking props' grass is a foliage card, and the other two packs have none. The cloud-shadow texture stays with the sky kit in `rocky_meadows.py`, which is also new: the pack's material, layer infos, sun, skybox, fog and grade moved out of `create_world.py` so one set of numbers dresses both worlds rather than two copies of them. | `RawContent/Terrain/ground.json`, `Scripts/Authoring/collect_terrain_assets.py`, `rocky_meadows.py` |
## 2026-09-20 — The twin reads the planet's own files, and says what a rate makes
| # | Decision | Where |
| --- | --- | --- |
| D-67 | **World Orogen reads the planet's own files rather than two hand-picked ones, and the studio serves them read-only.** Three things carried over from `Tools/Terrain`, chosen because each answers a question an author has *while typing a number* and none of them needs the 8 m grid. **The class table prints two angles now.** `painted-report.js` is `plan.go`'s `divideAngle`, `typicalFromDivide`, `readsAs` and `clampCeiling` ported exactly - checked against `plan.json` on Map3, all five land classes and the clamp ceiling agreeing to 1e-9 - and the *typical* median is the column while the divide is the tooltip, because the divide is the steepest ground a rate can make, almost none of a map is divide, and reading it as the landscape is how a legend gets set two or three times too hot. **The overlay is carried, as a texture.** `painted-overlay.js` is the classifier (alpha is blank, an opaque pixel past `match_distance` is dropped and counted, never snapped to the nearest) and `painted-overlay-view.js` draws the sheet on the globe's own triangles with unwrapped UVs at the seam and on one quad over the map, shifted by texture offset so the centre-longitude slider wraps for free. **Not voted onto the mesh**, which is the whole design decision: a road is 8 px at 12.9 m and a region here is ~44 km, so a per-region colour would lose every thin stroke. Marks *are* voted for one thing, `coast_jitter`, the single mark property any pass reads, spread two hops so a stroke either side of the waterline protects the shore between them and the smaller factor wins where two meet. **Planet.json is read directly** and outranks a legend's own `planet` block, because it is the file the bake reads; it also carries `pipeline.fluvial` and `pipeline.thermal`, which is where the angles' constants come from, and the geology cell is derived as `quad_cm × geology_factor` exactly as `manifest.go` does it. **And `terrain studio` shares its files**: `share.go` puts `Access-Control-Allow-Origin` on GET and HEAD alone and answers no preflight, so a page can read the painting, both legends and the manifest and can never paint, save, plan or bake - a cross-origin POST with a JSON body needs a preflight it will not get. The four handlers that patched those files now serve them on GET, unmarshalled, so what Orogen reads is byte for byte what the next plan reads, and the painting comes from memory, including strokes made since the last save. Measured end to end, headless: the whole planet loaded from a running studio in 4.0 s, 3,875,832 overlay px painted and 0 far, **identical to `plan.json`'s count**, the sheet downsampled to 3869 × 1881, 200 steps in 7.3 s, overlay marks on 45,745 regions, zero page errors, and the export named and composited. Not carried, and for the same reason as D-66: faults (a range front is 6 km wide), craters, the coast pass (a shelf is 4.6 km), the detail passes, and `internal/stats`' slope histograms, which on a 44 km mesh would describe the mesh rather than the ground - the typical angle is the honest number there. | `Tools/Orogen/js/painted-report.js`, `painted-overlay.js`, `painted-overlay-view.js`, `Tools/Terrain/internal/studio/share.go`, `RawContent/World/Templates/README.md` |
## 2026-09-19 — Off the ladder, the painted workflow gets a browser twin in World Orogen
| # | Decision | Where |
| --- | --- | --- |
| D-66 | **The painted-map workflow has a second implementation, in World Orogen, and the legend file is the contract between the two.** Raised as "give Orogen some of the features we were working on in the Go tool: import a painted map to generate off, generate all the heightmaps and other maps". The GPL browser generator now under `Tools/Orogen/` (the same one D-65 borrowed its edge-preserving smooth from) has a **Painted Map** source on its import page: it reads the *same* painting and the *same* `*.legend.json` that `terrain plan` reads — nearest-colour classification, majority vote onto the Voronoi regions, strokes dissolved and pole-touching strokes turned into `edge_class`, the waterline roughened on the signed distance before anything is solved, the class rate cut by a massif fabric at a quantile of the **planet**, the coastal-plain ramp, a planet-wide rock field on `k_mult`, the regional swell — and then a Braun-Willett implicit stream-power solve on the sphere mesh with the ocean fixed at sea level, so the rivers, divides and basins are the physics' answer to the painting. It is a twin, not a port: the globe is Earth-sized, so legend distances are scaled by the ratio of circumferences; the mesh is 204K irregular Voronoi cells of ~44 km, so faults, craters, the repose clamp and the detail passes have no meaning there and are not carried; and because n = 1 makes the steady state linear in U/K, relief is a single scale set afterwards (Peak Height) rather than a physical K. What it buys is a 30-second loop — paint, solve, look at the rivers on a globe with climate on it, export the class, uplift, erodibility, drainage, slope, basin and 16-bit heightmap PNGs — against the two-hour bake, on the same files. Measured on Map3: 29.1 MP classified in 0.45 s, 200 steps in 12.3 s at 204,001 regions, 39 % land, zero page errors, headless. The legend gained an optional `planet` block (circumference, massif and lithology wavelengths, variation, `lithology.k_multipliers`) so the Planet.json numbers can travel with it; new keys are read optionally on both sides and nothing existing is renamed. Not a replacement for `terrain bake`: the 8 m geology grid, the coast pass and the tiles are still the Go tool's, and only they produce ground a player can stand on. | `Tools/Orogen/js/painted.js`, `painted-layers.js`, `import.html`, `Tools/Orogen/CLAUDE.md`, `RawContent/World/Templates/README.md` |
## 2026-09-17 — Off the ladder, the terrain generator leaves numpy and gains a drainage network
| # | Decision | Where |
| --- | --- | --- |
| D-65 | **The laser-carved mountain flanks are D8's, not the ridged noise's: drainage area is multiple-flow now.** Reported as "bunch of 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 and had ruled out grid locking because "the ribs are oblique, not axis-aligned" - which is not a counter-indication, because D8's parallel-flow grooves run in whatever direction the slope faces. **The grooves are channels**: they are in `map_flow.png` as parallel high-accumulation lines and absent from `map_uplift.png`, and the network re-derived from the heightmap is *pinnate* - ruler-straight parallel trunks with barbs at a near-constant angle - with **25 sources and no confluences** in a 12.8 km window at a 1 km2 threshold, against about one source per confluence for a dendritic network. **The decisive measurement is the pitch in cells**: 18 cells in both `Bake_x4` (32 m) and `Bake_020` (8 m), the same painting and seed. Every physical candidate - the ridged fBm at 250-300 m, the massif fabric, the fault grain - is fixed in metres and would change its pitch in cells by four; only a grid-scale mechanism survives it. **The cause, isolated with no erosion at all** (`flow_test.go`: one fill, one accumulate, on a planar ramp at a 22.5 degree aspect, where the correct specific catchment area is the same at every point along a contour): the most-drained cell in a contour band carries **769 times the median** and **29.5 % of the grid drains nothing**, because every cell picks the same steepest neighbour, the flow lines never converge, and drainage area grows linearly along a line instead of by basin area. Stream power then reads A^m off that and cuts each line in; the ±0.05 % tie-break jitter is a *static* field, the same hash at step 1 and at step 1000, so the rare merges it allows are re-carved a thousand times rather than averaged out - which is what sets the spacing, and why the spacing is in cells. **Fixed with Freeman MFD for `Accumulate` only**, D8 receivers kept because Braun-Willett walks one receiver chain: 769 -> **1.34** and 29.5 % -> **0.4 %**. Ordering is Kahn over the flow graph, not the descending-elevation sort 4.B2 proposed - exact, O(n), no elevation comparison, and a bucket sort would have leaked every lake bed's area because the queue quantises to a centimetre while the flood's epsilon ladder is a millimetre a cell. float32 is enough; area conserves to 2.4e-9 over a closed basin. Costs **101 ns a cell against D8's 17**. Three smaller repairs in the same change, each separately argued: the repose clamp jitters its pop order and its allowance (rank correlation with the raster index -1.000 -> -0.025, though a clamped cone is unchanged - on a cone no two cells share a bucket, and the residual octagon is the octile metric, not the order); `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 at all; and `field.SmoothEdgePreserving` is ported from the World Orogen browser generator, off by default. That generator uses single steepest-descent too - what keeps its flanks clean is that it solves on an irregular Voronoi mesh with no lattice directions for flow to collapse onto, which a raster pipeline cannot adopt, so MFD is the grid-native route to the same isotropy. | `Docs/Terrain-Next.md` 4.B2-4.B6, `internal/fluvial/mfd.go` |
| D-65a | **Supersedes D-65's own first attempt at the MFD precondition.** The pending counts Kahn needs were folded into `ComputeReceivers`, which already reads all eight neighbours and produced them for nothing. The walk *consumes* them, so a second call without an intervening `ComputeReceivers` seeded its whole queue at once and returned a drainage area that was **silently wrong rather than panicking** - and `Run` calls the two in lockstep, so nothing in the solve would ever have caught it. A benchmark did. The counting pass lives inside `AccumulateMFD` now: it is a pure gather, so it parallelises and costs almost nothing, and a pass that owns its own preconditions cannot be misused that way. | `internal/fluvial/mfd.go` |
| D-64 | **The shelf break was the square canvas’s, so a painted 512 m ocean came out thirty metres deep.** Raised as "it is just a landmass and no oceans really", against the exported heightmap rather than the preview. Measured on `Bake_020`, the whole planet at 1000 steps: the legend paints `ocean` and `deep` at **512 m over 55.9 % of the world** and **17 % of it gets there**, while **40 % of the planet is water between nought and thirty metres** - one 26.8 % spike at -20 m with the entire continental slope spread behind it at about 0.25 % a 20 m bin. At the manifest’s -1024..2048 m encoding thirty metres is 1 % of the ramp away from sea level, so the shelf and the land encode to the same grey, every landmass wears a halo the width of its shelf, and the halos fuse the continents into blobs; stretched to its own data range the heightmap is one white shape on black. **The cause is one number taken from the other canvas.** `coast.Build` needs a `BreakM`, the depth at the shelf break, and both call sites computed it as `-pipeline.continent.sea_floor_m.hi()` = **30 m** - a square-canvas default whose own comment says it is not a shelf break, because on a 14.28 km canvas "a real shelf, 75 km out to a break at 130 m, does not fit". `continent` is the synthetic-mask generator D-53 decomposed the planet away from, and the break was the last piece still holding on: `AbyssM` had been per-cell from the painting since D-60 and the break never was, so every bake joined a painted ocean to a bench and only the bench was ever visible. **What hid it is arithmetic nobody had done.** 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** - every strait on the planet is narrower than twice the reach, so the painting is never consulted anywhere in it. The profile itself is monotone and correct at any break depth, which is why every profile test passed and why the defect had to be found in a histogram. `pipeline.coast.break_m` is a key now; `ShelfBreakM()` falls back to the old reading so the **square canvas is unchanged** - verified, it still prints a 30 m break - and a planet defaults to **130 m**, a real shelf break and the same depth the first template’s own `shelf` class is painted at, the number arrived at from the author’s end. 512 m over the unchanged 4.6 km margin is a 6.3 degree continental slope, so the margin’s width was never the problem. Two further copies of the same read were in `tiles.go`, both commented as "the shelf break": at 30 m `restoreSeaFloor` would have staircased the whole of the new shelf at the geology cell, and the tile hillshade would have flattened it to one tone. **And a bake prints its sea floor and its ramp now.** `elevation_m` is -1024..2048 against data of -521..+340, so the world uses 28 % of its 16 bits and its land 7 %; the clip fraction was the only check there was, and it catches a range too narrow, never one three times too wide - which clips nothing and reports nothing. The range is the author’s and `Planet.json` is untouched. Measured at unit scale, 9.6 km of sea painted at 512 m, break 30 m against 130 m: **33 % -> 8 %** of it left shallower than 50 m, the painting reached in open water in both, and a sea painted at 20 m still 20 m deep, because the break can never be deeper than the water it is a break in. **The whole-planet re-bake was killed by memory pressure before it finished, and its numbers are still owed.** | [Terrain.md](Terrain.md) |
| D-63 | **A fault set saturates rather than stacking, and the initial relief is scaled by the rate before the faults.** D-62 widened a fault from a 600 m welt to a 6 km footwall and was verified on a region two traces reach. On one that several cross it regressed, and the report was "the mountains seem to be streaking horizontally like someone just cut them apart with a knife" - `Bake_018`, region 11, whose southern two thirds came out a corduroy of parallel ribs. Two mechanisms, both D-62's. **Faults stacked.** `FaultDelta` accumulates with `+=`, harmless while a fault reached 600 m because two of them almost never met; at 6 km they meet constantly, and a fault set is sub-parallel *by construction* - traces inside one cell of the orientation grain share a strike - so where they meet they all push the same way. Measured on a 200 m grid over the region: **75 % of the faulted ground had two or more faults on it**, the sum was a median **1.77x** the largest single contribution there and up to 4.46x, and 13 % of it asked for more uplift than the repose ceiling allows on its own - so the hard clamp downstream fired on **160 289 cells, 4.1 % of the region** against 0.15 % over the whole planet before, and a hard clamp makes plateaus. The answer is a soft knee per cell, keyed to the **largest single contribution at that cell** rather than to the largest throw in the set: a planet throw would not bite, since the biggest on this template is 744 m while the biggest single contribution anywhere in the region is 209 m after taper and falloff. Below the knee it is the identity, so a lone fault is untouched and D-62's calibration holds; above it the excess bends onto an asymptote of 1.6x the knee, so a belt still stands higher than an unfaulted one but five parallel faults cannot deliver five throws. **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 upper bound, so D-62 took the stamped amplitude from about 39 m on unfaulted foreland to about 166 m on a 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 the symmetry and became the flank texture: the ribs measure 250-300 m, which is octave five of a 4.2 km ridged fBm. It reads the pre-fault rate now, bounded at one. How much noise sits on a hillside is not a fault's decision. Measured, region 11 re-baked at the same seed and step count, `Bake_018` against `Bake_D63`: max elevation **221.4 -> 115.6 m**, cells at the repose ceiling **160 289 -> 2 865** (a 56-fold drop), median slope **6.26 -> 1.79 deg**, slope-area exponent **-0.938 -> -0.599** against a theoretical -0.500, drainage density 0.58 -> 0.60 /km, and the stacking factor itself median **1.77 -> 1.50**, p90 2.87 -> 1.60. The corduroy is gone from the hillshade. What remains on the flanks is Terrain-Next 4.B3, which predates D-62 and is now diagnosed rather than fixed. | [Terrain.md](Terrain.md) |
| D-62 | **A fault is a range front, not a welt: the cross-fault profile is continuous and kilometres wide.** Raised as "each fault line makes a rough line of mountains that just doesn't look realistic", and it was not a manifest number set too low - `per_1000km2`, `throw_m` and `length_km` say how many, how long and how much, and nothing said what shape. The shape was two constants in `painted_faults.go` and a step between them: the whole throw on the upthrown side of the trace decaying over 200 m, the whole throw negated on the other decaying over 2000 m, so the rate field jumped by **two throws across one 8 m cell** - 89 degrees, measured - inside an upthrown flank that reached zero 600 m out. Both halves are unsolvable and for the same reason the painted path exists. A discontinuity in the *rate* is a painted cliff: the surface has nowhere to put it but a scarp at the angle of repose, so the trace facets at any throw and turning `throw_m` down only lowers the same artefact. And 600 m is narrower than one hillslope - `Bake_013` measures a drainage density of 0.45 channels per km, so a divide sits 1.1 km from its channel - so no valley can head on it, no stream power can bite at that drainage area, and the uplift profile is **printed** onto the surface instead of being eroded into a landform. That is the artefact exactly: in a hillshade every fault in that bake is a smooth ruled ridge running through terrain that is dissected everywhere else, because it is the one part of the map erosion never touched. The profile is now an odd saturating ramp across the trace times a flank envelope with finite support - zero *on* the trace, which is also the honest reading of a rate difference across a line, a 6 km footwall and a 4 km hanging wall, and algebraic rather than transcendental because it runs at a few hundred million cells a planet. The along-strike taper was a flat top over the middle two thirds, which extrudes the cross-section unchanged along most of every trace; it is a bell now, so nothing repeats. And `throw_m` means what the word means: the whole step across the fault, not a full throw on each side, which is what the old profile built. Measured on a 400 m throw: the steepest cell in the rate field **89 deg -> 17.4 deg**, the step across the fault 400 m over 2.66 km (8.5 deg mean) against 800 m over one cell, the footwall above half its crest for 3.7 km against 0.6 km. Solved on the same synthetic landscape for 1000 steps: the old profile leaves a ruler-straight cliff on the trace with a dead flat apron below it and **43 m** of relief, against 45 m for no fault at all; the new one builds a dissected range front with its own drainage network at **97 m**. | [Terrain.md](Terrain.md) |
| D-61 | **The studio's canvas is a GPU texture, not a 2D canvas.** Asked for as "more modern and less laggy", and the lag had one shape: the template is 7738 x 3761, and the renderer put **all** twenty-nine million cells through the 2D canvas on every pointer event - a full-width `putImageData` band to push the stroke into an offscreen copy, then an `imageSmoothingQuality: "high"` downsample of the whole image, once per repetition across the seam. Both costs are linear in the size of the world and neither is a function of what the stroke touched, which is the diagnosis rather than a symptom: measured on one machine and one GPU, a 24-pixel brush and a 400-pixel one both cost **about 105 ms an event** at the zoom that shows the whole world, and a kilohertz mouse asked for it a thousand times a second. The world is a texture now. A stroke uploads **only the rectangle it touched**, read in place out of the `ImageData` with no intermediate copy, which is what WebGL2's `UNPACK_ROW_LENGTH` is for - and is why this is WebGL2 and not WebGL1, along with a 7738-wide non-power-of-two texture that still wraps and still mips. Drawing is one textured quad, and the seam comes free with `REPEAT`, which also fixes what the tiling loop could not: each repetition was a separate `drawImage` with texture coordinates of its own, so the derivative at the wrap was wrong and the seam blurred under minification. Measured after, same machine, same driving: **6.0 to 6.3 ms, flat**, at fit zoom with a 400-pixel brush, at 1:1, at 4x and panning - the floor of the measurement rather than a number about the renderer; the old path's worst case was a **1.4 s p90** at 1:1, the read-back stall `getImageData`/`putImageData` costs on a GPU-backed canvas. **Input is decoupled from drawing**: pointer events write down where the pointer was and one rAF loop owns the screen, so a thousand events a second become sixty frames, and `getCoalescedEvents` puts every sub-frame position into the stroke instead of throwing eleven of twelve away. **The mip chain is the minified view** and costs 4.6 ms to rebuild, so it is thrown at most ten times a second while the brush is down, never above 1:1 where nothing reads it, and once for certain when the brush comes up. **Both offscreen canvases are gone** - `full` and `ovFull` are the only copies of either sheet, encoded to PNG when pushed rather than mirrored in a canvas throughout, which is 230 MB not held - and the page **survives a lost GPU context** by uploading them again, where it used to leave a black rectangle with an unsaved painting behind it. What the author gets that is not speed: a **brush ring** in the colour about to be painted, because 400 px is 400 px at any zoom and the slider cannot say how much of *this* view that is; **eased zoom** holding the point under the cursor, because one notch is a factor of two and a jump gives the eye nothing to follow; `f` to fit and `1` for one cell to one pixel; space to drag; rendering at the screen's own pixels rather than CSS pixels, which on a scaled display had been a stretched image and a soft coastline; and a shortcut typed into a number field is a character again, which `o` needed most - it had been swapping the sheet under a half-written number. **And a stroke is an undo step.** Asked for straight after, and the same constraint decides the shape: a sheet is 116 MB, so a stack of snapshots is not a stack. The unit is **one stroke** - brush down, drag, brush up - because that is what a hand means by "take that back", not the last frame of it. What a step keeps is the pixels the stroke covered, by **copy-on-write over a 256-pixel tile grid**: a tile is copied out the first time a stroke writes into it, which makes the bookkeeping a `Map` lookup per stamp instead of rectangle algebra, and makes the cost of a step a function of what was painted rather than of the world. Measured: a dab is one tile and 256 KB, a 60-pixel drag two tiles and 512 KB, and a 400-pixel brush dragged 800 pixels 42 tiles and 9.1 MB; the cap is 192 MB, which bounds both stacks because a new stroke empties redo and a step *moves* between the two rather than being copied. `keepTiles` is called from the top of `stamp`, which is the only writer, so there is no path by which a pixel changes that undo has not already recorded. **Undo and redo are one function in opposite directions**: applying a step swaps what it holds against what is on the sheet now, which is simultaneously the undo and the construction of the record that redoes it. **One bug worth recording, because the test found it and reading would not have.** The tile grid is meaningful only inside `[0, W)`: 7738 is not a multiple of 256, so the last tile column is 58 wide and the grid does not line up with itself across the seam. Cutting the stamp's *unwrapped* rectangle into tiles and wrapping the indices afterwards gave columns 30 and 0 for a brush at x=2 that had also written into column 29, and those pixels were gone for good - restoring the sampled pixels looked right while a whole-sheet hash did not. The rectangle is wrapped into runs **before** it is cut into tiles now. Both sheets are on screen at all times, the annotation layer only dimmed and never hidden, so a step is visible whichever tab it belongs to and no tab is switched under anybody; a map view is dropped, because it is the one thing covering what just changed. `Decided`. Verified against the running studio through the DevTools protocol: the painted pixel agrees in the sheet and on the GPU, a stroke across the seam uploads as two rectangles and both ends take the colour, every screen column at 1:1 maps to exactly the right paint column on both sides of the wrap, the overlay eraser puts the alpha back, and plan still round-trips both sheets; and for undo, a stroke restores the sheet and the texture bit for bit, four undo/redo cycles are exact inverses, four strokes come back one at a time in order, a seam stroke restores both ends *and* the whole-sheet hash, undoing one sheet leaves the other untouched, the eraser undoes back to the mark, a new stroke drops the redo future, ctrl+z mid-stroke does nothing and the stroke is still undoable afterwards, and ctrl+z in a text field stays the field's own. | [Terrain.md](Terrain.md) |
| D-60 | **The coastal pass wraps, and a planet has a shore.** `internal/coast` ran on a flat grid, so a planet bake laid the painted sea floor and stopped - the land met the painted ocean depth in **one cell**, with no shelf, no surf-cut platform, no beach, no sediment budget and no exposure anywhere on the world. Every bake said so in its own log. What wraps is four primitives, three of them one loop each: `boxBlur`'s running sum, `fetch`'s ray march, `shelfWidth`'s inland march and the gradient of the distance field they both take their direction from; the distance transform already wrapped. **The abyss becomes a field.** A painted planet's sea classes carry their own `depth_m`, so the ocean is laid at 20, 120 and 512 m before this pass runs, and a derived slope bottoming out at one global number would have stepped to the painting wherever the two disagreed. The per-cell abyss also fixes the shallow case properly rather than by clamping: the break cannot be deeper than the water it is a break in, so a strait painted shallower than the shelf break is shelf all the way across instead of a trench. **And the pass stops being 608 MB of `supply`.** `Geometry.Ref` now holds an index into `Waterline` rather than a cell index, so every per-shore quantity is a few hundred thousand entries instead of tens of millions - the sediment supply was a `[]float64` over the whole grid, for an array only ever read at the waterline. `Measure` was restructured to hold **one** distance transform at a time rather than two, which needed one observation: a sea cell's stretch of shore is the stretch its nearest land cell already belongs to, so the second pass reads the answer out of `Ref` instead of out of the first pass's feature index. The waterline is taken straight off the mask, which is what frees the ordering. `boxMean`'s coverage is separable - `cx(x)*cy(y)` exactly, for any pass count - so it is two vectors rather than a field and a second blur; and the before-and-after snapshot is taken *into* the change map and subtracted in place. **One latent bug fell out of that last change and is worth recording**, because it is the kind that leaves no symptom: `deposit` computes `math.Pow(1-exposure, ShelterBias)` with a fractional bias, so an exposure over 1 by five parts in a hundred thousand - which the new float64 divisor produced where the old float32 one had not - gives NaN, and one NaN spreads through the drift kernel into the whole budget and comes out as a laid volume of NaN. Clamped at the point of use, because relying on a smoother a hundred lines away to bound its output is not an invariant. Measured on the 100 km template: the pass is **7.9 s over the whole 76-million-cell cylinder**, and the seam step in the sea floor went from a mean of 9.1 m and a worst of 523 m to a mean of **0.32 m**, which is what an ordinary column is - the residual is the template's own 9.4 % wrap disagreement and is an author's to fix. `Decided`. | [Terrain.md](Terrain.md) |
| D-59 | **Statistics are histograms that pool, not sorts, so a planet can be judged at all.** Terrain.md has said since D-53 that "statistics pool across regions rather than being computed per region and averaged" - and it was a rule with no implementation. Every function in `internal/stats` took a grid and sorted it, so a planet bake printed its elevation range and **nothing else**: no slope distribution, no per-uplift-class breakdown, no drainage density, no slope-area fit. The block the documentation calls the one that matters most was the one that could not be afforded. It cost twice in one session: the lowland slope distribution that settled "are the lowlands hilly" and the scarp measurement that settled "do faults survive the solve" both had to be taken by hand in Python, off a PNG. **The fix is one structure.** A fixed-bin histogram is O(1) a value with no allocation, its quantile error is bounded by the bin width, and - the property that actually matters - it is **additive**: summing two regions' bins and taking the quantile of the sum is exactly what one pass over both would have given, where a median of medians is not and an area-weighted mean of means is right for a mean and wrong for everything else. Each region builds an `Accumulator` while its grid is still alive (the composited planet has no uplift field or flow topology to recover them from) and they are merged in **region order**, because the bins are integers but the running sums are floats and float addition is not associative - rule 12 in the one place it could still leak. **Extent and ground are measured in different places, deliberately.** A region carries an ocean margin and two neighbouring margins overlap, so pooling "cells" across regions counts the same water twice and reports a land fraction that means nothing; `AddExtent` runs once on the composited cylinder while `Add` runs per region over the land that region *owns*, which `Partition.Cut` already marks disjointly. A partial run says so in the summary rather than leaving the two to be compared - found by running one, where the drainage density came out an order of magnitude low because it divided the channels of three islands by the land of a planet. **And local relief stops being O(radius squared).** `field.SlidingMin` joins `SlidingMax` and `LocalRelief` is the pair subtracted: the loop it replaces is 1.1e11 comparisons at a 500 m window on 28 M cells, which is not a slow diagnostic but one nobody would see the end of. Measured: 1.09 s for a 9 M cell region, about 120 ns a cell, so a whole planet is a few seconds once at the end of a two-hour bake. `generate` and `bake` now compute their statistics with the same code, so a number measured on the square canvas is comparable with the same number measured on a planet - which was not true before and nobody had noticed, because one of them printed no numbers. `Decided`. | [Terrain.md](Terrain.md) |
| D-58 | **A painting is a composition, not a world: the seed re-rolls the rock and the faults inside it.** Asked for as "reliefs and etc should also be regeneratable for the base image so we can have different faultlines". Half of it already worked - measured, seed 7 against 9342 on one painting moved 13.6 % of the uplift map, because the massif fabric, the swell, the initial relief, the crest lines and the coastline jitter are all seeded - and half of it did not exist at all: **faults and lithology were on the procedural path only**, so a painted planet had one flat erodibility inside every class and no structure inside any range. `map_erodibility.png` was a recolour of `map_class.png`, and two seeds differed on it only where the *coastline* had moved. Neither could be ported as it stood, for the two reasons D-53 and D-55 already establish. **`uplift.Build`'s lithology takes `f.Percentile()` of the grid it is handed**, which on a decomposed planet is two regions measuring their own extents and putting the same hillside in different rock; the cut is now a quantile of the **planet**, from the same fixed probe the massif fabric uses, which is why `measureFabric` is now generic. **And its fault centres are two calls to `Float()` read as fractions of that grid**, so the same fault would land somewhere different in every region; the set is now drawn once for the planet in world metres, and a region filters it to the traces reaching its own frame. A class carries `faults: {per_1000km2, throw_m, length_km}` - so an author says *this range is faulted and that plain is not*, which is both the control they wanted and what is true of the world - and `lithology_mix`, 0 to 1, so an ice cap has no bedrock province showing through it. **The softening is pointwise, in rank space**, not a blur: a blur is a neighbourhood operation and one near a region's edge reads cells a different decomposition would not have given it. The four defects recorded against the procedural faults in Terrain-Next 4.A2 are fixed rather than carried, because a fresh implementation with a known fault list is cheaper than a port plus repairs: a walked, heading-perturbed trace instead of an 8-point parabola (no polygonal distance contours), the throw tapered to nothing over the last sixth at each tip instead of stopping dead, long faults broken into en-echelon segments, and no clamp to a fraction of a global rate - the bound is the repose ceiling *or whatever the author painted*, whichever is higher, and the count of cells it binds is reported. 4.A3's straight-corduroy strike is fixed too: the orientation comes from a grain field sampled as a vector through `atan2`, never one global angle and never a value lattice read as an angle, which would jump a whole turn along its own wrap. **And the influence cut-off is subtracted rather than truncated.** At three gentle lengths the raw exponential is still 5 % of peak - 0.013 mm/yr on a 400 m throw, a fifth of a lowland's whole rate - which as a hard cut would be a step at a line six kilometres from every fault that the solve would carve into a scarp nobody placed. Subtracting the floor and renormalising also made the box an honest optimisation: with per-segment boxes hoisted out of the cell loop the pass went from 5.2 s to 1.5 s on a 6.3 M cell region. `--seed` is on `plan`, `bake` and `tiles` (the last because the detail passes hash it, so a tile run has to be told the seed its heightmap was baked under), through `fs.Visit` rather than a sentinel, because every sentinel is a seed somebody could want; and the studio has the seed and a Re-roll button beside the wavelengths. `Decided`. | [Terrain.md](Terrain.md) |
| D-57 | **A world is two paintings: the template, which is geology, and an overlay, which is everything placed on the finished world - and the one thing the generator reads off it is which coastlines to leave alone.** Every colour on the template is an uplift rate the solve answers for, which is the wrong place to say "a forest grows here", "this is the village", "a road follows this valley" or "I drew that shore deliberately": three of the four are not geology and the fourth is a constraint on a pass rather than a rate. There is no uplift rate for a town. So a second image, registered to the first, the same size, painted in the same studio, with a legend of marks of its own. **Blank is alpha and not a colour**: an overlay is a transparent sheet with strokes on it, which is what every image editor gives and what the studio writes, and reserving a background colour would spend one of the author's colours on nothing and break the moment they exported with a white matte behind it. An opaque pixel further than `match_distance` from every mark is **dropped and counted**, which is the opposite of the class legend, where every pixel must become something and the nearest always wins - the two rules are different because most of an overlay is nothing, and snapping a JPEG halo round a road to the nearest mark is how a halo becomes a road. **`coast_jitter` is the only thing any pass reads.** D-56's waterline roughening exists because a drawn shore is a smooth curve and a real coast is fractal, which is true of a shore nobody thought about and false of one traced off a map on purpose; the amplitude is therefore a *field* now, zero pins a stretch exactly as painted and above one chews it harder, so a fjord coast is a brush stroke rather than a global setting. **An unmarked cell is uninstructed rather than 1**, and takes its instruction from the nearest cell on the far side of the waterline - which `dt.Transform` has already found for the class-inheritance rule - because a mark lands on whichever side of the line the author's hand was on, and a stroke on the land would otherwise be overruled by the water beside it and the coast would move anyway. Verified exactly: an overlay painted `coast_jitter: 0` over every pixel gives a class raster byte for byte identical to a run with `coast_jitter_px` at 0. **Everything else is inert and travels.** Two bakes with and without a forest are the same terrain to the bit; what the marks do is come out as an 8-bit index raster beside every detail tile - indexed rather than one mask per mark, because marks cannot overlap on one painting, so 254 fit in the file one would have taken - and as features in world metres in `overlay.json`: an **area** gives a centre, an area, a radius and an extent per connected blob, a **path** is thinned to its geodesic diameter and gives an ordered polyline, because the thing built from a road on the other side is a spline and not a ribbon-shaped polygon. Wrapping is handled where it bites: a blob across the seam is one feature whose centre is a **circular** mean (a plain mean puts it on the opposite side of the planet), and a tile samples the overlay through **world metres** rather than a tile-local index, which is rule 1 of the tiling plan applied to a raster. The known limit is stated rather than hidden: one stroke is one path, so a fork reports its two longest arms as one line and drops the third; `terrain plan` prints the piece count per mark, which is where that shows. **And the plan's class table stops lying about steepness.** It printed the divide angle - exact, and the *steepest* ground a rate can make, since `S = U/(K*A^m)` is largest where `A` is smallest - as if it were the landscape. Measured on a 600² grid of 8 m cells with the manifest's own constants at 1000 steps, the median slope is a third of it in tangent and remarkably flat across a factor of twenty in rate (0.34, 0.33, 0.33, 0.32 at 0.012, 0.045, 0.080 and 0.250 mm/yr), and the P90 drifts 0.59 to 0.40 as the clamp starts to bind. The table now prints both and takes `reads as` from the median, so `highland` at 0.25 mm/yr reads as hill country at 11.7° rather than alpine at 32°, and `lowland`'s massif floor as a 0.6° plain rather than a 1.7° one. Same defect as D-55's, one level up: the number an author steers by has to be the ground they will stand on. **And the same defect once more, in the picture.** The complaint was raised against a preview, so the real continent was baked to settle it: region 12, 45.9 x 19.8 km, 9.0 M land cells, comes out 0..47 m with a median slope of 0.61 degrees, 4.4 % over three and nothing over eight - a plain, and never anything else. What made it read as an alpine massif is that the hypsometric ramp's top is `land_top_percentile`, **a percentile of the world being drawn**, so the whole ramp from green to snow was stretched over that continent's 32 m and its 40 m hills got the white caps a 2800 m range would. Redrawn against a fixed 400 m ceiling the same heightmap is flat green with four pale massifs on it. `palette.land_top_m` is that absolute ceiling, and `WritePreview` now returns the ceiling it used so every run prints which one the colours meant; the percentile stays the default, because an absolute ramp over a world with no mountains is a green shape with nothing legible on it. What was missing was never the option, it was the sentence. `Decided`. | [Terrain.md](Terrain.md) |
| D-56 | **The painted waterline is roughened before it is projected, and the template gets a painting tool of its own.** Two halves of the same complaint - the coastlines looked drawn, because they were. **The coast mask.** `coast_jitter_px` had been in the manifest, documented and defaulted, since D-53 and **nothing anywhere read it**; the painting went to the solve exactly as drawn. What it means now is a mask on the *signed distance* to the waterline: fractal noise is added to how far each cell is from the shore and the sign is re-read, so land juts out where the noise is positive and the sea reaches in where it is negative, at every scale the octaves cover. A domain warp was built first and thrown away - a smooth warp of a smooth boundary is another smooth boundary, just wigglier, and it cannot cut a bay at any amplitude that does not also drag every inland class boundary the same distance. A cell that changes sides takes its class from `dt.Transform`'s nearest-feature index, so sea eaten out of a shore becomes the surf that was lying against it; and the amplitude is capped per cell at two thirds of the widest land within reach, because a wavelength far larger than an islet moves the whole islet at once and an archipelago vanishes between two runs - measured, 2 of 12 without the guard. **And the classifier gained a despeckle pass**, because the mask made a pre-existing defect visible and load-bearing at once: a JPEG blend of `surf` and `lowland` lands at (186,219,174), whose distance to `desert` is 53.8 against 77.9 to either parent, so every temperate coast carried a one-pixel ribbon of spurious desert - 1607 px of it - and the mask handed that class to every cell it turned into shore. The test is spatial and has to be: the colorimetric fix (notice the pixel is on the segment between two classes, give it to the nearer) was built and measured and it reclassified 943 000 genuine `shelf` pixels, because `shelf` itself sits 10 units off the line between `ocean` and `surf`. A 5x5 majority removes a one-pixel ribbon and leaves a two-pixel band alone; it moves 0.068 % of the map. **And the studio.** `terrain studio` serves a painting tool on loopback where the brushes *are* the legend's classes, so picking `highland` is picking 0.25 mm/yr and the panel says that is 32 degrees and reads as alpine while you paint it. It paints hard-edged exact colours (an antialiased brush would manufacture the very blend the despeckle pass exists to remove), the canvas wraps at the seam because the world does, and `plan` is a button. A bake runs from it too and can be *watched*: `BakeOptions.OnRegion` fires as each landmass is composited, under a lock that stops the other workers, so the studio can draw the planet as it stands - the world fills in a continent at a time, and the first one answers "is this the world I meant" long before the last. `fluvial.Grid.SetCancel` checks once a step so it can be stopped, and a cancelled run is written to `Partial_NNN` rather than discarded, because the solve is per landmass and a region that finished is finished; it stays out of the `Bake_NNN` namespace because `tiles --bake` takes the newest of those. It saves by **patching the text** of the legend and the manifest rather than re-marshalling them - the same argument the palette writer already makes one file over: a legend is mostly commentary, and `MarshalIndent` over a `map[string]any` returns it alphabetised with every comment moved away from what it explained. `Decided`. | [Terrain.md](Terrain.md) |
| D-55 | **A painted class is two rates and a fraction, not one rate: the class rate is what a massif reaches, a floor is the plain between them, and one upland fabric for the whole planet decides which is where.** One class was one uplift rate, `n` is 1, and D-49 says the rate alone fixes the hillslope angle - so one class was one landscape. `lowland` at 0.08 mm/yr is 11.3 degrees on every divide it touches, and a 45 km continent painted with it came out as 45 km of continuous hill country with no flat ground on it anywhere. Europe away from the Alps is a plain at a fraction of a degree with isolated massifs standing out of it, and the difference is not the rate, it is that the rate is not the same everywhere. **`massif: {floor_mm_yr, fraction}`**: the class rate is re-read as the rate a massif reaches, the floor is the plain, and the fraction is how much of the class stands above the midpoint. **One fabric for the whole planet** (`planet.massif_wavelength_km`), cut by every class at its own level, so a highland belt and the hills in the lowland beside it are the high and low parts of one structure rather than two unrelated noises meeting at a painted edge; the wavelength must sit well below a landmass, because at 12.5 km against islands of 20-45 km one island came out entirely above the cut, which is the original defect over again. **The threshold is a quantile of the planet and never of the region.** A percentile of the grid is what `FromTemplate` exists not to do (D-53) - two regions taking quantiles of their own extents would put the same physical hillside on different sides of the cut - so it is a fixed 1024-column probe of the cylinder, binned, about ten milliseconds, and identical in every region by construction. The ramp is cut in **probability** rather than in the fabric's values, which is what makes `fraction` predictable from a JSON file instead of a property of the noise's distribution. **And a fraction is a share of the planet**, so it is only the *expected* share of an island: a small one gets all of a massif or none, as a real island on or off an orogen would, and normalising per landmass would hand every island its quota of hills. Also corrects the reading that caused it - `internal/stats` calls anything under 0.1 mm/yr "plain", which is a reporting bucket calibrated for the procedural path and not a description of terrain, and 0.1 mm/yr is a fourteen-degree hillslope; `terrain plan` now prints what every class *reads as* from its divide angle, and `coastal_floor_mm_yr` defaults to 0.02 rather than the 0.06 the same misreading set. `Decided`. | [Terrain.md](Terrain.md) |
| D-54 | **A painted class carries shape as well as rate: a coastal plain that puts the range inland, and a crater stamped onto the finished terrain rather than solved.** Three things a legend could not say before, each from looking at the first whole planet. **The coastal plain.** For `n = 1` the uplift rate alone fixes the hillslope angle (D-49), so a uniformly painted island sits at the angle of repose right down to the water: the rivers cut to sea level but the ground between them does not care how far from the coast it is, and the result is fjords everywhere rather than a range with a plain in front of it. `coastal_plain_km` ramps the rate from `coastal_floor_mm_yr` at the waterline up to the class rate over a stated distance. It is the deliberate, opt-in inverse of the taper D-52 removed, and the distinction is the whole point: that one was a *hidden* side effect of multiplying by the continent mask and it went to **zero** at the waterline, which flattened the hundred-metre strip the surf works in and moved every cliff inland. This is an author saying where their range starts, and the waterline keeps a real rate. **The crater.** An impact is an event, not a rate, and it cannot be expressed as one: a closed basin does not survive the solve, because the priority-flood runs every step and *raises* every depression to its spill level, so a crater built out of negative uplift is filled in before the run is a hundred steps old. It is stamped onto the composited terrain after the solve, in the same place and for the same reason the coastal pass runs there. The shape is derived from the painted blob rather than drawn — distance inward from the blob's own boundary, normalised by its widest point, is a coordinate running 0 at the shore to 1 at the centre whatever size and shape was painted — so `rim_m`, `floor_m`, `rim_at` and `wall_at` describe every crater on the map. A crater that straddles the seam is one crater, by the same wrap-aware flood the region partitioner uses. **And a class carries its own detail character.** At the geology grid a class is an uplift rate and an erodibility, and those two numbers cannot tell a desert from a wet lowland - both are "low ground, slowly rising". The difference is at two metres, so a class may override what the detail passes do on it: `droplets_per_cell` (a fifth of the running water thins the dendritic gully network out to isolated wadis, which is the single load-bearing one), `strata_contrast` (mesas and ledges keep their edges because nothing is rounding them off) and `amplitude_m` (flat ground with tens of metres of relief on it is a sand sea; flat ground with two is a plain). Everything left out keeps the pipeline's number, and a template whose legend overrides nothing does not even carry the class raster through the detail passes. **And how a world is drawn is a file of its own.** The hypsometric ramp, the water, the rivers, the ice and the light move out of `internal/field`'s constants into a palette the planet manifest points at, defaulting to the same numbers. It is separate from the legend on purpose: the legend says what the colours in the *input* mean and is about the world, while a palette is purely about the *picture*, changes no height - two bakes of the same world under two palettes are the same terrain - and is therefore the thing most likely to want swapping. `terrain palette ` writes the defaults out to start from; it is hand-formatted with its commentary rather than through `MarshalIndent`, which re-indents whatever a custom marshaler returns and so puts every channel of every stop on a line of its own. **Snow** is a display and material hint that no pass reads: the hypsometric ramp tops out at snow by *elevation*, so a polar cap fifty metres above the water came out the same green as a meadow, and a map that renders an ice sheet as a meadow is lying about the one thing it is for. `Decided`. | [Terrain.md](Terrain.md) |
| D-53 | **A world is a painted template plus a legend, the geology is solved one landmass at a time, and everything else runs once on the whole cylinder.** The source stops being a seed and becomes a picture: an author paints a flat cylindrical world map, a JSON legend beside it says what each colour means, and the simulation produces the terrain. **The legend carries an uplift rate and an erodibility, never a height** - a stream-power solve handed a painted surface erodes it into something else within a few hundred steps and throws away the drainage network that is the reason the generator exists, so painting a heightmap is refused rather than supported. X wraps and Y does not, so a landmass may straddle the seam and comes out whole. **The fluvial solve is decomposed per landmass, which is exact rather than approximate**: ocean cells are held fixed at sea level for the whole run and nothing in the solve can move them (`ComputeReceivers` makes every outlet its own receiver, and `StreamPower`, both diffusions, the repose clamp and thermal all skip a fixed cell), so no flow path crosses open water and every basin lives in one eight-connected land component. A region is that component dilated by an ocean margin, its bounding box taken round the cylinder; the border is water by construction, which is the invariant `TestBorderIsAlwaysOcean` exists for, and `TestOceanCellsAreUntouchedByTheSolve` now asserts the premise directly. Regions are found by dilating the land mask with one exact distance transform and connected-componenting the result, **not** by overlapping bounding boxes, which are transitively closed and collapse a real template into one region. **The coastal pass is not decomposed**: it costs tens of nanoseconds a cell against tens of nanoseconds a cell *per step* for the solve, and cutting it up would truncate the fetch across every strait, split the sediment budget whose conservation is the one thing in it not derived from something already measured, and leave the shoreline length and the exposure percentiles as statistics that do not pool. Decompose the solve, not the map. Consequences taken deliberately: the polar caps get a few rows of synthetic ocean above and below the painted map, because `isOutlet` treats the grid edge as an outlet and land there would freeze; a landmass that rings the planet is **refused**, because a region has to be a rectangle with water on both sides; the router jitter moves from a hash of the grid index to a hash of the world position (rule 1 of the tiling plan), which re-baselines every measured number on the square canvas; and the seed alone no longer names a world - the margin and the minimum landmass size do too, so both live in the manifest and in `meta.json`. The planet gets its own `RawContent/World/Planet.json`; `World.json` and the numpy pipeline are untouched. `Decided`. | [Terrain.md](Terrain.md) |
| D-52 | **The continent mask is thresholded, not multiplied, into the uplift field: where the land ends does not decide how fast it is rising.** The rate was `r * l` with `l` the smoothstep continent mask, so uplift tapered to zero across the shore and every coastline on the map was the lowest-uplift ground on the map by construction. Steady state is `S = U/(K*A^m)`, so ground with no uplift grades to no slope: the surf had a flattened strip to work in wherever it looked. The mask now answers only "is this cell sea", which is the yes-or-no the solve needs for its base level, and a range that runs out to the water rises at range rates right up to the waterline. Measured, seed 7 at 1400 with everything else held: surf cut 23.32 → 38.87 Mm³, planed 3.0 → 3.9 km², new beach 3.41 → 5.59 km²; seed 9342 the same in direction, 22.42 → 30.35 Mm³. `Corrects a measurement, not only the code`: the "mean cliff 2 m" that motivated this was a broken metric — it measured the drop from a cell to its seaward neighbour, which is a gradient, and at the angle of repose one cell of a 10 m grid is 7 m, so it could never have exceeded 7 whatever the coast did. Replaced by backshore height, the land's elevation one to two surf reaches inland, median and P90; on that metric the *old* build already read 88 m at P90, so the taper was never the difference between cliffs and no cliffs — it flattened the ~100 m strip the surf works in, so the cliff began a hundred metres inland instead of at the water. The slope–area fit moved −0.480 → −0.312 on seed 7 and −0.698 → −0.720 on seed 9342, in opposite directions and both inside a seed-to-seed spread that is several times larger, so it is not a regression; it is a warning that the fit at five or six bins cannot judge a single change on a single seed. `Decided`. | [Terrain.md](Terrain.md) |
| D-51 | **The coastline becomes a pass of its own, run after the fluvial solve, and the continent outline gains the octaves that make it a coast rather than a blob.** The coast was a line in a mask: the sea floor dropped to a flat plane at −180 m in one step, the land met the water at whatever angle the last erosion step left, and no process knew the shoreline was there. `internal/coast` adds three, each derived rather than drawn. A shelf, from an exact signed distance transform: gentle to a break, then a continental slope to the abyss, its width read off the relief standing behind each stretch of shore, so a plain gets a wide shelf and a range that reaches the water gets a narrow one. A surf, which 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, so its height is whatever the land behind it stands at. And a sediment budget: what the surf cuts is counted, carried a drift length along the shore, and laid in sheltered shallow water, with river mouths delivering their own load in proportion to what they drain, which is what makes a delta. It runs after the solve because two of the three need the finished terrain, and it owns the sea floor outright — `uplift.Result.Bathymetry` is gone, and ocean cells stay at sea level for the whole solve, which is the invariant that stopped rivers cutting to −180 m. Shelter is measured as effective fetch over the *seaward* half-space against fixed absolute anchors, not as a percentile of the map: casting in every direction counts the land behind the shore as shelter, and a percentile is a global statistic that two tiles would disagree about. Measured on seed 7: the outline's octave gain, swept with everything else held, gave 64 km of shoreline at 0.50, 81 at 0.58, 96 at 0.62 and 114 at 0.66, while the fetch's view of the median stretch of coast went 1.00, 0.98, 0.84, 0.51 — five octaves put the finest coastal feature at 450 m, which is a smooth blob with nothing to shelter anything from anything. 8 octaves at gain 0.62 is the setting that has islands, inlets and headlands without the outline breaking into speckle. The whole pass costs 83 ms at the manifest's geology grid. `Decided`. | [Terrain.md](Terrain.md) |
| D-49 | **Plains are made by lowering the intraplate uplift rate an order of magnitude, not by tuning erosion; and the hillslope law becomes Roering nonlinear diffusion with the repose clamp demoted to a constraint the diffusion smooths over.** Steady state is `S = U/(K*A^m)`, and with `critical_area_m2` at 0 that holds down to a single cell, so at K 5e-5, m 0.5 and a ~10 m geology cell an intraplate rate of 0.25-0.9 mm/yr puts *every divide on the map* at 32 degrees or past the 35 degree repose angle. Measured on the old numbers: 81 % of the land in the >0.5 mm/yr class, the plain class 1 % of it and all sea cliff. The plains were never over-dissected; they were being uplifted at mountain rates, and for n = 1 the uplift rate alone fixes the hillslope angle. `intraplate_mm_yr` 0.25 -> 0.03 and `intraplate_swell_mm_yr` 0.9 -> 0.08 keeps the ~2.5-fold swell that puts divides on a plain while making the mountain-to-plain ratio 30-fold, which is what real ones are; `relief.amplitude_m` lo 50 -> 15 so the plains do not start as hills the run must erode away. Measured after: plains 42 % of land at 0.8 degrees median and 9 m of relief over 500 m. The repose clamp stays in the step loop but nonlinear diffusion runs *after* it every step, because the clamp cuts along the eight D8 directions and leaves grid-aligned pyramid faces; a symmetric five-point stencil rounds them off before the next step. Clamping once at the end instead was measured and is worse. `Supersedes` the three-fold mountain-to-plain ratio D-47's uplift notes settled on. `Decided`. | [Terrain.md](Terrain.md) |
| D-50 | **The D8 router's flat-ground tie-breaks are jittered by a hash of the cell index, and `critical_area_m2` stays at 0.** Across a filled flat the only gradient is the priority-flood's millimetre of epsilon, applied in the order the flood happened to reach the cells, so the router draws the flood's own traversal geometry as ruler-straight diagonal rivers — the polygonal network that killed the first attempt at flat plains, and the dominant artefact once D-49 made the plains genuinely flat. The epsilon is scattered per cell by plus or minus half and the choice between two equally steep neighbours is broken by a hash of the cell and direction rather than by the fixed `dx8` order, both deterministic from the seed (cross-cutting rule 12). Measured: the slope-area fit went from R² 0.055 to 0.459 normalised, 0.039 to 0.664 raw, at no cost. A channelization threshold was re-measured after D-49 and still fails — at 1e4 the plains go back to 7.0 degrees and half the rolling class pins against the clamp — because the hillslope it creates must shed its uplift by diffusion and at D 0.02 it cannot. It stays at 0 until there is a transport law to pair it with. `Decided`. | [Terrain.md](Terrain.md) |
| D-48 | **The world's canvas becomes 7141 vertices at 200 cm, elevation −512…1536 m, and it keeps its coastline.** 7140 = 255 × 28, so the engine's importer gives 28×28 components of 255 quads by its own rule (D-45) and the side stays 14 280 m, identical to today's, so nothing `create_world.py` places moves; cells go from 3.5 m to 2.0 m. A 2048 m elevation span *is* Z scale 400, so the incoming spec's canvas is expressed in the manifest's metres contract with no loss, at 3.125 cm precision. The ceiling drops from 2800 m to 1536 m deliberately: 2800 m over 14 km is a Himalayan gradient, and 800–1500 m of relief is what a fluvial landscape this size looks like. `Supersedes` the 2600 m crests the noise was tuned for. The coast stays and sea level is the fixed base level on every ocean cell, which is a better-posed boundary for the stream-power solve than one outlet edge and closes the spec's own open question. A full run holds today's five-minute bar, which sets `fill_every` at 50. `Decided`. | [Terrain.md](Terrain.md) |
| D-47 | **The heightmap generator becomes a standalone Go CLI in `Tools/Terrain/`, and stream-power fluvial erosion replaces particle erosion as the thing that shapes the land.** Droplets carve the path a droplet takes; they do not make a drainage network, which is why the world reads as weathered noise rather than as terrain with a history. A Braun & Willett implicit solve over an uplift field on a coarse geology grid does, and plates, faults and lithology exist to feed it; particle erosion, thermal weathering and strata survive as detail passes at full resolution. The core is engine-free and deterministic from a seed (cross-cutting rule 12), and `World.json` stays the single contract, with its `erosion` block replaced by `pipeline`. Retires `heightmap_noise.py`, `heightmap_erosion.py`, `heightmap_io.py` and `generate_heightmap.py`, about 780 lines of tuned numpy whose lessons are carried into the port by name; keeps `create_world.py`, `dump_level.py`, `world_manifest.py` and `ULandscapeAuthoringLibrary`. `Supersedes` D-46 on the language and on which pass shapes the land. | [Terrain.md](Terrain.md) |
## 2026-09-17 — Off the ladder, the world's terrain is simulated, not only sampled
| # | Decision | Where |
| --- | --- | --- |
| D-46 | **The heightmap goes through geological passes after its source, in numpy, at the world's resolution.** Domain-warped ridged noise and cellular crest lines give the uplift; particle hydraulic erosion (a coarse pass for valleys, a fine pass for gullies), mass-conserving thermal weathering at an angle of repose and a strata hardness map shape it; flow, wear, deposition and curvature maps come out beside the weightmaps and drive the layer painting. All of it is engine-free so the source stays swappable, and `"enabled": false` in the manifest skips it for a real DEM. `Decided`. | [RawContent/World/README.md](../RawContent/World/README.md) |
## 2026-09-16 — Off the ladder, the world's terrain
| # | Decision | Where |
| --- | --- | --- |
| D-45 | **The world heightmap is 4081 vertices a side, not Epic's 4033.** The engine's importer picks the largest section size that divides the quad count, preferring one section per component; 4080 divides by 255 and gives 256 components, 4032 divides only by 63 and gives 4096. A resolution change stays on `255 x N + 1` or `127 x N + 1`. | [RawContent/World/README.md](../RawContent/World/README.md) |
| D-44 | **`L_World` is a product of a manifest, generated PNGs and one script, never hand-edited.** `RawContent/World/World.json` fixes the size and what a 16-bit value means in metres (world Z 0 is sea level); the height comes from a named source, seeded noise now, a real heightmap by editing the source block; the paint layers are always derived from the height. The level is dressed as Elite_RockyMeadows dresses its own maps, with the numbers read from those maps rather than guessed. `Decided`. | [RawContent/World/README.md](../RawContent/World/README.md), [Worklog](Worklog.md) |
## 2026-09-16 — Step 2, the telemetry seam
| # | Decision | Where |
| --- | --- | --- |
| D-43 | **The editor serves the engine's MCP plugin on `127.0.0.1:8000/mcp` whenever it is open.** `bAutoStartServer` in `Config/DefaultEditorPerProjectUserSettings.ini`, the port `.mcp.json` points Claude Code at, plus the `EditorToolset`, `AutomationTestToolset`, `GameplayTagsToolset`, `ConfigSettingsToolset` and `LiveCodingToolset` plugins. Loopback only; nothing listens when the editor is closed. Everything the plugin sends to an LLM is Licensed Technology under the UE EULA §6(e). | [CLAUDE.md](../CLAUDE.md) |
| D-42 | **The build string is `FApp::GetBuildVersion()` plus the git short hash read by `Salty.Build.cs` at build time.** A commit changes the definition and rebuilds the gameplay module; that cost is accepted so a telemetry file never lies about which build wrote it. `nogit` when git is unavailable. | [Telemetry](Spec/Telemetry.md) |
## 2026-09-16 — Step 1 closes the two open decisions
Taken when the project was created from the engine's Third Person template.
| # | Decision | Where |
| --- | --- | --- |
| D-41 | **The template's three gameplay variants stay in-tree as reference only.** `Source/Salty/Variant_*` and `Content/Variant_*` (Combat, Platforming, SideScrolling) compile and load but nothing new is built on them; they break the conventions (rules in Blueprint, casts, no server path) and are deleted when step 7 closes or the moment they block a build. The plain template character, game mode and controller are renamed `ATemplate*` and are the placeholder body until step 3. | [Architecture](Spec/Architecture.md) |
| D-40 | **The project is `Salty`.** `Salty.uproject` at the repository root, modules `Salty` and `SaltyCore`, test filter root `Salty.`. Closes OD-02. | [CLAUDE.md](../CLAUDE.md) |
| D-39 | **Unreal Engine 5.8, the launcher build.** Pinned in `Salty.uproject`; upgraded deliberately, never mid-step. Closes OD-01. Four Fab packs sit in `Content/` unused: `Medieval_Weapons` is expected at step 7, `RPGEnvironmentVFX` at step 14, `HouseForge_01` as dressing after 7, `Elite_RockyMeadows` serves no step on the ladder. A pack enters a step only when that step's spec names the asset. | [Steps](Steps.md) |
## 2026-09-15 — The stat block
Taken on the same day, after the first read of the doc set.
| # | Decision | Where |
| --- | --- | --- |
| D-38 | **Counters live on the receiver as tags and attributes**: immunity blocks by tag, resistance scales by attribute, cleanse removes by tag, and every status and every block is visible. The same status from several sources: strongest wins, longest remaining duration; different statuses multiply. | [Stats](Spec/Stats.md) |
| D-37 | **One stat block on everything with a body, and every outside influence is a gameplay effect on it.** Base values, buffs, debuffs, ground, carried weight, gear and being downed all go through the same effects; no system keeps its own multiplier. `Supersedes` the tagged speed-multiplier map the first draft of Movement gave the movement component. | [Stats](Spec/Stats.md) |
## 2026-09-15 — The move to Unreal and the reframing
Taken while rewriting the two earlier projects' documentation into this one. Everything below is `Decided`
unless marked.
| # | Decision | Where |
| --- | --- | --- |
| D-01 | **Unreal Engine 5, C++ first.** Blueprints compose, tune and decorate; they never hold a rule, a replicated property or a server RPC. | [Architecture](Spec/Architecture.md) |
| D-02 | **Two runtime modules**, `Core` (rules, data, tags, telemetry contract; no actors) and `` (gameplay). Dependency one way. A third arrives when a feature is stable, never before. | [Architecture](Spec/Architecture.md) |
| D-03 | **Server-authoritative from the first line; the dedicated server is the real target.** No listen-server design, no "are we multiplayer" branch. Tested with the editor's dedicated server option from step 1. | [Networking](Spec/Networking.md) |
| D-04 | **The client predicts only its own movement and its own animation.** Three responsiveness tiers assigned by one test: does a rollback have a visible victim? No rollback of world state a player can already see. | [Networking](Spec/Networking.md) |
| D-05 | **Ask, then show.** Commit-shaped moments wait for the server inside their own feedback beat. Never show a number as final before it is committed. | [Networking](Spec/Networking.md) |
| D-06 | **A player is an identity, not a body.** Class, attributes, cooldowns, discoveries and identity live on the player state; the character actor is lent. | [Networking](Spec/Networking.md), [Architecture](Spec/Architecture.md) |
| D-07 | **Persistence behind one provider interface with a local-file implementation.** Nothing is persisted yet; the seam exists first. | [Networking](Spec/Networking.md) |
| D-08 | **Gameplay Tags are the vocabulary.** `Config/Tags/.ini` is the source; code-referenced tags are declared natively. No string ids anywhere. | [Architecture](Spec/Architecture.md) |
| D-09 | **Content is `UPrimaryDataAsset`s** addressed by primary asset id or tag, with soft references for art. | [Architecture](Spec/Architecture.md) |
| D-10 | **Rules are pure functions in the core module**, tested without a world, called by both the client preview and the server verdict. | [Architecture](Spec/Architecture.md) |
| D-11 | **Telemetry from step 2**, one subsystem, one sink interface, JSON Lines to a local file, no personal data, events not aggregates. | [Telemetry](Spec/Telemetry.md) |
| D-12 | **`UCharacterMovementComponent`, extended**, not a custom controller and not the Mover plugin. Sprint is a saved-move flag; dodge and blink are root-motion abilities. | [Movement](Spec/Movement.md) |
| D-13 | **One rig, two camera modes.** First and third person on the same mannequin; the camera never enters the authority path; both modes exist from step 3; the default is decided by playing (OD-03). | [Movement](Spec/Movement.md) |
| D-14 | **The deadzone look model**, salvaged: the head turns freely inside a yaw deadzone, then the body follows; the deadzone widens while carrying. | [Movement](Spec/Movement.md) |
| D-15 | **Sprint takes Shift.** The earlier four-slot layout (Q, E, Shift, R) becomes Q, E, R, C. Interact F, drop G tap, throw G hold. `Supersedes` the earlier project's Controls decision. | [Movement](Spec/Movement.md) |
| D-16 | **The controller is built first and proved against a written checklist in a gym level** that never leaves the project. | [Movement](Spec/Movement.md), [Steps](Steps.md) |
| D-17 | **One interaction system.** An interface, a world subsystem as the funnel, the player's component as the only RPC path; reach re-checked server-side with tolerance; prompt and permission from one function. | [Interaction](Spec/Interaction.md) |
| D-18 | **Interaction is a discrete act on a fixed prop; carrying is continuous possession.** Pick-up is routed by the interaction key but is a carry verb. | [Interaction](Spec/Interaction.md) |
| D-19 | **Two hands; two-handed objects block interaction and, in first person, the view.** Held objects attach and stop simulating; dropped and thrown ones are server physics. Throwing is in and scrappy; nothing is destroyed by it. | [Interaction](Spec/Interaction.md) |
| D-20 | **The Gameplay Ability System** for attributes, abilities, effects, cooldowns and cues. The component is on the player state for players and on the pawn for enemies. | [Combat](Spec/Combat.md) |
| D-21 | **One damage funnel**: one execution calculation every damaging effect uses. **No friendly fire**: the funnel discards player-on-player damage and keeps the impulse. | [Combat](Spec/Combat.md) |
| D-22 | **Down before death.** Bleed-out, revive by anyone, a solo down is an instant wipe. A downed player is a prop. | [Combat](Spec/Combat.md) |
| D-23 | **Kits define ability access; gear defines stats and crosses classes; mods never grant a signature.** The `bSignature` flag exists from the first ability; gear and mods do not. | [Combat](Spec/Combat.md) |
| D-24 | **The recoverability contract** applies to any verb that inconveniences a teammate: recovery takes less time than the verb, and none may down, kill or deprive. All emit `grief_action`. | [Combat](Spec/Combat.md) |
| D-25 | **Sublinear party scaling and role relaxation** are reserved rules applied to a count. | [Combat](Spec/Combat.md) |
| D-26 | **Part, piece, trait.** A family declares parts; a piece fills one and carries three trait layers. No recipes anywhere. | [Crafting](Spec/Crafting.md) |
| D-27 | **"Material" is "substance"** in code and docs, because `UMaterial` is the engine's. | [Crafting](Spec/Crafting.md) |
| D-28 | **Substances are objects, not stacks**, with identity, quality and provenance. Affordable only because storage is physical; an abstract bank is refused on that ground. | [Crafting](Spec/Crafting.md) |
| D-29 | **Discovery unlocks, choice applies.** Characteristics are discovered through play and chosen at the station. Known set is per player. | [Crafting](Spec/Crafting.md) |
| D-30 | **One activity runtime, three guarantees**: completion never gated on skill; skill modulates a quality band only; a second operator is additive, never required. The anvil is the growing zone; the forge is a 5×5 bed with two levels of detail bound by an equivalence rule. | [Crafting](Spec/Crafting.md) |
| D-31 | **The assembly bench is domain-neutral; domains gate the work, never the worker.** Class changes speed and quality only. | [Crafting](Spec/Crafting.md) |
| D-32 | **Durability is per part, summed; the item is lost at zero; nothing wears from crafting.** | [Crafting](Spec/Crafting.md) |
| D-33 | **A crafted weapon-family item is a weapon**: both authored and crafted weapons produce one `FWeaponProfile` that the swing, the funnel and the bar read. `[PROPOSED]` until step 13 closes. | [Crafting](Spec/Crafting.md), [Combat](Spec/Combat.md) |
| D-34 | **UMG with CommonUI; one theme asset by role; prop text is world-space with no canvas; every readable string is `FText` from a string table.** The menu never pauses the world. | [UI](Spec/UI.md) |
| D-35 | **Steps, not a roadmap.** A ladder closed by proofs; the next few steps concrete, the rest sketched and detailed only when reached. | [Steps](Steps.md), [Design](Design.md) |
| D-36 | **The two earlier loops are not carried.** Their ideas are catalogued as features with needs and constraints, none scheduled. | [Ideas](Ideas.md) |
## Open decisions
Questions that block steps. Each names what it blocks and the recommendation on record, so closing one is a
confirmation, not a fresh discussion. When one is taken it moves up as a D-line and the row is marked closed.
| id | Question | Blocks | Recommendation | Status |
| --- | --- | --- | --- | --- |
| OD-01 | **Engine version.** | 1 | The newest release with a hotfix out (5.6.x at the time of writing). Pin it in `CLAUDE.md` and the `.uproject` in step 1; upgrade deliberately, never mid-step. | Closed, D-39 (5.8) |
| OD-02 | **Project and module name.** | 1 | Short, one word, no spaces. It becomes the `.uproject`, the module prefix and the test filter root. The docs write `` until it exists. | Closed, D-40 (Salty) |
| OD-03 | **Default camera mode.** | none until 8 | Build both in step 3, play the gym and the first fight in each, let `camera_mode_changed` and `movement_sample` settle it by step 8. | Recommended |
| OD-04 | **Engine from source.** | the first packaged server | The launcher build until then; the editor's dedicated-server option covers every step in the ladder. | Recommended |
| OD-05 | **Placeholder character and animations.** | 3 | The engine's Third Person template mannequin and its animation blueprint; a retargeted pack only when the swing needs a clip the template lacks. | Recommended |
| OD-06 | **Physics replication mode for thrown objects.** | 6 | The engine's predictive interpolation mode; resimulation is not needed for objects nobody predicts. | Recommended |
| OD-07 | **Which two kits ship first.** | 9 | Warrior and Cleric: a taunt and a heal are what make the goblin's threat table and the downed state legible. | Recommended |
| OD-08 | **Crafting professions against combat kits.** | after 15 | A design session, not a step. See Ideas. | Open |
## Deferred, with triggers
Not gates. Listed so nobody reopens them as if they were undecided.
| Deferred | Until |
| --- | --- |
| The Mover plugin | It leaves experimental and a movement feature needs what it has (Movement Q2) |
| Stamina as a cost | A kit wants one (Combat Q2); the attribute exists so it is a cost effect, not a refactor |
| A blade sweep instead of the box hit check | Real animation makes the box read wrong (Combat Q4) |
| Storage beyond hands and station buffers | A step needs more than hands can hold (Crafting Q4, Ideas) |
| Alloys and the kiln | Post-fifteen; meanwhile nobody hard-codes one-to-one processing (Crafting Q5) |
| Generated glyph discovery | Its own session; the composition path never changes, any seed is global |
| Reconnect into a running session | Written down before the first playtest with strangers (Networking Q1) |
| Seamless travel, a second map | The second map |
| Replication Graph or Iris | A world with many actors and many players; dormancy and relevancy keep it cheap |
| Steam, lobbies, invitations, voice | A session layer in front of the server; never inside gameplay |
| Continuous integration | A reason for it; tests run by hand until then |
| Resistances per damage type beyond physical and magic | A second elemental damage type (Stats Q2) |
| Predicting surface effects on the owning client | The correction on entering mud is felt (Stats Q1) |
| A stat block on props that are not bodies | The first prop that should burn or freeze (Stats Q3) |