Files
UnrealPrototyping/Docs/Worklog.md
T

240 lines
23 KiB
Markdown

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