Files
UnrealPrototyping/Docs/Worklog.md
T

23 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-17 - Off the ladder, the coast, part two: the uplift field stops being multiplied by the continent mask (D-52), so a range that reaches the water rises at range rates right up to the waterline instead of being tapered to nothing across the shore. Surf cut 23.3 → 38.9 Mm3 on seed 7 and 22.4 → 30.4 on seed 9342; high ground now meets the water in the detail crop where it previously had a uniform low fringe in front of it. New TestBorderIsAlwaysOcean in internal/check, because the margin that keeps land off the map border was relying on that same multiplication as a side effect: the border is still all ocean on three seeds, so there is no frozen rim. Did not work, and it was mine: the "mean cliff" statistic measured the drop from a cell to its seaward neighbour and called it a cliff. That is a gradient - one cell of a 10 m grid at the angle of repose is 7 m - so it could not exceed 7 whatever the coast did, and it read 2 m on a plain coast and 3 m on a cliffed one. It is why yesterday's brief claimed there were no sea cliffs anywhere; the replacement, backshore height at one to two surf reaches inland, shows the old build already at 88 m P90. A cliff is how far you fall, not how steep the first cell is. Also measured: the slope-area fit is too noisy at five or six bins to judge one change on one seed - the same code gives -0.480 on seed 7 and -0.698 on seed 9342 - and 14 to 15 % of every coastline is drawn by the map margin rather than by the noise, which is a straight line parallel to the map edge and is pre-existing.
  • 2026-09-17 - Off the ladder, the coast. internal/coast, running after the fluvial solve, on the terrain the solve produced. An exact signed distance transform with a feature index (Felzenszwalb, two 1-D passes) is the coordinate everything is written in; on it sit a continental shelf whose width is read off the relief behind each stretch of shore, a surf that planes the land to a shore platform within a reach set by how open the water is - the cliff is the step where the reach ends - and a sediment budget that carries what the surf cut a drift length along the shore and lays it in sheltered shallow water, with river mouths delivering their own load. uplift.Result.Bathymetry is gone: the coast pass owns the sea floor, and ocean cells stay at sea level for the whole solve. New: map_exposure.png, map_coast.png, a coast block in meta.json and in the summary, --no-coast and six other overrides, nine tests, and the determinism test now covers the pass. The measured finding was the coastline outline: at five octaves its finest feature is 450 m, which is a blob, and the fetch called the median stretch of coast fully open. Swept, seed 7 at 1400: shoreline 64/81/96/114 km and median exposure 1.00/0.98/0.84/0.51 at outline gain 0.50/0.58/0.62/0.66. D-51 sets 8 octaves at 0.62. Whole pass 83 ms. Did not work: a percentile stretch for exposure (degenerate on an even coast, and a global statistic two tiles would disagree about); casting fetch in every direction rather than seaward only (it counts the land behind the shore as shelter, so a straight coast scored more sheltered than a bay); a cut fraction read as a fraction of the height above the platform (15 % of a 120 m headland is 18 m, which is not a platform); dep = blur(supply) * want / blur(want), which looks like a normalised convolution and lost 68 % of the sediment budget - the supply blurred onto land and deep water where nothing wants it was silently dropped, and the conserving order is to divide by the blurred want first, then blur; and one kernel for both jobs - the sediment needs zero padding to stay symmetric and a carried value needs edge clamping, and using the mass-preserving one on the shelf width shrank every shelf near the border to nothing.
  • 2026-09-17 - Off the ladder, the plains problem. The lowlands read as shrunken mountains because they were being uplifted at mountain rates: S = U/(K*A^m) applied down to one cell puts a 0.25 mm/yr divide at 28 degrees and the 0.9 mm/yr swell past the 35 degree repose clamp, so the clamp was the surface of the continent (81 % of land in the >0.5 mm/yr class, the plain class 1 % and all sea cliff). Built stats.UpliftBuckets first, to measure per uplift class rather than map-wide - that is what made it visible. Then D-49 (intraplate 0.25 to 0.03, swell 0.9 to 0.08, relief lo 50 to 15 m) and D-50 (hash-jittered flood epsilon and D8 tie-breaks, for the flat ground the fix exposed). Plains are now 42 % of land at 0.8 degrees and 9 m relief; slope-area R2 0.06 to 0.46. Then Roering nonlinear hillslope diffusion in internal/fluvial/hillslope.go, running after the repose clamp each step so the clamp's D8 pyramid facets get rounded off: mountains 31.1 to 28.5 degrees and 44 % to 33 % pinned at talus, at 8 % more runtime. Four tests on it. A run now also writes uplift, erodibility, slope, relief, flow and basin maps. Did not work: critical_area_m2 1e4, re-measured after the uplift fix and still failing (plains back to 7 degrees, 79 % of mountains at the clamp) - it needs a hillslope transport law to pair with; clamping once at the end of the run instead of in the loop (facets returned). Still open: half the land is still in the mountain uplift class; fault traces and the range grain are still visibly straight lines; MFD accumulation unbuilt.
  • 2026-09-17 — Off the ladder, build-order steps 3 and 4 of Terrain.md: the Go core exists and the stream-power solver works. Tools/Terrain/ (module salty/terrain, Scripts/build-terrain.sh, binary and RawContent/World/Preview/ gitignored): field (the one array type, vertex-convention resampling, Catmull-Rom upsample, greyscale PNG through image/png, hillshaded thumbnails, and field.Rows as the only place goroutines are made), manifest (World.json over Go defaults, the same height contract as world_manifest.py, and a validator that refuses a resolution the importer would turn into thousands of components), noise, uplift (the continent, now producing an uplift rate rather than terrain), fluvial (priority-flood, D8 receivers, stack, accumulation, implicit Braun & Willett update, sub-stepped diffusion), stats and two integration tests. The proof: the analytic check K*A^m*S^n/U measures 1.0000 over 822 channel cells, and on the continent the slope-area exponent is -0.498 against -0.500 at R2 0.996 with drainage density 1.33 /km. Determinism is asserted across five values of GOMAXPROCS, not assumed. Nothing in the editor was touched (it was open), so steps 1 and 2 are still first and still open; the numpy pipeline still builds L_World and is unchanged. Three bugs, all of which produced plausible-looking terrain and none of which could have been seen by looking: slope-area measured with the topographic gradient instead of the channel gradient read -0.78 where the truth was -0.50; cells at a local minimum were skipped entirely including their uplift, which froze exactly the basins that differential uplift was forming; and the fix for that then uplifted the outlets, because a map border is an outlet but is not ocean, so base level rose 2 m a step and the steady-state ratio read 0.03. The last one was invisible in real runs, where the border is ocean, and only the test caught it.
  • 2026-09-17 — Off the ladder, no code: an incoming procedural terrain specification (tectonics, faults, lithology, stream-power erosion, a Go core, an editor bridge into a Landscape edit layer) reconciled against the built pipeline in Terrain.md, and the Go core taken as D-47. What the reconciliation found: the spec's 7113 canvas is a trap, because 7112 divides by 127 and not by 255, so the importer's own rule gives 3136 components rather than the spec's 784 — the 4033 mistake again (D-45); 7141 at 200 cm gives 28x28 components of 255 quads with the same 14.28 km side the manifest already has. The spec's Z scale 400 is exactly a 2048 m elevation span, so its canvas is expressible in the manifest's own metres contract, at the cost of dropping the crests from 2800 m to 1536 m (Q2). The spec's uplift/K parameter ranges and its elevation budget are not jointly consistent at their corners: steady state puts slope at U/(K*A^m), and the aggressive corner overshoots 1536 m badly, so U/K is the one relief knob and the clip fraction is the check. The spec has no coast at all and leaves base level open, where this world has one and a sea plane (Q1). Lithology (plan view) and strata (vertical) are orthogonal and both kept. The spec's §8 DEM escape hatch is already built and better specified than the spec's version. Deferred with triggers: the preset gallery widget, UTerrainPreset, river splines, build-zone volumes, any GPU port. The five open questions were put to the user the same day and all five went the recommended way (D-48): the coast stays and sea level is the base level, the ceiling drops to 1536 m, the canvas goes to 7141 at 200 cm, and a full run holds the five-minute bar. Go 1.25 turned out to be installed already, on 16 cores, so the toolchain cost was zero. Nothing is built; the ladder is untouched.
  • 2026-09-17 — Off the ladder, third pass: geological simulation in the generator (D-46). heightmap_erosion.py, numpy only: particle hydraulic erosion in vectorised batches, a coarse pass on the 4x-downsampled map for the valleys and a fine pass at full resolution for the gullies; mass-conserving thermal weathering at a 35° angle of repose; strata hardness scaling the erosion; flow, wear, deposition and curvature maps written as PNGs and used by the layer rules. The uplift gained Worley cellular crest lines through the same domain warp. The manifest's erosion block drives it; "enabled": false for a real DEM. Two things the smoke test caught: droplets stepping together in a batch and crowding one cell run away to infinity without a per-step cap, and thermal weathering that sheds half the mean excess converges far slower than half the largest excess.
  • 2026-09-16 — Off the ladder, second pass: the world's terrain is now a manifest, RawContent/World/World.json (D-44). world_manifest.py derives the landscape's Z scale and Z offset from an elevation range in metres so world Z 0 is sea level; heightmap_io.py reads 16-bit PNG and raw r16 with numpy alone and resamples; heightmap_noise.py builds the continent in metres (retuned: 80 % of the land under 15°, ranges on a fifth of the map); generate_heightmap.py takes either the noise or a real heightmap named in the manifest and derives the three Rocky Meadows layers from the height by slope and altitude, so a real heightmap needs no weightmaps. create_world.py dresses L_World with the pack's kit as dump_level.py (new, dumps any level's actors to JSON) read it from Rocky_Meadows_01: sun with the cloud-shadow light function, skybox dome, sky light, height fog, fixed exposure and saturation; plus World_Sea_Proto, a plane with the engine's water material. Resolution moved from 4033 to 4081 so the engine's importer picks 16x16 components of 255 quads instead of 64x64 of 63 (D-45): the import takes two minutes instead of forty.
  • 2026-09-16 — Off the ladder, at the user's request: L_World, a world-partitioned ~200 km² landscape from a seeded heightmap. Scripts/Authoring/generate_heightmap.py (numpy, in Scripts/Authoring/.pylib via bootstrap-pylib.sh) writes 16-bit height and 8-bit weight PNGs to RawContent/World/Heightmaps/; create_world.py rebuilds the level from them through ULandscapeAuthoringLibrary in the new SaltyEditor module, because the engine exposes no landscape creation to Python. Elite_RockyMeadows' landscape material and three layer infos. Swap the terrain by replacing the PNGs and rerunning. Nothing in gameplay references it.
  • 2026-09-16 — Step 2: ITelemetrySink with null, log and JSON Lines sinks; FTelemetryEvent, the envelope and Telemetry::ToJsonLine; TelemetryEvents with the four session names; UTelemetrySubsystem; ASaltyGameMode minting the session id into ASaltyGameState; bs.TelemetryTest; the git hash in the build string (D-42); four Core tests. Proved standalone, and with a headless -server plus -game client sharing one session id.
  • 2026-09-16 — The editor's MCP: the engine's experimental plugin only listens when bAutoStartServer is set, so .mcp.json refused until DefaultEditorPerProjectUserSettings.ini set it (D-43). Enabled the Editor, AutomationTest, GameplayTags, ConfigSettings and LiveCoding toolsets; before that the only toolset was skills. Reconnect with /mcp after the editor is up. Live Coding must be closed before a shell build.
  • 2026-09-16 — Step 1: Salty.uproject (UE 5.8) at the root from the Third Person template, renamed from ProjectSomething with class redirects; SaltyCore and Salty modules; SaltyServer.Target.cs (unbuildable on the launcher, OD-04); USaltyAssetManager calling InitGlobalData; Config/Tags/ with 23 root namespaces; Gameplay Abilities, CommonUI and Python enabled; Scripts/run-tests.sh, build.sh, Authoring/create_gym.py; Git LFS attributes; the placeholder test. Template variants kept as reference (D-41). Four Fab packs in Content/, none used yet (D-39).
  • 2026-09-15 — Spec/Stats.md: one stat block on every body, every outside influence an effect, counters on the receiver (D-37, D-38). Replaced the movement component's own speed-multiplier map, which was the first strand of the spaghetti this exists to prevent; enemies and kits now carry FStatBlockDefaults; step 5 builds it.
  • 2026-09-15 — The documentation set written for Unreal from the two earlier projects' docs: Design.md, Steps.md (fifteen rungs, the first six in full), Ideas.md, Decisions.md (D-01 to D-36, OD-01 to OD-08), and the eight specs under Spec/. No code, no project yet; step 1 is next and waits on OD-01 and OD-02.

