167 lines
13 KiB
Markdown
167 lines
13 KiB
Markdown
# 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`](Docs/Spec/README.md) (the notation and the twelve cross-cutting rules) and
|
||
[`Docs/Spec/Architecture.md`](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`](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`](Docs/Design.md). Ideas from the two earlier projects that are not built
|
||
are in [`Docs/Ideas.md`](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: `Content/Maps/L_World`, 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`](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 now (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`](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.
|
||
|
||
Where it is going: finish the detail passes so there is a full-resolution output at all, then make the source
|
||
a **painted map** — an author paints the uplift, the mask and the erodibility, never the height, and the solve
|
||
turns that intent into terrain with real drainage. Worlds are far larger than today's 14 km canvas, so the
|
||
geology solve stays whole (it cannot be tiled: drainage area is global) and only the detail passes tile. That
|
||
makes two rules binding on anything written now: index every noise and hash by **absolute world coordinates**
|
||
rather than grid index, and give every tiled pass an overlap margin. Composition tuning is parked.
|
||
|
||
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/`, one folder per feature. `Variant_*` is template reference (D-41) |
|
||
| `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/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 through
|
||
`UInteractionSubsystem`; all activity quality through `FActivityResult`. 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 through `ApplyStatus` or
|
||
`ApplyDefaults`. Never add a multiplier, a modifier list or a status timer to a component. Counters are
|
||
`Immune.*` tags and resist attributes on the receiver. See `Docs/Spec/Stats.md`.
|
||
- **Content is data.** `UPrimaryDataAsset` subclasses, 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
|
||
with `UE_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 `*_Cosmetic` hooks.
|
||
- **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 in `BeginPlay` and unregister in `EndPlay`.
|
||
- **Every rejection carries a reason tag** a player can read. **Nothing is destroyed silently.**
|
||
- **Emit telemetry through `UTelemetrySubsystem` with names from `TelemetryEvents`**, never a literal, never a
|
||
static helper. Every feature emits; the catalogue in `Docs/Spec/Telemetry.md` and the feature spec agree.
|
||
- **Naming is Unreal's:** `A`/`U`/`F`/`E`/`I` prefixes, no project prefix on classes, `PascalCase` members, asset
|
||
prefixes `BP_ DA_ DT_ IA_ IMC_ GA_ GE_ GC_ ABP_ AM_ SK_ SM_ M_ MI_ WBP_ T_ L_`. Placeholders carry `_Proto`.
|
||
- **Units are centimetres and seconds.** `FText` for anything a player reads. `TObjectPtr` for object properties.
|
||
- **The design word "material" is "substance"** in code and docs. `UMaterial` is 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
|
||
|
||
```bash
|
||
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
|
||
```
|
||
|
||
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 `.ini` files 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 `.uasset` files.
|
||
- 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 `.uasset` and `.umap` goes through Git LFS; `.gitattributes` is created in step 1. Run `git lfs install`
|
||
once per machine. Never commit `Saved/`, `Intermediate/`, `DerivedDataCache/` or `Binaries/`; the `.gitignore`
|
||
covers 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 in `Docs/Worklog.md`; a line in `Docs/Decisions.md` if 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.md` into a roadmap. Detail the next step when the current one closes, not before.
|