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