Worked

  • (nothing built yet)

Did not work

  • 2026-09-17 — "fill_every": 50 in the terrain spec, written to protect the five-minute budget, silently destroyed the stream-power solve. Uplift reaches 5 mm/yr, which at dt 1500 is 7.5 m a step, so fifty steps is up to 375 m of differential uplift between floods: basins close and sit unrouted while everything above them stops eroding. Measured at 512² over 3000 steps the fitted exponent goes -0.500 (R2 0.992) at every step, -0.277 at every fifth, -0.121 at every tenth, noise beyond. Even every fifth step is broken, and the saving was 93 s against 38 s. The budget came back from the step count instead: 1000 steps, not the spec's 5000, because at this K the trunk response time is about 45 000 yr and the exponent stops moving after 500.

  • 2026-09-17 — The heap in the priority-flood was the wrong data structure. The flood pops in non-decreasing elevation and never pushes below the current front, which is exactly when a monotone bucket queue is valid; swapping the binary heap for one took 1.6x off the whole solve at identical output. 22 comparisons and as many cache misses per operation, on two thirds of the runtime.

  • 2026-09-17 — Relief from the stream-power solve is strongly resolution-dependent: the same seed and uplift field give 1020 m of land relief at 512² and 2605 m at 1786², because finer grids resolve smaller drainage areas near the divides and S goes as A^(-m/n). Tuning U/K on a --size preview will therefore overshoot the elevation ceiling at full resolution. Judge shape on the preview; confirm the elevation budget at the real geology grid, where the clip warning is the check.

  • 2026-09-16 — The first L_World rebuild of the evening died at twelve minutes with no error: it was run under a tool with a ten-minute timeout, which killed the commandlet mid-import and left an empty level. Long commandlets are launched detached (Start-Process) and watched through their log.

  • 2026-09-16 — A rebuild while the editor had ever loaded L_World ended in a nameless /Temp/Untitled world whose save went nowhere: deleting and recreating the level resaves L_World_HLODLayer_Instanced and _Merged, and the open editor keeps those two files locked. create_world.py now loads and empties an existing level instead of recreating it, checks the world's package name before building, and sweeps leftover proxy packages after the save. Check the editor's current level (SceneTools.get_current_level over MCP) before a rebuild; never rebuild the level the editor has open.

  • 2026-09-16 — Epic's recommended 4033 resolution gives the engine's importer 64x64 components of 63 quads, because 4032 does not divide by 127 or 255 and the importer prefers one section per component: 4096 components, over 8000 textures to build, forty minutes. 4081 divides by 255: 256 components, two minutes.

  • 2026-09-16 — The noise stacked eight octaves at gain 0.5, so every octave was as steep as the last and a third of the land stood above 50°. Lower gains, no octave finer than about 50 m, a percentile-thresholded range mask and a 38° thermal pass brought it to 80 % under 15°; measured with a slope histogram, not by eye.

  • 2026-09-17 — Cellular (Worley) crest lines at 30 % of the mountain height turned the ranges into a honeycomb of polygon walls with flat floors in the hillshade. Kept at 12 % through a stronger warp; the ridged noise and the erosion make the divides.

  • 2026-09-17 — The first eroded map had the meadows brushed with rills: land under 15° fell from 64 % to 38 %. A stage-by-stage slope histogram (uplift, coarse pass, fine pass, thermal) put most of it on the coarse pass: the slope gate sat at 7°, the median lowland slope, and one droplet could cut 7 m per step at 14 m cells. Gate at 14°, cut capped at a fifth of a cell per step. Attribute by measurement before turning knobs.

  • 2026-09-17 — Spreading droplet deposits through a 3x3 brush built mounds: a pit's rim rises faster than its floor, the pit never fills, and every droplet draining into it adds its load to the rim. Cuts go through the brush (no one-cell rills), deposits land on the droplet's own cell, and a droplet's load is capped, since capacity scales with the drop per step and a cliff hands a droplet a hundred cell-heights.

  • 2026-09-16 — The first L_World with the manifest pipeline showed rock everywhere: Elite_RockyMeadows' layer names mislead. Its Base_Layer function samples the rock textures, Layer_02 the grass, Layer_03 the high rock, and the meadow weightmap had gone to Base_Layer. Read a pack's layer functions before mapping layers.

  • 2026-09-16 — The retuned noise overshot into flat land with small hills; the user wanted aggressive ranges. Ranges now come from an elongated, warped, percentile-thresholded band (about two fifths of the land with foothills), ridged noise sampled through the same warp, crests to about 2600 m.

  • 2026-09-16 — AActor.set_is_spatially_loaded does not exist in Python; the property is set with set_editor_property("is_spatially_loaded", False).

  • 2026-09-16 — Positional unreal.Rotator(a, b, c) is (roll, pitch, yaw), not (pitch, yaw, roll). Both authoring scripts pitched the sun 25 to 30 degrees upward, every map rendered as night, and the viewport's "cached lighting is going to be clipped" warning was the auto exposure at EV -8.5 saying so. Keyword arguments from now on.

  • 2026-09-16 — A landscape imported from a commandlet had collision but no visible surface: edit layers are always on in 5.8, the render heightmaps come from a GPU merge, and a commandlet reports it can never render unless launched with -AllowCommandletRendering. ULandscapeAuthoringLibrary now forces the full layer update and create_world.py documents the flag.

  • 2026-09-16 — The asset library cannot delete a folder of world-partition streaming proxies (their packages do not load on their own); create_world.py removes the folder on disk instead.

