45 KiB
Unreal prototype
An Unreal Engine 5 project, C++ first, that builds and proves three things in order: a movement controller,
fighting, and crafting. Engine: Unreal Engine 5.8, launcher build at D:\UE_5.8 (D-39). Project: Salty
(D-40). Where an older doc says <Project>, read Salty.
Before you start
Read Docs/Spec/README.md (the notation and the twelve cross-cutting rules) and
Docs/Spec/Architecture.md before writing or changing any code. Nearly every wiring
decision follows from them.
For what to build next, read Docs/Steps.md. It is a ladder, not a roadmap: each rung says
what exists afterwards, links the spec sections that define it, and states the proof that closes it. The spec
docs under Docs/Spec/ are the specification; each is C++-shaped pseudocode plus the reasoning, and the
pseudocode is meant to be implemented rather than admired. If a step and its spec disagree, the spec wins and the
step is corrected.
The human summary is Docs/Design.md. Ideas from the two earlier projects that are not built
are in Docs/Ideas.md; do not build one inside a step about something else.
Where the project actually is
Step 1 is built (see Docs/Worklog.md): Salty.uproject at the root, the SaltyCore and Salty modules, the
placeholder test, the tag namespaces, LFS, the scripts and L_Gym. It was created from the engine's Third Person
template; the plain template classes are ATemplate* and are the placeholder body until step 3. The template's
Variant_* folders under Source/Salty/ and Content/ are reference only (D-41): do not add code there, do not
depend on them. Four Fab asset packs sit in Content/ (HouseForge_01, Medieval_Weapons, Elite_RockyMeadows,
RPGEnvironmentVFX); none is referenced yet, and a pack is used only when a step's spec names the asset.
Step 2 is built: UTelemetrySubsystem on the game instance with the null, log and JSON Lines sinks
(-telemetry or telemetry.File 1 writes Saved/Telemetry/session_*.jsonl), the four session events,
ASaltyGameMode minting the session id into ASaltyGameState, and bs.TelemetryTest in
Source/Salty/Core/SaltyCheats.cpp, the home of every later bs.* command. Emit with a name from
TelemetryEvents and a FTelemetryPayload.
The editor serves the engine's MCP plugin on 127.0.0.1:8000/mcp while it is open (D-43); .mcp.json points
Claude Code at it. Reconnect with /mcp once the editor is up. Close the editor and its Live Coding console
before a shell build.
Off the ladder, and Docs/World-Pipeline.md is the map of it: what order a
painted world map becomes ground in Unreal, which of the three routes is actually live, what every artefact is
and whether it is tracked, and the traps. Read it before running any of this; the rest of this section is the
reasoning behind it.
Content/Maps/L_World is the one world level, and it is the planet-map region (D-72).
There is no L_Region any more: a second world level was a second thing to keep dressed, lit and in step,
and the region always was the world. Region.json says which level it builds, the tile PNGs are named after
it, and build_region.sh reads the name out of the manifest rather than carrying one of its own.
What follows describes the numpy pipeline, which now builds L_Canvas_Proto rather than L_World — legacy
by D-47 and kept only because it is still the one path that carries the erosion pass's flow, wear and deposit
maps into Unreal. World.json's level is named apart from the real world deliberately: create_world.py
empties whatever level it is handed, so a manifest still pointing at L_World would replace 98 landscapes
with a 14 km square on one run, without a prompt.
Content/Maps/L_Canvas_Proto, a ~200 km² world-partitioned landscape, is a product of three inputs and
nothing else: the manifest RawContent/World/World.json (size, what a heightmap value means in metres, the
height source, the paint-layer rules), the PNGs Scripts/Authoring/generate_heightmap.py writes from it into
RawContent/World/Heightmaps/, and Scripts/Authoring/create_world.py, which imports them through
ULandscapeAuthoringLibrary in the SaltyEditor module and dresses the level as Elite_RockyMeadows dresses its
demo maps (landscape material and layers, sun with cloud shadows, skybox, fog, grade; numbers read with
dump_level.py). The height source is seeded noise today, weathered and carved by heightmap_erosion.py
(hydraulic droplets, thermal weathering, strata; D-46); a real heightmap is a manifest edit and a rerun
(RawContent/World/README.md). Run create_world.py detached, never under a tool timeout, and never while the
editor has L_World open. No gameplay code references the world; the gym is still the test level. Where the
generator is going is settled in Docs/Terrain.md (D-47, D-48): a Go core in
Tools/Terrain/, stream-power fluvial erosion instead of droplets as the thing that shapes the land, a canvas
of 7141 vertices at 200 cm over −512…1536 m, and a Generated edit layer so sculpting survives a rerun. Read
it before touching the generator, and the numpy pipeline is not to be extended.
The Go core now exists and works: Tools/Terrain/ builds an uplift rate field and lets a Braun-Willett
stream-power solve produce the terrain from it, with a Roering nonlinear hillslope law and per-uplift-class
statistics. The coast is a pass of its own (D-51, internal/coast), running after the solve: a continental
shelf whose width follows the relief behind the shore, a surf that planes a shore platform and leaves a cliff
where its reach ends, and a sediment budget carried along the shore into the bays and out of the river mouths.
It stops at the geology grid, so the detail passes are unbuilt and L_World still comes from the numpy
pipeline. Docs/Terrain-Next.md is the working brief — how to run it, what
each output map is for, what still looks wrong, and what has already been measured and rejected. Start there.
The source is a painted map now (D-53). An author paints a flat cylindrical world map; a JSON legend
beside it says what each colour means in uplift mm/yr and erodibility; the simulation makes the terrain.
And a class is two rates, not one (D-55). n is 1, so a class's uplift rate is its hillslope angle, and
one rate over a painted blob is therefore one landscape over it - the first planet's continents came out
uniformly dissected from the waterline to the summit with no flat ground on them anywhere. A class now carries
massif: {floor_mm_yr, fraction} and cuts one planet-wide upland fabric, so a painted lowland is a plain with
hill masses standing out of it. The threshold is a quantile of the planet, never of the region.
And there is a tool for the painting now (D-56): terrain studio serves a painter on 127.0.0.1:8099
where the brushes are the legend's classes, the panel shows the hillslope angle each rate buys as you type
it, the canvas wraps at the seam, and plan is a button. It saves by patching the text of the legend and
the manifest, so their commentary survives. Its canvas is a GPU texture (D-61), because the 2D one put
all twenty-nine million cells through putImageData and a whole-image downsample on every pointer event -
105 ms an event whatever the brush touched, against 6 ms flat now. A stroke uploads only its own rectangle
(UNPACK_ROW_LENGTH, no copy), drawing is one quad, the seam is REPEAT, and one rAF loop owns the screen.
Do not put per-event work back on the input path, and do not reintroduce a second copy of a sheet in a
canvas: full and ovFull are the authority and are encoded only when pushed. ctrl+z undoes a stroke,
which is copy-on-write over a 256 px tile grid recorded at the top of stamp - the only writer, which is
what makes it complete. Anything new that writes to a sheet goes through stamp or records its own tiles
first, and wraps a rectangle into runs before cutting it into tiles: the grid does not line up with itself
across the seam, because 7738 is not a multiple of 256. The painted waterline is also roughened before projection now -
coast_jitter_px had been in the manifest since D-53 with nothing reading it - by adding fractal noise to the
signed distance from the shore rather than by warping the painting.
RawContent/World/Templates/ holds the first one and its README is how to paint another;
RawContent/World/Planet.json is the planet's own manifest and World.json beside it — the square 14.28 km
canvas the numpy pipeline still builds — is untouched by any of it. terrain plan reads the painting and cuts
the planet into regions in four seconds without eroding anything; terrain bake solves it, about two hours at
100 km round. Paint the uplift, never the height: a solve handed a painted surface erodes it into
something else and throws the drainage network away, which is the reason the generator exists.
And the overlay can be proposed from a bake now (D-68). terrain overlay reads a finished bake and
fills the annotation layer in: woodland, settlements and the roads between them, which are all consequences
of ground an author cannot see while painting. It never touches a painted pixel - it fills blanks, so
generate, move the towns, regenerate is a loop that runs both ways - and it is opt-in per mark through a
generate block, so a legend without one produces the blank sheet it always did. Roads are a minimum
spanning tree over least-cost paths with water impassable, so each landmass gets its own network; forest
takes its treeline from a quantile of the land's own heights, because metres mean nothing until a world is
baked; not_classes keeps marks off the ice caps. The coast kind is supported and deliberately not
shipped enabled, because coast_jitter is the one overlay property a pass reads and generating it would
move the next bake's coastline everywhere. It proposes and does not decide: there is no economy, no history
and no climate in the Go tool, so "where would a city be" is answered with drainage, slope and distance to
the sea.
And generating it is a button now (D-70). The studio's overlay tab has Generate marks, and because a
button is pressed repeatedly rather than once, every press is a fresh seed and each draft replaces the last
rather than piling on it: the server remembers exactly which pixels the previous generation put down and
clears those alone, so hand-painted work is never in the set. It runs before the first bake too, on the
painting alone - flat height, no slope, drainage nil rather than zero - and says which of the two ran, because
a sketch that knows nothing about rivers must not be mistaken for one that does. A bake made from a
different painting is ignored with its reason printed: CheckBake compares only how a heightmap is
encoded, and two paintings of one planet agree on every one of those numbers, so nothing else would catch it.
And there is a second painting now (D-57), which is not geology. An overlay the same size as the
template, registered to it, painted in the studio's other tab (o), carries marks rather than classes:
forests, settlements, roads, and stretches of coast. Blank on it is alpha, never a reserved colour, and an
opaque pixel matching no mark is dropped and counted rather than snapped to the nearest — the class legend's
rule inverted, because most of an overlay is nothing. Exactly one mark property is read by any pass:
coast_jitter scales the waterline roughening per pixel, so a coastline drawn deliberately is pinned with a
brush stroke (0) while the rest of the world is still roughened, and a fjord coast is the same knob above 1.
Everything else is inert and travels: an 8-bit mark index beside every detail tile, and overlay.json with
every feature in world metres — a centre, area and extent per blob, an ordered polyline per path, because what
UE builds from a road is a spline. Nothing in the generator reads any of it back. RawContent/World/Templates/ Map3.overlay.json is the starter legend.
A painting is a composition and the seed re-rolls what it does not fix (D-58). One painting is many
worlds: the same seed change that moves the massifs, the swell, the initial relief and the coastline detail
now also moves the rock and the faults, neither of which existed on the painted path before. Lithology
is one planet-wide rock field cut into pipeline.lithology's types at a quantile of the planet, multiplying
each class's own k_mult by its lithology_mix; before it, map_erodibility.png was a recolour of
map_class.png. Faults are a per-class faults: {per_1000km2, throw_m, length_km} - a rate difference across
a line, steep one side and gentle the other, which erosion carves into a scarp - drawn once for the planet in
world metres and filtered per region, so a fault crossing a region boundary is one fault. Neither could be
ported from uplift.Build: its lithology takes a percentile of the grid it is handed and its fault centres
are fractions of that grid, which is what D-53's decomposition forbids. --seed is on plan, bake and
tiles, and the studio has a Re-roll button. Measured: seed 7 against 9342 on one painting moves 13.8 % of
the uplift map and 24.9 % of the erodibility map.
And a fault was a welt with a cliff down the middle (D-62). Raised as "each fault line makes a rough line
of mountains that just doesn't look realistic", and it was not a legend number set wrong: per_1000km2,
throw_m and length_km say how many, how long and how much, and nothing said what shape a fault is. That
was two constants with a step between them - the whole throw one side of the trace, the whole throw negated
the other, one 8 m cell apart, which is 89 degrees, inside an upthrown flank only 600 m wide. Both halves
are unsolvable and for the same reason the painted path exists. A discontinuity in the rate is a painted
cliff, so the trace facets at any throw and turning throw_m down only lowers the artefact. And 600 m is
narrower than one hillslope - the drainage density puts a divide 1.1 km from its channel - so no valley can
cut it and the profile is printed on the surface instead of being eroded into a landform, which is why
every fault in a bake read as a smooth ruled ridge through terrain dissected everywhere else. The profile is
continuous now, zero on the trace, with a 6 km footwall and a 4 km hanging wall, and the along-strike throw
is a bell rather than a flat top over the middle two thirds, so the cross-section is never extruded.
throw_m is the step across the fault now, not a full throw on each side, so a legend written before it
asks for half what it used to get. Measured on a 400 m throw: the steepest cell in the rate field went from
89 to 17.4 degrees, and solving the same landscape for a thousand steps, the old profile left a
ruler-straight cliff with a dead apron at 43 m of relief - less than the 45 m with no fault at all -
against a dissected range front at 97 m.
And widening it made a set of them stack (D-63). D-62 was verified on a region two traces reach, which
is the wrong place to verify a width change: FaultDelta accumulates with +=, harmless at 600 m because
two faults almost never met, and at 6 km they meet constantly - and a fault set is sub-parallel by
construction, since traces inside one cell of the orientation grain share a strike. On a 22 km landmass
crossed by 13 traces, 75 % of the faulted ground had two or more faults on it, the sum was a median
1.77x the largest single contribution, and the repose clamp fired on 4.1 % of the region against 0.15 %
planet-wide before - which is how the mountains came to read as "streaking horizontally like someone just
cut them apart with a knife". The bound is a soft knee per cell keyed to the largest single contribution
there (a planet-wide throw would not bite: 744 m biggest throw against a 209 m biggest contribution):
identity below it so a lone fault is untouched, bending onto 1.6x above it. Continuous and
frame-independent, and it runs unconditionally or a cell's value would depend on which frame asked.
The other half was the initial relief reading the finished rate: painted.go scaled the
symmetry-breaking noise by rate/maxClassRate with the fault delta in it and unbounded, so a 6 km footwall
stamped ~166 m of ridged fBm on a landmass whose whole relief was 221 m - and initial relief the size of
the landscape is not a symmetry-breaker, it is the landscape. Pre-fault rate now, bounded at one. Measured,
same region, same seed, same steps: max 221 -> 116 m, repose-clamped cells 160 289 -> 2 865, median
slope 6.3 -> 1.8 deg, slope-area exponent -0.94 -> -0.60 against a theoretical -0.50. The corduroy
is gone; the residual flank ribbing is Terrain-Next 4.B3, predates D-62 and is now diagnosed there.
terrain plan prints two angles per class now, and the second is the one to read. divide is
U/(K·A^m) at one cell — exact, and the steepest ground a rate can make; typical is the median over the
class, measured at a third of it in tangent. Almost none of a map is divide, so the old single column is how a
legend gets set two or three times too hot. And preview.png lies about scale the same way: its
hypsometric ramp tops out at a percentile of the world being drawn, so a 47 m lowland continent gets the
bare rock and snow a 2800 m range would. palette.land_top_m is an absolute ceiling in metres and every
run now prints which ceiling its preview used. Measured: the real 45.9 x 19.8 km lowland bakes to
0..47 m with a 0.61° median slope, 4.4 % of it over three degrees and nothing over eight - a plain,
and it always was one. What makes one read as hill country in the numbers is its massif fraction and
the rate the massifs reach, both of which are the author's.
Two rules govern anything written here. The geology solve is decomposed per landmass, exactly — ocean
cells are fixed at sea level and nothing in the solve can move them, so no flow path crosses open water and a
landmass in a box of its own gets the same answer as the whole planet would. Everything else, the coastal pass
included, runs once over the whole cylinder: 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.
Decompose the solve, not the map. And index every noise and hash by absolute world coordinates rather
than by grid index, through noise.WorldUV and a world.Frame — done for the router and the painted path,
still to do for the detail passes, which are the only thing that tiles.
The detail passes are built and tiled: terrain tiles runs passes 8 to 12 and 14 over a bake and writes 5 km
tiles of 2500 samples at 2 m, about twelve seconds each, with a hillshade and the flow, wear and deposit maps
beside each one. Their margin is measured rather than reasoned - three droplet lifetimes - and the droplets
spawn from a hash of world position so a tile's interior is what it would have been in one whole run.
And a planet can be judged now (D-59). internal/stats sorted a copy of every land cell, so a bake
printed its elevation range and nothing else - no slopes, no per-uplift-class breakdown, no drainage density.
Fixed-bin histograms replace the sorts and field.LocalRelief the O(radius²) window, but the property that
matters is that a histogram adds: a planet's statistics are pooled from its regions, exactly, where a
median of medians would not be. Each region accumulates while its grid is alive and they merge in region
order; the extent is measured once on the composited cylinder, because regions carry overlapping ocean
margins. A partial bake says PARTIAL rather than letting the whole world's extent be read against three
islands' ground. About 120 ns a cell, so a planet is a few seconds at the end of a two-hour bake.
And a planet has a shore now (D-60). internal/coast ran on a flat grid, so a bake laid the painted sea
floor and stopped: the land met the painted ocean depth in one cell, with no shelf, no surf platform, no beach
and no exposure anywhere. Four primitives wrap now - boxBlur's running sum, fetch's ray march,
shelfWidth's inland march and the distance-field gradient both marches steer by - and the test for it is
that the same island in two places is the same island, not that the seam looks right. The abyss is a field,
because a painted planet's sea classes carry their own depths and one global number would step to the painting
wherever they disagreed; it also means the break can never be deeper than the water it is a break in. Memory
was the other half: Geometry.Ref holds a waterline slot rather than a cell index, so the sediment supply is
a few hundred thousand entries instead of 608 MB, and Measure holds one distance transform at a time. 7.9 s
over the whole 76 M cell cylinder; the seam step in the sea floor went from a mean of 9.1 m to 0.32 m, which is
what an ordinary column is. The pass is now the memory peak of a bake, about 8.3 GB against the solve's 3.6.
And the ocean was thirty metres deep (D-64). Reported as "it is just a landmass and no oceans really",
against the exported heightmap. The legend paints ocean at 512 m over 56 % of the planet and 17 % of it got
there; 40 % of the world was water between 0 and 30 m, which at the manifest's -1024..2048 encoding is 1 %
of the 16-bit ramp from sea level, so the shelf and the land came out the same grey and every landmass's
shelf halo fused it to its neighbours. The depth at the shelf break is one number, and both call sites took
it from pipeline.continent.sea_floor_m - the square canvas's block, whose own comment says −30 m is not
a shelf break because a real one does not fit on a 14.28 km canvas. It is pipeline.coast.break_m now, 130 m
on a planet and the old reading on the square canvas, which is unchanged. What hid it is arithmetic: the
derived margin reaches shelf_km.hi() + slope_km = 4.6 km from every shore, and 1069 km of shoreline against
a 3111 km² sea is more margin than there is ocean, so no strait ever reached the painting. The margin's
width is right and was never the problem - 512 m over 4.6 km is a 6.3° continental slope. The profile is
monotone at any break depth, so every profile test passed; this had to be found in a histogram, and a bake
prints its sea floor's three numbers now. It also prints how much of the 16-bit ramp the world used - 28 %,
with land at 7 % - because too wide an elevation_m clips nothing and clip_fraction only ever catches one
too narrow. The whole-planet re-bake was killed by memory pressure and its numbers are still owed; what is
measured is the mechanism at unit scale, 33 % → 8 % of a painted 512 m sea left shallower than 50 m.
And the mountain flanks were laser-carved, which was D8 (D-65). Reported as "streaks going down the
side of the mountains", and Terrain-Next.md 4.B3 had the wrong cause: it blamed the ridged-fBm initial
relief and dismissed grid locking because the ribs are oblique, which is not a counter-indication - D8's
parallel-flow grooves run in whatever direction the slope faces. The grooves are channels: in
map_flow.png, absent from map_uplift.png, and the network is pinnate - ruler-straight parallel trunks
with barbs at a fixed angle - with 25 sources and no confluences in a 12.8 km window. What settles it is
that the pitch is 18 cells in both the 32 m bake and the 8 m one, and everything fixed in metres would
have changed by four. Isolated with no erosion at all, on a planar ramp where the correct specific catchment
area is the same along a contour: D8 gives the most-drained cell 769x the median and leaves 29.5 % of
the grid draining nothing, because every cell picks the same steepest neighbour and the flow lines never
converge. Drainage area is Freeman MFD now - Accumulate only, D8 receivers kept because Braun-Willett
walks one receiver chain - ordered by Kahn over the flow graph, not by elevation: exact, O(n), and a
bucket sort would leak every lake bed's area, since the queue quantises to a centimetre and the flood's
epsilon ladder is a millimetre a cell. 769 -> 1.34, 29.5 % -> 0.4 %, float32 is enough (2.4e-9 over a
closed basin), and it costs 101 ns a cell against D8's 17. pipeline.fluvial.mfd_exponent is 1; 0 is the
old behaviour, so the A/B is one flag. Three repairs alongside: the repose clamp jitters its pop order and
allowance (the residual octagon on a clamped cone is the octile metric, not the order, and is irreducible);
DiffuseNonlinear is the isotropic nine-point stencil, because the clamp cuts across eight faces and a
five-point smoother cannot transport across a diagonal one at all; and field.SmoothEdgePreserving is ported
from the World Orogen browser generator, off by default, and measured against its own acceptance gates
it fails them: two passes at slope_ref 0.3 move the slope-area exponent from -1.02 to +0.36 and the
mountain median slope by 3.7 degrees, and one gentle pass at 0.02 still lands at +0.16. It is a filter, not a
polish, and it is for somebody who decides in a file that they want the look more than the statistic. That generator routes single-receiver too; what keeps its flanks clean is that it solves on an
irregular Voronoi mesh with no lattice directions, which a raster pipeline cannot adopt. Do not fold a
pass's precondition into another pass: the Kahn counts were first computed in ComputeReceivers for free,
and because the walk consumes them a second call returned a silently wrong area that only a benchmark caught.
And the painting has a browser twin (D-66). Tools/Orogen/ is World Orogen, the GPL browser planet
generator D-65 borrowed its smooth from, and its import page now has a Painted Map source that reads the
same painting and legend terrain plan does and solves them on its 204K-region sphere mesh in about
twelve seconds: the same classify, vote, stroke, coast, massif, rock and swell steps, then a Braun-Willett
solve with the ocean fixed, then Orogen's own climate, satellite view and exports - plus class, uplift,
erodibility, drainage, slope and basin layers. It is for looking at a painting's rivers on a globe in half a
minute and tuning the legend's numbers in a table before a two-hour bake; it is not ground a player can
stand on, and it carries no faults, craters, plates or detail passes, because a 44 km cell cannot
show them. The legend is the contract between the two tools: new keys optional, nothing renamed. Serve the
folder with any static server (npx serve Tools/Orogen) and open /import.
And the twin reads the planet's own files now (D-67). Three things carried across, each one a question an
author has while typing a number. The class table prints the typical hillslope angle a rate makes and
what it reads as, with the divide angle in the tooltip - plan.go's functions ported exactly and checked
against plan.json to 1e-9 - because the divide is the steepest ground a rate can make, almost none of a map
is divide, and reading it as the landscape is how a legend gets set two or three times too hot. The
overlay is carried as a texture over the globe and the map rather than voted onto the mesh, since a road
is 8 px and a region there is 44 km; marks are voted for one thing only, coast_jitter. And Planet.json is
read directly, outranking a legend's own planet block because it is the file the bake reads. terrain studio serves all of it: share.go sets the CORS header on GET and HEAD alone and answers no preflight,
so Load from studio brings the whole planet in four seconds, including strokes made since the last save,
and no browser tab can ever paint, save, plan or bake. The two tools are not interchangeable and the twin is
not on a path to replace the bake: the 8 m geology grid, the coast pass, the faults, the craters and the
detail tiles are the Go tool's, and only they produce ground a player can stand on.
And there is ground in Unreal now, 900 km² of it (D-69). L_Region is a window cut out of a finished planet heightmap and laid out as a grid of landscapes - 6 x 6 tiles of 2551 vertices at 200 cm, 10 x 10 components of 255 quads each, 30.60 km a side, 936 km² of map holding 905 km² of land, built one batch at a time by Scripts/Authoring/build_region.sh. Its contract is RawContent/World/Region.json and RawContent/World/README.md is how to move the window. Today's source is the Orogen twin's export, and three things about it are the whole design. The scale is a choice, not the planet's: the export carries none, and at Planet.json's 100 km circumference the planet is 31.8 km across, so a flat 30 km square does not fit on it at all; 22.583 m a pixel is a 185 km circumference, the finest reading whose best window still clears 900 km² of land. The map is read flat, which at latitude -24 stretches the ground 9.6 % east-west - cheaper than any projection over a patch two thirds the width of the planet. The heights are Orogen's normalised metres, not the generator's: terrain bake makes this same continent a 116 m plain and the export makes it 2972 m, so the relief is art. The source resolves about 200 m, so below that the ground is smooth and no upsample can invent what is not there - the detail is still the Go tool's, and when terrain tiles replaces the window as the source, generate_region_tiles.py is the only thing that changes. Seams are exact rather than blended, because every vertex is sampled from its global position in the window. Do not run a landscape import for the whole grid in one process: a hundred components cost about a gigabyte the editor never gives back, and thirty-six tiles in one run reached 14.7 GB by the ninth. Do not pass a script's arguments after -script= either - they go inside the quoted value, and the engine silently eats them otherwise.
And Orogen exports that tile set itself now (D-71). The Python cutter exists because Orogen gives two
things Unreal can import neither of - a picture, and one flat 8192 x 4096 PNG with no scale on it, which
is what forced metres_per_pixel to be a number somebody chose. The browser twin's export card now has
Unreal Landscape…: it renders a window of the planet straight into the tiles the importer wants - a
16-bit height at 255*N+1 vertices and three 8-bit weightmaps per tile, plus the Region.json describing
them - into a folder picked through the File System Access API, so the files land in RegionTiles/ and
create_region_world.py reads them unchanged. The window is sampled once and then cut: every tile is
resampled out of one float raster by its global vertex position, which is what makes a shared column
bit-identical (measured: 0 of 1021 vertices differ on either seam, heights and paint). A tile rendered under
its own camera would put a rasteriser's luck on every seam, where one 16-bit step is 11 cm of crack. The
paint closes only because tiles carry a one-vertex margin for the slope's central difference - D-69's numpy
defect, met again in JavaScript. The panel is for the number you have not typed yet: it re-plans on the
keystroke and prints ground, components, files, sample spacing and what the flat reading costs, and it
immediately said what this project had not: 936 km² on a 100 km planet is 29 % of the entire globe, a
window 110 degrees on a side stretched 74.7 % at its edge, because a 100 km circumference is a 3183 km²
world. The projection is cosine-corrected at the centre latitude so the error splits between the two edges;
the defaults are 4 x 2 tiles, 208 km² at 5.4 %. It does not make ground finer - the mesh resolves a
couple of hundred metres and no sampling invents what is not there - so generate_region_tiles.py stays,
because it is the path that will read terrain tiles. Two traps worth keeping: never <input type="number">
for a decimal (it parses in the browser's locale, so a comma-decimal machine turns 0.17 into NaN and writes
a tile set of nothing), and a build now refuses to start when the editor holds the level (D-71a) -
--rebuild empties first and saves last, so a lock detected late is indistinguishable from data loss.
And the ground is collected in one place now (D-69a). Content/Terrain/ is the project's own ground, copied out of the asset packs by Scripts/Authoring/collect_terrain_assets.py from the list in RawContent/Terrain/ground.json; adding a substance is a line in that file and a rerun. A copy is not ownership - a duplicated material function still samples the pack's textures, because the reference is inside the graph - so the copies are repointed at each other and the asset registry is then asked what still points outside the folder. The packs are never modified. Elite_RockyMeadows' sky kit stays separate in Scripts/Authoring/rocky_meadows.py, which both create_world.py and create_region_world.py now dress from.
And there is a map of it now, in the game and in the editor (D-73). The three candidate sources were one:
Region.json imports the whole Orogen export with no crop, so L_World is the planet map and
world-to-map is u = (X/100 + 35700) / 71400 — one multiply and one add per axis. There is no scene capture
and no render target, and a capture would have been wrong rather than merely expensive: the level is
world-partitioned, so a capture only ever sees the streamed-in region, and a map is the thing that must show
ground nobody is standing on. FWorldMapProjection in SaltyCore is the whole transform, pure and tested;
SWorldMap is one Slate widget with three ways in — UWorldMapWidget for UMG, the editor's Window →
World Map tab, and bs.WorldMap straight over the viewport — because the editor tab has no UWorld at all
and anything needing one could not have been shared. A whole cylinder wraps, so a view across the seam is
two draws of two parts of one image and WrapU uses floor rather than FMath::Fmod, which keeps its
argument's sign and would leave a westward pan sampling nothing. The art is Tools/MapArt, a Go tool, because
the engine's Python cannot decode 33 megapixels of RGB — heightmap_io.py is greyscale-only and unfilters a
byte at a time — and it renders four 4096 x 2048 layers in six seconds from RawContent/World/MapArt/layers.json.
The default layer, relief, is derived rather than exported: hillshade and a hypsometric tint off the same
heightmap the landscape was cut from, so the map and the ground are the same shape by construction, where a
colormap shows biome and a player needs landform. Nothing inside a PNG says which planet it is, so mapart check measures land/sea agreement against the heightmap instead — the pasted orogen-colormap-14733759 scores
97.5 % against orogen-heightmap-7945 and is the same world; Orogen numbers each export, not each planet.
The definition's projection is copied out of Region.json and never typed, because a map that disagrees with
the landscape about how big the world is is the one bug here that still looks like a plausible map. Overlay
marks are not drawn yet and transfer by the same normalised coordinates when they are.
Where it is going: the coastal detail pass, which the shelf and the shore platform have just unblocked. Composition tuning is parked, and on a painted world most of it is the author's job now.
Next: step 3, the body, the camera, the input map and the gym. Step 1's PIE proof (two clients, dedicated server, two pawns in the gym) is still a person's to tick. Update this section when a step closes.
Layout
The .uproject sits at the repository root, which is what the .gitignore assumes.
| Path | What it is |
|---|---|
Salty.uproject, Source/Salty.Target.cs, SaltyEditor.Target.cs, SaltyServer.Target.cs |
The project and its three build targets. The server target needs a source-built engine (OD-04) |
Source/SaltyCore/ |
Rules, data types, tags, the telemetry contract. Knows no AActor. Tests in Tests/ |
Source/Salty/ |
Gameplay: Core/, Stats/, Movement/, Interaction/, Combat/, Crafting/, UI/, World/, one folder per feature. Variant_* is template reference (D-41) |
Source/SaltyEditor/ |
Editor-only tools: Authoring/ (the landscape library the Python scripts call), WorldMap/ (the dockable map tab). May depend on Salty; never the reverse |
Content/<Feature>/ |
Assets per feature: Definitions/ for data assets, Blueprints, meshes, montages |
Content/Maps/L_Gym |
The movement test level, authored by Scripts/Authoring/create_gym.py. Never leaves the project |
Content/Terrain/ |
The project's own ground: textures, layer functions, the landscape material and its layer infos, collected out of the packs (D-69a). Built by collect_terrain_assets.py from RawContent/Terrain/ground.json |
Content/Maps/L_World |
The world. Ground from a planet map, one landscape a tile (D-69, D-72). Built by Scripts/Authoring/build_region.sh; the contract is RawContent/World/Region.json, written by hand or by Orogen's Unreal export (D-71). Never run a build with the editor holding the level |
Content/Maps/L_Canvas_Proto |
The numpy pipeline's square 14.28 km canvas, legacy (D-47). Built by create_world.py from World.json. Named apart from L_World on purpose: that script empties whatever level it is handed |
Content/World/ |
World dressing the project owns rather than borrows. M_Sea_Proto, the placeholder grey the sea plane wears; authored on demand by rocky_meadows.sea_grey_material |
Content/World/Maps/ |
The world map's textures and DA_WorldMap_L_World (D-73). Built by Scripts/Authoring/build_world_map.sh; the contract is RawContent/World/MapArt/layers.json and the projection is copied out of Region.json |
Tools/MapArt/ |
Renders the map's layers from the planet images: downsamples what is already coloured, shades relief from the heightmap. Go, because the engine's Python cannot decode 33 megapixels of RGB |
Content/Tests/ |
Functional test maps |
Config/Tags/<Feature>.ini |
Gameplay tag source of truth, one file per top-level namespace |
Scripts/ |
run-tests.sh, build.sh, and Authoring/ for editor Python scripts. Run by hand; there is no CI. UE_ROOT overrides the engine path |
Docs/ |
The documentation set. Update it when you change how something works |
Docs/Worklog.md |
One terse line per step closed or wall hit |
Module dependency is one way: Salty depends on SaltyCore, never the reverse. If the core module seems
to need an actor, the thing it needs is data.
Conventions
- The server decides. Every mutation is a
Server*path that validates its instigator and re-checks its preconditions, even in standalone play. The client predicts its own movement and its own animation, nothing else. Never write a listen-server assumption or an "is this the host" branch. - Rules are pure C++ in the core module; actors and components adapt them. A preview and the real thing call the same function. Every pure rule has automation tests, one per rejection reason.
- One funnel per consequence. All damage through
UDamageExecution; all interactions throughUInteractionSubsystem; all activity quality throughFActivityResult. Do not add a second path. - One stat block; everything is an effect on it. Every body has
UStatBlockAttributeSet; every buff, debuff, surface, weight, gear and base value is a gameplay effect applied throughApplyStatusorApplyDefaults. Never add a multiplier, a modifier list or a status timer to a component. Counters areImmune.*tags and resist attributes on the receiver. SeeDocs/Spec/Stats.md. - Content is data.
UPrimaryDataAssetsubclasses, addressed by gameplay tag or primary asset id, art through soft references. No string ids. If a variant needs code, the model is wrong. - Gameplay Tags are the vocabulary. Defined in
Config/Tags/; tags code references are declared natively withUE_DECLARE_GAMEPLAY_TAG_EXTERN/UE_DEFINE_GAMEPLAY_TAG. No string literal tag at a call site. - C++ owns rules, replicated properties and RPCs. Blueprints own composition, tuning and cosmetics. A
Blueprint is a child of a C++ class that sets assets and numbers and wires
*_Cosmetichooks. - Lifetimes are the engine's. Game-instance subsystems for the application, world subsystems for a map, the player state for a player across bodies, the pawn for a body. Nothing about a player that would matter tomorrow lives on the character actor.
- No world searches in gameplay code. No
GetAllActorsOfClass, no static gameplay singletons. Subsystems are the registries; actors register inBeginPlayand unregister inEndPlay. - Every rejection carries a reason tag a player can read. Nothing is destroyed silently.
- Emit telemetry through
UTelemetrySubsystemwith names fromTelemetryEvents, never a literal, never a static helper. Every feature emits; the catalogue inDocs/Spec/Telemetry.mdand the feature spec agree. - Naming is Unreal's:
A/U/F/E/Iprefixes, no project prefix on classes,PascalCasemembers, asset prefixesBP_ DA_ DT_ IA_ IMC_ GA_ GE_ GC_ ABP_ AM_ SK_ SM_ M_ MI_ WBP_ T_ L_. Placeholders carry_Proto. - Units are centimetres and seconds.
FTextfor anything a player reads.TObjectPtrfor object properties. - The design word "material" is "substance" in code and docs.
UMaterialis the engine's. - Do not add an interface, a module or an abstraction for one implementation with no test double. The two source projects were each trimmed of speculative abstraction once already; the interfaces that exist here are the ones with several implementations named in the specs.
Commands
Scripts/run-tests.sh # headless: UnrealEditor-Cmd Salty.uproject -ExecCmds="Automation RunTests Salty; Quit" -unattended -nopause -NullRHI
Scripts/run-tests.sh Core # a filter: Salty.Core.*
Scripts/build.sh Win64 Development # UAT BuildCookRun for the game target; add -server for the server target once the engine is a source build
D:/UE_5.8/Engine/Build/BatchFiles/Build.bat SaltyEditor Win64 Development -Project="<abs>\Salty.uproject" -WaitMutex # compile the editor from a shell
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/create_gym.py # rerun an authoring script
Scripts/Authoring/build_world_map.sh # the world map: render the art, import it, write the definition asset
cd Tools/MapArt && go run . check # is every map layer the same planet L_World was cut from?
In the editor the map is Window → World Map; in game it is M, with N to switch layers - both
DebugExecBindings in Config/DefaultInput.ini onto bs.WorldMap and bs.WorldMapLayer, which is the
engine's own mechanism for binding a key to a console command and exists in development builds only. It needs
no Input Action, no mapping context and no asset, so it does not pre-empt step 3, where the real input map is
designed; when the map gets a HUD it gets an Input.* action and those two lines go away. Drag to pan, wheel
to zoom about the cursor, right-double-click to fit, left-double-click to go back to the player and follow
them again (bs.WorldMapFollow does the same). The map opens centred on you with an arrow, haloed so it is
findable at whole-world zoom, pointing where you are looking; panning by hand drops the follow deliberately,
because a map that snaps back as you let go of it cannot be read.
There is no CI, deliberately, and there will not be until there is a reason. Run the tests yourself before saying
a step is done. Every replicated step is also played in the editor with two clients, "Run Dedicated Server" on, and
network emulation at 100 ms and 5 % loss with p.NetShowCorrections 1.
Working with the editor
- Prefer editing C++ and
.inifiles directly. Blueprints and maps are binary; keep them thin and keep logic out of them so a diff can be reviewed. - Batch authoring (a folder of definition assets, a greybox level from a table) goes through the editor's Python
API in
Scripts/Authoring/, idempotent, kept so it can be rerun. Do not hand-edit.uassetfiles. - Blueprint-side wiring an authoring script cannot express is done once in the editor and recorded in the step's worklog line so it can be redone.
- Watch for the compile trap: a C++ error that stops the editor launching is fixed in the files, with the build output, not by guessing. The editor's Live Coding is fine for iteration and unreliable for header changes; a header change means a full rebuild.
- Every
.uassetand.umapgoes through Git LFS;.gitattributesis created in step 1. Rungit lfs installonce per machine. Never commitSaved/,Intermediate/,DerivedDataCache/orBinaries/; the.gitignorecovers them.
Documentation upkeep
- A step that lands updates: its status in
Docs/Steps.md; a What was built, and where it differs section at the end of the spec it built from; a line inDocs/Worklog.md; a line inDocs/Decisions.mdif a decision was taken; and the "Where the project actually is" section above. - A design change that contradicts a decision names the decision it supersedes in the log. Never delete a log line.
- Do not turn
Docs/Steps.mdinto a roadmap. Detail the next step when the current one closes, not before.