98 KiB
Worklog
One line per item, newest at the top of each section. What was done, what worked, what did not. Keep it terse; the reasoning lives in the specs, this is the memory.
Done
-
2026-09-20 - Phase 2 closed: seven layers, end to end.
ULandscapeAuthoringLibrary::CreateLayerInfobuilt and the four layer infos created with the rightLayerName. Verified per layer - layer info, parameter prefix and substance - plusweightmap_entriesresolving 7 of 7, which is the call a level build makes per tile. The verification earned itself twice.LandscapeLayerInfoObject::LayerNameturned out to have a publicSetLayerName(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, becauseLayerBlendInput.layer_inputis exposed to MCP but not to Python. -
2026-09-20 - Phase 2, most of it: the material blends seven layers now.
build_ground_material.pyextends 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: theLandscapeLayerBlendcarries 7 layers, all wired,MF_Ground_Beachand 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:SlateInspectorToolsetcan type into the Output Log's command box, andpy <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.argvand__file__are set explicitly in the one-liner becausepyguarantees neither. Two engine limits found the hard way, both silent.ObjectTools.set_propertiesover MCP does nothing on a Texture2D - returns success, changes nothing, no error - which nearly passed review because the read-back looked right: a_Normalsuffix makes UE set TC_Normalmap and sRGB off by itself, so only the Roughness maps exposed it. AndULandscapeLayerInfoObject::LayerNameis 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 nowULandscapeAuthoringLibrary::CreateLayerInfo, in the editor module for the same reasonCreateLandscapeFromHeightmapis - 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.uassetamong them, so the Content Browser could not see any of it. Moved toRawContent/Terrain/Source/and untracked - raw input belongs there,Content/Terrainis 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 substancesnow 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.Junglewas 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.pyonly duplicates assets already in/Gameand 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'slayersblock now carriesbiomesand an orderedpaintlist - eight layers, five of them not enabled until there is a substance - andmapart biomeswrites one blurred mask per biome thatgenerate_region_tiles.pysamples 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 exporteddesertsits 53 from legendiceand 60 from legenddesert- 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 ofkoppen.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.jsonis alreadyocean, deep, ice, lowland, highland, desert, craterwith an RGB each, and that map exists at planet resolution inPlan/, in every bake and inOrogen Gens/, registered to the world by the sameu,vas the heightmap. Tropical is the only one on the list that is not painted - it comes from the climate export- and
forestis 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 areLandscapeGrassOutputrather 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.
- and
-
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.mdand 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 (whatL_Worldis), Orogen's direct tile export (built, better, unused), andterrain bake->terrain tiles(the point of the generator, not wired). Writing it turned up two stale claims inRawContent/World/README.md: its "what it is today" table still described the 6 x 6, 30.60 km, 22.583 m/px window, whereRegion.jsonhas 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, soL_Worldis the planet map and world-to-map isu = (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'sL_Canvas_Protoshares nothing with this and was not targeted. Checked that the colormap is the same planet:orogen-colormap-14733759againstorogen-heightmap-7945reads 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 checkis that test kept, and it is agreement rather than a hash because every layer is a different render of one planet. OneSWorldMap, three ways in. Slate rather than aUUserWidgetbecause the editor tab has noUWorldat all;UWorldMapWidgetwraps it for UMG, the tab hosts it directly,bs.WorldMapputs 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/MapArtrenders the art, in Go, because the engine's Python cannot decode 33 megapixels of RGB -heightmap_io.pyis 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.reliefis 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 bugsgo vetcaught in the manifest struct:X, Y int \json:"x"`gives *both* fields the tag, sowindow.yandwindow.heightwould never have been read. **Built, tested and imported.**SaltyEditorcompiles clean across all three modules; 10 of 10Salty.Core.*tests pass;build_world_map.shwrites four 4096x2048 textures andDA_WorldMap_L_WorldintoContent/World/Maps/and reads them back. Three things cost time and are worth knowing. **EditDefaultsOnlycannot be written from Python**, because it means edit-on-default-only and aUDataAssetis an *instance* - so every property on the definition isEditAnywhere, 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. Directsetattris 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. **AnFSoftObjectPathreprs as{}in Python whether it is set or not**, because its fields are notUPROPERTYs. A first verification pass reportedlevelempty and it was never empty -export_text()says/Game/Maps/L_World.L_Worldand the asset registry lists/Game/Maps/L_Worldas a dependency of the definition. Comparing a Python enum withstr()lied the same way:str(TA_WRAP)is"<TextureAddress.TA_WRAP: 0>", not the name. Both traps are now insidecreate_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_Worldis 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'slevelis/Game/Maps/L_World;World.json's is/Game/Maps/L_Canvas_Proto. Nothing else was entangled -L_Worldwas named only byWorld.jsonandworld_manifest.py's default, no gameplay code refers to either level, and both default maps areL_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_nameis its last segment plus the coordinates), so changinglevelmade all 98 tile sets look missing and would have regenerated 208 MB that already existed - the 392 files were renamed instead.build_region.shreads 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 becameL_Canvas_Proto_*.png, becauseL_World_Height.pnginHeightmaps/besideL_World_x0_y0_Height.pnginRegionTiles/is two files differing by a tile suffix. The old canvas is repointed, not deleted, and the different name is the point:create_world.pyempties whatever level it is handed, so a manifest still sayingL_Worldwould 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_Worldemptied 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 onWorld_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_GREYis 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:BasicShapeMaterialis the near-white that once read as an ice sheet to the horizon (2026-09-16) andWorldGridMaterialputs a metre grid on a plane 70 km across. The part worth keeping:ensure_dressingspawns 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_seanow re-applies the material on every run, andfix_sea_material.pyrepaints a finished level and saves it, so changing one material reference does not cost a rebuild of 98 landscapes. Applied toL_Regionand 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_pixelhad 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 theRegion.jsonthat describes them, written into a folder through the File System Access API - so the files land inRawContent/World/RegionTiles/andcreate_region_world.pyreads 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 rideheightmapColor'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.valuereturns empty, making the setting NaN and the export a tile set of nothing. Caught in the first screenshot. An existingRegion.jsonis 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 asRegion.generated.jsonand 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 retiregenerate_region_tiles.py, which is the path that will readterrain 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 holdingL_Regionopen: batch 0 emptied the level, batches 0-3 built twelve tiles, and batch 4's save died onMoveFile … (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.umapwithopen(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_startstraced 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 returned0.0, which is notNoneand 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_FOGis 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_fogdivides 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_worldstraight down at the origin hits at Z = 0, the sea plane. The 36Landscapeactors are always-loaded and carry zero components;ChangeGridSizemoved 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 inpage.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, becauseCheckBakecompares only how a heightmap is encoded and two paintings of one planet agree on all of it -BakeIsOfThisPaintingnow 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 ofcreate_world.pyintorocky_meadows.pyso 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 atPlanet.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 bakemakes this same continent a 116 m plain - so the relief is art;sea_scale0.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.gradienttakes 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:- 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.shruns 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. - Arguments never reached the script.
UPythonScriptCommandlet::Mainreads-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. - 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
-abslogthroughout.UnrealEditor-Cmdalso 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, soL_World's sea has been a grey shape material rather than water. Not yet re-verified by a rebuild.
- 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
-
2026-09-20 - Off the ladder:
Content/Terrain/collects the ground out of the asset packs (D-69).RawContent/Terrain/ground.jsonnames fourteen assets - six textures, three layer functions, the master material, its instance and three layer infos - andScripts/Authoring/collect_terrain_assets.pycopies 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 outsideContent/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:Expressionsis protected on UMaterial and absent on UMaterialFunction, so it has to come fromMaterialEditingLibrary; a function needsget_material_function_expressions, sinceget_material_expressionsrefuses 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_RockyMeadowsstill names the pack'sT_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 fromContent/Terrain; the pack cannot be deleted until that one is cleared. -
2026-09-20 - Off the ladder:
terrain overlayproposes an annotation layer from a bake (D-68).internal/overlay/generate.go+generate_roads.go+internal/planet/overlaygen.go, and agenerateblock 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_jitterchanges 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 untilnot_classesexisted. Measured on Map3 against Bake_022: 17 s, 3.88 M painted px kept, 2.48 M added, andterrain planreads 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.jsisplan.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 againstRawContent/World/Plan/plan.jsonon Map3, five land classes and the clamp ceiling all to 1e-9. (2)js/painted-overlay.js+painted-overlay-view.jscarry 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 forcoast_jitter, which pins or roughens the shore. (3)Planet.jsonis read directly and outranks a legend'splanetblock, bringing the pipeline constants the angles need. PlusTools/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 toplan.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.jsonasterrain planand 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 toedge_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, aplanetblock in the legend for the Planet.json numbers,assets/painted-legend.jsonand a quarter-sizeassets/painted-demo.pngmade 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.md4.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 inmap_flow.png, absent frommap_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 bothBake_x4at 32 m andBake_020at 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 forAccumulateonly, 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,DiffuseNonlineargoes to the isotropic nine-point stencil because the clamp cuts across eight faces and a five-point smoother cannot transport across a diagonal one, andfield.SmoothEdgePreservingis 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_020measured: the legend paintsoceananddeepat 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.Buildwants aBreakM, 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.AbyssMhas been per-cell from the painting since D-60; the break never was. What hid it: the derived margin reachesshelf_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_mis 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 ownshelfclass 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 intiles.go, both commented "the shelf break":restoreSeaFloorwould 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, soclip_fractionnever saw it.Planet.jsonuntouched; ~-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.FaultDeltaaccumulates 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. NowsoftStack: 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 keepsTestTwoFramesAgreeAboutTheSameFaultstrue - 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.goscaled the symmetry-breaking noise byrate/maxClassRatewith 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 inBake_013too 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 = 200andfaultGentleM = 2000inpainted_faults.gowith 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 turningthrow_mdown 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.tipTaperwas a flat top over the middle two thirds, which extrudes the cross-section along most of every trace; it is a bell now. Andthrow_mis 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.TestAFaultIsSolvableRatherThanPrintedis 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
putImageDataband, 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 throughUNPACK_ROW_LENGTHwith no copy, drawing is one quad, and the seam isREPEATrather 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,getCoalescedEventskeeps 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,fto fit,1for 1:1, space to drag, rendering at device pixels, and shortcuts that stay out of input fields -ohad 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, whichplan's SEAM check already reports. Andctrl+ztakes back a stroke (ctrl+shift+zorctrl+yputs 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.keepTilessits at the top ofstamp, 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 ispushRect'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/coastran 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.Refholds 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.Measureholds 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 readsRefrather 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.depositdoesmath.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/statssorted 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;AddExtentruns once on the composited cylinder,Addruns 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.SlidingMinandfield.LocalReliefjoinSlidingMax, 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.generateandbakeshare 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.pngwas a recolour ofmap_class.pngand two seeds differed on it only where the coastline had moved. It is 24.9 % now. Neither ported as it stood. The old lithology takesf.Percentile()of the grid it is given and the old fault centres areFloat()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 (measureFabricwent generic), and the fault set is drawn once in world metres with each region filtering to the traces that reach its frame. A class saysfaults: {per_1000km2, throw_m, length_km}andlithology_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.--seedon 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.TestAFaultLeavesAScarpAfterTheSolveis 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/overlayis 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_jitterscales 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 amap_class.pngbyte 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 inoverlay.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, andplanprints 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 andreads ascomes from the median, sohighlandis 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 thatpreview.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_mis 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_pxwas 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, andfield.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 fromdesertand 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 realshelfpixels, becauseshelfsits 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,planis a button (7.5 s round trip), and it saves by patching the text of the legend and manifest so their commentary survives -MarshalIndentover a map returns the file alphabetised. Then two notes from using it. Saves are versioned and never overwrite -Map3_001.png,Map3_002.png, asBake_NNNalready 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 isregion.Buildat 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.OnRegionfires 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 overres.Height/res.Flowrather thanPainted(), which allocates 300 MB a call.fluvial.Grid.SetCancelchecks once a step, so a bake can be stopped - and a cancelled run is written toPartial_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 theBake_NNNnamespace on purpose, sincetiles --baketakes 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.
lowland0.08 mm/yr is 11.3 degrees on every divide it touches. Nowmassif: {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 whatFromTemplateexists 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, sofractionmeans what it says whatever the noise's distribution is.map_uplift.pnghad 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/statscalling under 0.1 mm/yr "plain" is a reporting bucket, not terrain, and 0.1 is a 14 degree hillslope - soterrain planprints what a class reads as from its angle, andcoastal_floor_mm_yrdefaults to 0.02 not 0.06. Five tests ininternal/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.pngpoint-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)Kcell = 0.280 mm/yr at an 8 m cell;
terrain planprints the angle per class now. Did not work, in order: blamed the strata hardness (turned it off, nothing changed), then the tile hillshade (WriteThumbnailsaturates to black and white at 2 m a cell - a real bug, now a proper DEM hillshade, but not the cause). What settled it wastiles --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 planmeasures 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/detailandinternal/tile, ported fromheightmap_erosion.pywith every brake by name, plusterrain 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 berounds 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.FixedBandsis a partition fixed by the grid rather than the machine.
- 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 -
-
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 ofcoastand given a cylinder),internal/template(image, legend, classifier),internal/region(the partition) andinternal/planet(the driver), plusterrain planandterrain 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 -TestOceanCellsAreUntouchedByTheSolvenow 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 onfield.Field, because a Field is used for masks and scratch that have no position and would all quietly claim to sit at the origin. Also: the router jitter moved from a hash of the grid index to a hash of the world position (rule 1 of the tiling plan), done first and on its own because it re-baselines every measured number on the square canvas; and every image writer turned out to be silently square, which is invisible at 1:1 and squashes a 2:1 planet. -
2026-09-17 - Off the ladder, the coast, part two: the uplift field stops being multiplied by the continent mask (D-52), so a range that reaches the water rises at range rates right up to the waterline instead of being tapered to nothing across the shore. Surf cut 23.3 → 38.9 Mm3 on seed 7 and 22.4 → 30.4 on seed 9342; high ground now meets the water in the detail crop where it previously had a uniform low fringe in front of it. New
TestBorderIsAlwaysOceanininternal/check, because the margin that keeps land off the map border was relying on that same multiplication as a side effect: the border is still all ocean on three seeds, so there is no frozen rim. Did not work, and it was mine: the "mean cliff" statistic measured the drop from a cell to its seaward neighbour and called it a cliff. That is a gradient - one cell of a 10 m grid at the angle of repose is 7 m - so it could not exceed 7 whatever the coast did, and it read 2 m on a plain coast and 3 m on a cliffed one. It is why yesterday's brief claimed there were no sea cliffs anywhere; the replacement, backshore height at one to two surf reaches inland, shows the old build already at 88 m P90. A cliff is how far you fall, not how steep the first cell is. Also measured: the slope-area fit is too noisy at five or six bins to judge one change on one seed - the same code gives -0.480 on seed 7 and -0.698 on seed 9342 - and 14 to 15 % of every coastline is drawn by the map margin rather than by the noise, which is a straight line parallel to the map edge and is pre-existing. -
2026-09-17 - Off the ladder, the coast.
internal/coast, running after the fluvial solve, on the terrain the solve produced. An exact signed distance transform with a feature index (Felzenszwalb, two 1-D passes) is the coordinate everything is written in; on it sit a continental shelf whose width is read off the relief behind each stretch of shore, a surf that planes the land to a shore platform within a reach set by how open the water is - the cliff is the step where the reach ends - and a sediment budget that carries what the surf cut a drift length along the shore and lays it in sheltered shallow water, with river mouths delivering their own load.uplift.Result.Bathymetryis gone: the coast pass owns the sea floor, and ocean cells stay at sea level for the whole solve. New:map_exposure.png,map_coast.png, a coast block inmeta.jsonand in the summary,--no-coastand six other overrides, nine tests, and the determinism test now covers the pass. The measured finding was the coastline outline: at five octaves its finest feature is 450 m, which is a blob, and the fetch called the median stretch of coast fully open. Swept, seed 7 at 1400: shoreline 64/81/96/114 km and median exposure 1.00/0.98/0.84/0.51 at outline gain 0.50/0.58/0.62/0.66. D-51 sets 8 octaves at 0.62. Whole pass 83 ms. Did not work: a percentile stretch for exposure (degenerate on an even coast, and a global statistic two tiles would disagree about); casting fetch in every direction rather than seaward only (it counts the land behind the shore as shelter, so a straight coast scored more sheltered than a bay); a cut fraction read as a fraction of the height above the platform (15 % of a 120 m headland is 18 m, which is not a platform);dep = blur(supply) * want / blur(want), which looks like a normalised convolution and lost 68 % of the sediment budget - the supply blurred onto land and deep water where nothing wants it was silently dropped, and the conserving order is to divide by the blurred want first, then blur; and one kernel for both jobs - the sediment needs zero padding to stay symmetric and a carried value needs edge clamping, and using the mass-preserving one on the shelf width shrank every shelf near the border to nothing. -
2026-09-17 - Off the ladder, the plains problem. The lowlands read as shrunken mountains because they were being uplifted at mountain rates:
S = U/(K*A^m)applied down to one cell puts a 0.25 mm/yr divide at 28 degrees and the 0.9 mm/yr swell past the 35 degree repose clamp, so the clamp was the surface of the continent (81 % of land in the >0.5 mm/yr class, the plain class 1 % and all sea cliff). Builtstats.UpliftBucketsfirst, to measure per uplift class rather than map-wide - that is what made it visible. Then D-49 (intraplate 0.25 to 0.03, swell 0.9 to 0.08, relief lo 50 to 15 m) and D-50 (hash-jittered flood epsilon and D8 tie-breaks, for the flat ground the fix exposed). Plains are now 42 % of land at 0.8 degrees and 9 m relief; slope-area R2 0.06 to 0.46. Then Roering nonlinear hillslope diffusion ininternal/fluvial/hillslope.go, running after the repose clamp each step so the clamp's D8 pyramid facets get rounded off: mountains 31.1 to 28.5 degrees and 44 % to 33 % pinned at talus, at 8 % more runtime. Four tests on it. A run now also writes uplift, erodibility, slope, relief, flow and basin maps. Did not work:critical_area_m21e4, re-measured after the uplift fix and still failing (plains back to 7 degrees, 79 % of mountains at the clamp) - it needs a hillslope transport law to pair with; clamping once at the end of the run instead of in the loop (facets returned). Still open: half the land is still in the mountain uplift class; fault traces and the range grain are still visibly straight lines; MFD accumulation unbuilt. -
2026-09-17 — Off the ladder, build-order steps 3 and 4 of
Terrain.md: the Go core exists and the stream-power solver works.Tools/Terrain/(modulesalty/terrain,Scripts/build-terrain.sh, binary andRawContent/World/Preview/gitignored):field(the one array type, vertex-convention resampling, Catmull-Rom upsample, greyscale PNG throughimage/png, hillshaded thumbnails, andfield.Rowsas the only place goroutines are made),manifest(World.json over Go defaults, the same height contract asworld_manifest.py, and a validator that refuses a resolution the importer would turn into thousands of components),noise,uplift(the continent, now producing an uplift rate rather than terrain),fluvial(priority-flood, D8 receivers, stack, accumulation, implicit Braun & Willett update, sub-stepped diffusion),statsand two integration tests. The proof: the analytic checkK*A^m*S^n/Umeasures 1.0000 over 822 channel cells, and on the continent the slope-area exponent is -0.498 against -0.500 at R2 0.996 with drainage density 1.33 /km. Determinism is asserted across five values of GOMAXPROCS, not assumed. Nothing in the editor was touched (it was open), so steps 1 and 2 are still first and still open; the numpy pipeline still buildsL_Worldand is unchanged. Three bugs, all of which produced plausible-looking terrain and none of which could have been seen by looking: slope-area measured with the topographic gradient instead of the channel gradient read -0.78 where the truth was -0.50; cells at a local minimum were skipped entirely including their uplift, which froze exactly the basins that differential uplift was forming; and the fix for that then uplifted the outlets, because a map border is an outlet but is not ocean, so base level rose 2 m a step and the steady-state ratio read 0.03. The last one was invisible in real runs, where the border is ocean, and only the test caught it. -
2026-09-17 — Off the ladder, no code: an incoming procedural terrain specification (tectonics, faults, lithology, stream-power erosion, a Go core, an editor bridge into a Landscape edit layer) reconciled against the built pipeline in
Terrain.md, and the Go core taken as D-47. What the reconciliation found: the spec's 7113 canvas is a trap, because 7112 divides by 127 and not by 255, so the importer's own rule gives 3136 components rather than the spec's 784 — the 4033 mistake again (D-45); 7141 at 200 cm gives 28x28 components of 255 quads with the same 14.28 km side the manifest already has. The spec's Z scale 400 is exactly a 2048 m elevation span, so its canvas is expressible in the manifest's own metres contract, at the cost of dropping the crests from 2800 m to 1536 m (Q2). The spec's uplift/K parameter ranges and its elevation budget are not jointly consistent at their corners: steady state puts slope at U/(K*A^m), and the aggressive corner overshoots 1536 m badly, so U/K is the one relief knob and the clip fraction is the check. The spec has no coast at all and leaves base level open, where this world has one and a sea plane (Q1). Lithology (plan view) and strata (vertical) are orthogonal and both kept. The spec's §8 DEM escape hatch is already built and better specified than the spec's version. Deferred with triggers: the preset gallery widget,UTerrainPreset, river splines, build-zone volumes, any GPU port. The five open questions were put to the user the same day and all five went the recommended way (D-48): the coast stays and sea level is the base level, the ceiling drops to 1536 m, the canvas goes to 7141 at 200 cm, and a full run holds the five-minute bar. Go 1.25 turned out to be installed already, on 16 cores, so the toolchain cost was zero. Nothing is built; the ladder is untouched. -
2026-09-17 — Off the ladder, third pass: geological simulation in the generator (D-46).
heightmap_erosion.py, numpy only: particle hydraulic erosion in vectorised batches, a coarse pass on the 4x-downsampled map for the valleys and a fine pass at full resolution for the gullies; mass-conserving thermal weathering at a 35° angle of repose; strata hardness scaling the erosion; flow, wear, deposition and curvature maps written as PNGs and used by the layer rules. The uplift gained Worley cellular crest lines through the same domain warp. The manifest'serosionblock drives it;"enabled": falsefor a real DEM. Two things the smoke test caught: droplets stepping together in a batch and crowding one cell run away to infinity without a per-step cap, and thermal weathering that sheds half the mean excess converges far slower than half the largest excess. -
2026-09-16 — Off the ladder, second pass: the world's terrain is now a manifest,
RawContent/World/World.json(D-44).world_manifest.pyderives the landscape's Z scale and Z offset from an elevation range in metres so world Z 0 is sea level;heightmap_io.pyreads 16-bit PNG and raw r16 with numpy alone and resamples;heightmap_noise.pybuilds the continent in metres (retuned: 80 % of the land under 15°, ranges on a fifth of the map);generate_heightmap.pytakes either the noise or a real heightmap named in the manifest and derives the three Rocky Meadows layers from the height by slope and altitude, so a real heightmap needs no weightmaps.create_world.pydressesL_Worldwith the pack's kit asdump_level.py(new, dumps any level's actors to JSON) read it fromRocky_Meadows_01: sun with the cloud-shadow light function, skybox dome, sky light, height fog, fixed exposure and saturation; plusWorld_Sea_Proto, a plane with the engine's water material. Resolution moved from 4033 to 4081 so the engine's importer picks 16x16 components of 255 quads instead of 64x64 of 63 (D-45): the import takes two minutes instead of forty. -
2026-09-16 — Off the ladder, at the user's request:
L_World, a world-partitioned ~200 km² landscape from a seeded heightmap.Scripts/Authoring/generate_heightmap.py(numpy, inScripts/Authoring/.pylibviabootstrap-pylib.sh) writes 16-bit height and 8-bit weight PNGs toRawContent/World/Heightmaps/;create_world.pyrebuilds the level from them throughULandscapeAuthoringLibraryin the newSaltyEditormodule, because the engine exposes no landscape creation to Python. Elite_RockyMeadows' landscape material and three layer infos. Swap the terrain by replacing the PNGs and rerunning. Nothing in gameplay references it. -
2026-09-16 — Step 2:
ITelemetrySinkwith null, log and JSON Lines sinks;FTelemetryEvent, the envelope andTelemetry::ToJsonLine;TelemetryEventswith the four session names;UTelemetrySubsystem;ASaltyGameModeminting the session id intoASaltyGameState;bs.TelemetryTest; the git hash in the build string (D-42); four Core tests. Proved standalone, and with a headless-serverplus-gameclient sharing one session id. -
2026-09-16 — The editor's MCP: the engine's experimental plugin only listens when
bAutoStartServeris set, so.mcp.jsonrefused untilDefaultEditorPerProjectUserSettings.iniset it (D-43). Enabled the Editor, AutomationTest, GameplayTags, ConfigSettings and LiveCoding toolsets; before that the only toolset was skills. Reconnect with/mcpafter the editor is up. Live Coding must be closed before a shell build. -
2026-09-16 — Step 1:
Salty.uproject(UE 5.8) at the root from the Third Person template, renamed fromProjectSomethingwith class redirects;SaltyCoreandSaltymodules;SaltyServer.Target.cs(unbuildable on the launcher, OD-04);USaltyAssetManagercallingInitGlobalData;Config/Tags/with 23 root namespaces; Gameplay Abilities, CommonUI and Python enabled;Scripts/run-tests.sh,build.sh,Authoring/create_gym.py; Git LFS attributes; the placeholder test. Template variants kept as reference (D-41). Four Fab packs inContent/, none used yet (D-39). -
2026-09-15 —
Spec/Stats.md: one stat block on every body, every outside influence an effect, counters on the receiver (D-37, D-38). Replaced the movement component's own speed-multiplier map, which was the first strand of the spaghetti this exists to prevent; enemies and kits now carryFStatBlockDefaults; step 5 builds it. -
2026-09-15 — The documentation set written for Unreal from the two earlier projects' docs:
Design.md,Steps.md(fifteen rungs, the first six in full),Ideas.md,Decisions.md(D-01 to D-36, OD-01 to OD-08), and the eight specs underSpec/. No code, no project yet; step 1 is next and waits on OD-01 and OD-02.
Worked
- (nothing built yet)
Did not work
-
2026-09-17 —
"fill_every": 50in the terrain spec, written to protect the five-minute budget, silently destroyed the stream-power solve. Uplift reaches 5 mm/yr, which at dt 1500 is 7.5 m a step, so fifty steps is up to 375 m of differential uplift between floods: basins close and sit unrouted while everything above them stops eroding. Measured at 512² over 3000 steps the fitted exponent goes -0.500 (R2 0.992) at every step, -0.277 at every fifth, -0.121 at every tenth, noise beyond. Even every fifth step is broken, and the saving was 93 s against 38 s. The budget came back from the step count instead: 1000 steps, not the spec's 5000, because at this K the trunk response time is about 45 000 yr and the exponent stops moving after 500. -
2026-09-17 — The heap in the priority-flood was the wrong data structure. The flood pops in non-decreasing elevation and never pushes below the current front, which is exactly when a monotone bucket queue is valid; swapping the binary heap for one took 1.6x off the whole solve at identical output. 22 comparisons and as many cache misses per operation, on two thirds of the runtime.
-
2026-09-17 — Relief from the stream-power solve is strongly resolution-dependent: the same seed and uplift field give 1020 m of land relief at 512² and 2605 m at 1786², because finer grids resolve smaller drainage areas near the divides and S goes as A^(-m/n). Tuning U/K on a
--sizepreview will therefore overshoot the elevation ceiling at full resolution. Judge shape on the preview; confirm the elevation budget at the real geology grid, where the clip warning is the check. -
2026-09-16 — The first
L_Worldrebuild of the evening died at twelve minutes with no error: it was run under a tool with a ten-minute timeout, which killed the commandlet mid-import and left an empty level. Long commandlets are launched detached (Start-Process) and watched through their log. -
2026-09-16 — A rebuild while the editor had ever loaded
L_Worldended in a nameless/Temp/Untitledworld whose save went nowhere: deleting and recreating the level resavesL_World_HLODLayer_Instancedand_Merged, and the open editor keeps those two files locked.create_world.pynow loads and empties an existing level instead of recreating it, checks the world's package name before building, and sweeps leftover proxy packages after the save. Check the editor's current level (SceneTools.get_current_levelover MCP) before a rebuild; never rebuild the level the editor has open. -
2026-09-16 — Epic's recommended 4033 resolution gives the engine's importer 64x64 components of 63 quads, because 4032 does not divide by 127 or 255 and the importer prefers one section per component: 4096 components, over 8000 textures to build, forty minutes. 4081 divides by 255: 256 components, two minutes.
-
2026-09-16 — The noise stacked eight octaves at gain 0.5, so every octave was as steep as the last and a third of the land stood above 50°. Lower gains, no octave finer than about 50 m, a percentile-thresholded range mask and a 38° thermal pass brought it to 80 % under 15°; measured with a slope histogram, not by eye.
-
2026-09-17 — Cellular (Worley) crest lines at 30 % of the mountain height turned the ranges into a honeycomb of polygon walls with flat floors in the hillshade. Kept at 12 % through a stronger warp; the ridged noise and the erosion make the divides.
-
2026-09-17 — The first eroded map had the meadows brushed with rills: land under 15° fell from 64 % to 38 %. A stage-by-stage slope histogram (uplift, coarse pass, fine pass, thermal) put most of it on the coarse pass: the slope gate sat at 7°, the median lowland slope, and one droplet could cut 7 m per step at 14 m cells. Gate at 14°, cut capped at a fifth of a cell per step. Attribute by measurement before turning knobs.
-
2026-09-17 — Spreading droplet deposits through a 3x3 brush built mounds: a pit's rim rises faster than its floor, the pit never fills, and every droplet draining into it adds its load to the rim. Cuts go through the brush (no one-cell rills), deposits land on the droplet's own cell, and a droplet's load is capped, since capacity scales with the drop per step and a cliff hands a droplet a hundred cell-heights.
-
2026-09-16 — The first
L_Worldwith the manifest pipeline showed rock everywhere: Elite_RockyMeadows' layer names mislead. ItsBase_Layerfunction samples the rock textures,Layer_02the grass,Layer_03the high rock, and the meadow weightmap had gone toBase_Layer. Read a pack's layer functions before mapping layers. -
2026-09-16 — The retuned noise overshot into flat land with small hills; the user wanted aggressive ranges. Ranges now come from an elongated, warped, percentile-thresholded band (about two fifths of the land with foothills), ridged noise sampled through the same warp, crests to about 2600 m.
-
2026-09-16 —
AActor.set_is_spatially_loadeddoes not exist in Python; the property is set withset_editor_property("is_spatially_loaded", False). -
2026-09-16 — Positional
unreal.Rotator(a, b, c)is (roll, pitch, yaw), not (pitch, yaw, roll). Both authoring scripts pitched the sun 25 to 30 degrees upward, every map rendered as night, and the viewport's "cached lighting is going to be clipped" warning was the auto exposure at EV -8.5 saying so. Keyword arguments from now on. -
2026-09-16 — A landscape imported from a commandlet had collision but no visible surface: edit layers are always on in 5.8, the render heightmaps come from a GPU merge, and a commandlet reports it can never render unless launched with
-AllowCommandletRendering.ULandscapeAuthoringLibrarynow forces the full layer update andcreate_world.pydocuments the flag. -
2026-09-16 — The asset library cannot delete a folder of world-partition streaming proxies (their packages do not load on their own);
create_world.pyremoves the folder on disk instead.
Open
- World, next time (2026-09-17, the user's verdict: "still somewhat rough"). Three items, none started:
- Sculptable terrain. Today
create_world.pyempties the level and re-imports the heightmap into a fresh landscape, so any hand sculpting dies with the next rerun. Wanted: the generated height applied to the level as its base, with sculpting on top that survives a regeneration. The engine's shape for this is edit layers: import the PNG into a namedGeneratedlayer and leave aSculptlayer above it for hand work, then make a rerun re-import intoGeneratedonly (ALandscapeedit-layer API,FLandscapeEditDataInterface, orULandscapeEditorObject::ImportHeightmapper layer) instead of destroying the actor. Needs a C++ change inULandscapeAuthoringLibrary(aReimportHeightmapIntoLayerbesideCreateLandscapeFromHeightmap), so a rebuild ofSaltyEditorwith the editor closed; the weightmaps follow the same path. Check first whether the proxies' edit-layer data survivesChangeGridSize. - Not visible in the editor. The rebuilt terrain shows in PIE but the user reports it not visible in the
editor viewport. Most likely cause, to verify: the level is world-partitioned and the 256 landscape proxies
are spatially loaded, so the editor loads none of them until a region is loaded in the World Partition
window; only the always-loaded dressing shows. Candidates: World Settings → World Partition → turn
"Enable Streaming" off for now (everything loads, editor and runtime; 256 components is fine for a
prototype), or save loaded regions with the level, or have
create_world.pyload the regions after the build. Also confirm the editor reloadsL_Worldfrom disk after a rebuild rather than a stale in-memory copy. - No greenery on the floor. The meadow layer is a flat grass texture; nothing grows. No pack in
Content/has a grass mesh (Elite_RockyMeadows ships textures only, HouseForge's foliage folder holds one mushroom and a cover-plant material). Wanted: landscape grass on the meadow layer (ULandscapeGrassTypefed from aLandscapeGrassOutputnode, which means a child or copy ofM_Landscape_Mainsince the pack's material has none), driven by the erosion's deposit and flow maps once the material samples them, plus a foliage pass for trees and bushes from a pack still to be chosen. Distance culling matters at 200 km².
- Sculptable terrain. Today
- Step 1's last proof is a person's: Play In Editor in
L_Gym, two players, "Run Dedicated Server" on, two pawns in the gym. Until it is ticked the step is◐. - The default camera mode (OD-03) is deliberately undecided until step 8.
- The numbers in every spec are the earlier projects' guesses in centimetres and seconds. None has been played.