Open

  • World, next time (2026-09-17, the user's verdict: "still somewhat rough"). Three items, none started:
    1. Sculptable terrain. Today create_world.py empties the level and re-imports the heightmap into a fresh landscape, so any hand sculpting dies with the next rerun. Wanted: the generated height applied to the level as its base, with sculpting on top that survives a regeneration. The engine's shape for this is edit layers: import the PNG into a named Generated layer and leave a Sculpt layer above it for hand work, then make a rerun re-import into Generated only (ALandscape edit-layer API, FLandscapeEditDataInterface, or ULandscapeEditorObject::ImportHeightmap per layer) instead of destroying the actor. Needs a C++ change in ULandscapeAuthoringLibrary (a ReimportHeightmapIntoLayer beside CreateLandscapeFromHeightmap), so a rebuild of SaltyEditor with the editor closed; the weightmaps follow the same path. Check first whether the proxies' edit-layer data survives ChangeGridSize.
    2. Not visible in the editor. The rebuilt terrain shows in PIE but the user reports it not visible in the editor viewport. Most likely cause, to verify: the level is world-partitioned and the 256 landscape proxies are spatially loaded, so the editor loads none of them until a region is loaded in the World Partition window; only the always-loaded dressing shows. Candidates: World Settings → World Partition → turn "Enable Streaming" off for now (everything loads, editor and runtime; 256 components is fine for a prototype), or save loaded regions with the level, or have create_world.py load the regions after the build. Also confirm the editor reloads L_World from disk after a rebuild rather than a stale in-memory copy.
    3. No greenery on the floor. The meadow layer is a flat grass texture; nothing grows. No pack in Content/ has a grass mesh (Elite_RockyMeadows ships textures only, HouseForge's foliage folder holds one mushroom and a cover-plant material). Wanted: landscape grass on the meadow layer (ULandscapeGrassType fed from a LandscapeGrassOutput node, which means a child or copy of M_Landscape_Main since the pack's material has none), driven by the erosion's deposit and flow maps once the material samples them, plus a foliage pass for trees and bushes from a pack still to be chosen. Distance culling matters at 200 km².
  • Step 1's last proof is a person's: Play In Editor in L_Gym, two players, "Run Dedicated Server" on, two pawns in the gym. Until it is ticked the step is ◐.
  • The default camera mode (OD-03) is deliberately undecided until step 8.
  • The numbers in every spec are the earlier projects' guesses in centimetres and seconds. None has been played.