feat(shared): terrain as a column of solid intervals, and movement onto a surface #283

Merged
panda merged 21 commits from feat/volume-world-f1 into main 2026-08-08 15:35:58 +02:00
Collaborator

Summary

The world stops being a heightfield and becomes a volume: every cell is an ordered column of
half-open solid intervals [bottomZ, topZ), of which today's single-surface terrain is just the case
"one interval, from the world floor, in the default rock". This is the format and the read model — the
groundwork tunnels, caves and mining need (#268), landed before anything is authored on top of it.

Two consequences reach gameplay in this branch, both deliberate:

  • movement resolves a SURFACE, not a cell. A step picks which surface of the destination column you
    arrive on, and the server picks it — the client never says where it landed.
  • rock is walkable where it lies flat, not where it rises. A terrain may declare maxSlopeZ in
    tiledata and is then walkable only where its height differs from its neighbours by no more than that.
    It closes the hole no step-height gate can: every single step up a massif is legal, and their sum is a
    wall you strolled up. A new unscalable ground is one row of content, zero code.

The design decisions, the rejected alternatives and the reasoning are in
docs/superpowers/specs/2026-08-07-volume-world-model-design.md; the durable rules went into CLAUDE.md
(occupancy conservatively CONTAINS rendered solid; logical cells are never drawn; half-open everywhere,
so no +1/-1 at call sites).

Screenshots / recording

The one visible change here — feat(client): nothing above the player is drawn — is the first, blunt
version of the terrain cutaway, and it is superseded in the next PR of this stack
(feat/terrain-cutaway), where the behaviour is made structural and gets its screenshots. Capturing them
against this intermediate state would document something we deliberately replaced. Everything else in
this branch is format, read model and server-side rules, with no visible surface.

How it was tested

  • Unit tests throughout IsoMmo.Shared.Tests for the column/interval contracts: canonical ordering,
    half-open boundaries, validated-on-read with no repair (an unrecognised section is refused, never
    silently fixed), and the packed-storage accessors.
  • A frozen pre-F1 chunk payload test, so the format change is proven byte-compatible where it must be.
  • Gates on this exact commit: CSharpier clean (577 files), dotnet test green (10 test projects),
    whole solution builds with zero warnings.
  • IsoMmo.MapBench gained a warm-load RAM + timing phase to baseline the format before the change.

Checklist

  • just lint passes (CSharpier + analyzers, zero warnings)
  • just test is green
  • The whole solution builds (client and tools included)
  • Multi-platform preserved (server on Win/macOS/Linux, client on Win/macOS)
  • Tests added/updated for this change
  • Linked the related issue — this is the F1 slice of the multi-level world epic #268; it does not
    close it.

Stack: this is 1 of 3. feat/terrain-cutaway sits on top of it, and feat/map-editor-redesign on
top of that. Merge in order.

## Summary The world stops being a heightfield and becomes a **volume**: every cell is an ordered column of half-open solid intervals `[bottomZ, topZ)`, of which today's single-surface terrain is just the case "one interval, from the world floor, in the default rock". This is the format and the read model — the groundwork tunnels, caves and mining need (#268), landed before anything is authored on top of it. Two consequences reach gameplay in this branch, both deliberate: - **movement resolves a SURFACE, not a cell.** A step picks which surface of the destination column you arrive on, and the server picks it — the client never says where it landed. - **rock is walkable where it lies flat, not where it rises.** A terrain may declare `maxSlopeZ` in tiledata and is then walkable only where its height differs from its neighbours by no more than that. It closes the hole no step-height gate can: every single step up a massif is legal, and their sum is a wall you strolled up. A new unscalable ground is one row of content, zero code. The design decisions, the rejected alternatives and the reasoning are in `docs/superpowers/specs/2026-08-07-volume-world-model-design.md`; the durable rules went into `CLAUDE.md` (occupancy conservatively CONTAINS rendered solid; logical cells are never drawn; half-open everywhere, so no `+1/-1` at call sites). ## Screenshots / recording The one visible change here — `feat(client): nothing above the player is drawn` — is the first, blunt version of the terrain cutaway, and it is **superseded in the next PR of this stack** (`feat/terrain-cutaway`), where the behaviour is made structural and gets its screenshots. Capturing them against this intermediate state would document something we deliberately replaced. Everything else in this branch is format, read model and server-side rules, with no visible surface. ## How it was tested - Unit tests throughout `IsoMmo.Shared.Tests` for the column/interval contracts: canonical ordering, half-open boundaries, validated-on-read with **no repair** (an unrecognised section is refused, never silently fixed), and the packed-storage accessors. - A frozen **pre-F1 chunk payload** test, so the format change is proven byte-compatible where it must be. - Gates on this exact commit: CSharpier clean (577 files), `dotnet test` green (10 test projects), whole solution builds with zero warnings. - `IsoMmo.MapBench` gained a warm-load RAM + timing phase to baseline the format before the change. ## Checklist - [x] `just lint` passes (CSharpier + analyzers, zero warnings) - [x] `just test` is green - [x] The whole solution builds (client and tools included) - [x] Multi-platform preserved (server on Win/macOS/Linux, client on Win/macOS) - [x] Tests added/updated for this change - [ ] Linked the related issue — this is the F1 slice of the multi-level world epic **#268**; it does not close it. --- **Stack:** this is 1 of 3. `feat/terrain-cutaway` sits on top of it, and `feat/map-editor-redesign` on top of that. Merge in order.
A cell stops being a height and becomes a column of solid intervals, so rock is
matter and caves, tunnels and arbitrary levels are expressible. Today's authored
world is already a valid instance (one interval per column), so no reconversion.

Supersedes the representation decision of #268 (surfaces = land + walkable
statics): we follow ModernUO on movement and diverge on representation, because
this game wants mines and their model can only simulate them. The cube is the
logical/collision cell — the rendered mesh stays smooth and may protrude past it.

Design only; each phase gets its own plan, PR and DoD.
Reviewed revision of the volume world design. Corrects two overclaims in the
first draft: "no conversion" now means no flag-day rewrite (legacy v1 adapts to
one implicit interval, v2 is used only where a column needs it), and the cutaway
is deleted only after its structural replacement passes F4's gates — a global
"discard everything above the player" is explicitly forbidden.

Adds the half-open canonical interval form, the watertight seam contract for
extracted boundaries, surface sheets instead of a level ordinal, and swept
transition + diagonal corner rules in movement, without which a valid
destination support let a mover pass through solid rock.

F3 now carves two vertically stacked tunnels, because F4's DoD requires
selecting the correct support from absolute Z and one tunnel cannot show it.
Terrain material becomes a typed TerrainMaterialId naming the geological
material inside the rock, deliberately separate from the surface biome: derive a
tunnel's walls from the biome and a meadow gets grass walls. Legacy columns take
one declared default rock material; touching intervals merge only on a match.

Drops the multi-cell footprint from SurfaceResolver. A mover occupies one
logical cell, as in UO where a dragon is graphically huge and logically one
tile; only vertical clearance varies. A real 2x2 mobile needs its own design for
rotation, pathfinding, spawn, diagonals and AoI, so it is not anticipated here.

F3 stays a hand-built fixture with a single tunnel; the stacked-tunnel gate
moves to F4 as a test-only synthetic fixture.
Seven tasks, each green and committable: canonical half-open intervals with a
geological material; an optional interval section in the chunk payload so v1
regions stay readable and unrewritten; the complete TerrainColumnAt/StaticsAt
contracts with GroundZAt/StaticAt derived from them; packed per-map storage so
keeping the statics' Z costs no per-cell collection; a cell-by-cell golden test
of both projections against the authored world; RAM, load-time and on-disk size
gates; and the occupancy-contains-render rule into CLAUDE.md.

The legacy collapse rule is reproduced, not redesigned: today's warm load
overwrites per cell, so StaticAt returns the last static in the chunk's stored
order.
A column is legacy-simple only when it is exactly [Floor, groundZ) in the default
rock: a single sandstone span, or one that does not start at the floor, is
extended too. Encoding either as a dense Z silently lost it.

The disk door and the authoring door are now different functions: TryCanonicalise
may sort authored input, ValidateCanonical rejects an out-of-order payload rather
than repairing it into something plausible.

Also: contracts and packed storage land in one task, since an intermediate commit
with a collection per cell would break the constraint it exists to satisfy; a
pipeline test proves payload -> chunk -> load -> packed -> contracts drops
nothing; the bench harness lands before the loader so the baseline is measured
with the same code, under a declared protocol (Release, separate process,
warm-up, median of five); a frozen pre-F1 payload fixture replaces the
new-serializer-into-new-deserializer round trip; and every format validation rule
is decided here — precedence, duplicates, empty span counts, out-of-chunk coords,
material indices, with errors naming chunk, cell and index.

TerrainZ.Floor = Min - 1 makes a legacy column non-empty even at the lowest
authorable surface, which declares structural bedrock: F5 may not dig away a
column's last solid without redesigning TerrainColumnAt's promise.
The fossil of the v1 chunk format, captured while the serializer is still
pre-F1. A round trip of the new serializer through the new deserializer would
prove nothing — two symmetric bugs pass it — so the only independent witness the
format has is a payload that predates the change.

219 bytes, flags 0x03 (elevation + statics, intervals bit absent), with two
statics on cell (3,5) at Z 10 then 40: the case F1 must stop discarding, and the
case the legacy StaticAt projection must keep collapsing to the last.

Never regenerated: a failing test against it means the code changed, and
re-blessing the bytes would delete the evidence. Provenance, contents and the
expected assertions are recorded beside it.
A column becomes a canonical list of half-open solid spans carrying a geological
material. Half-open makes "touching" exact and bans +1/-1 at call sites.

TerrainZ.Floor separates the bottom of the world VOLUME from the lowest
authorable SURFACE: without it a cell authored at Min would read as the empty
span [Min, Min) and break the promise that every column has at least one
interval. Declared consequence: [Floor, Min) is structural bedrock.

TerrainColumn has two doors on purpose. TryCanonicalise is for authoring and may
sort; ValidateCanonical is for payloads read from disk, where order is part of
the contract and repairing it silently would let a map rot unnoticed. A test
pins that the same input is accepted by one and rejected by the other.

The manifest gains a geological material catalog, separate from the terrain
(biome) catalog; a v1 manifest is adapted on read to the one-rock world it
always was, never rewritten.
One measurement per invocation on purpose: the gate's protocol is a separate
process per run with a discarded warm-up and a median, and a loop inside one
process would time a JIT-warm second load instead.

Pre-F1 baseline on the authored world (Release, six invocations, first
discarded): 400,167,440 bytes after load, median 790.7 ms over 133,226,496
cells, 0 cells carrying statics.

Taken now, while the loader is still untouched, so the after-measurement
compares identical harness code rather than two different harnesses.
A third optional payload section behind IntervalsFlag, so a v1 chunk simply
lacks the bit and no authored region file is rewritten. A cell whose column the
legacy encoding cannot express carries a sentinel in the dense elevation array
and its canonical spans in the section; the decoder checks that correspondence
in BOTH directions, so neither a span without a marker nor a marker without
spans can slip through.

"Extended" is not "has a tunnel": a single sandstone span, or one that does not
start at the world floor, is extended too. Encoding either as a dense Z would
quietly lose the material or the bottom.

Every format rule is decided here rather than discovered later — out-of-order
spans, overlap, unmerged touching spans, empty span counts, duplicate cells,
out-of-chunk coordinates, stray markers — each rejected with an error naming the
chunk and the cell, and each pinned by a hand-built payload the encoder would
refuse to produce.
TerrainColumnAt and StaticsAt return everything the format holds — every solid
span with its material, every static with its Z, in stored order — while
GroundZAt and StaticAt become derived views answering exactly what they answered
before. Nothing migrates in this phase.

Packed, never a collection per cell: one flat array plus (offset, count) for the
cells that have anything, and a shared table of single-interval columns so a
legacy column is returned as a span without allocating or keeping scratch. An
extended column is marked in the dense elevation array by a sentinel outside the
world bounds and stored once, in the overlay — so its outer surface exists in
exactly one place.

Contracts and packing land together on purpose: an intermediate commit holding a
collection per cell would violate the very constraint this task exists to meet.
The pipeline test crosses every boundary at once — payload, chunk, region file,
warm load, packed storage, complete contracts — and compares the arrays whole.
Every other test covers one link, and none of them would notice a loader that
still kept only a cell's last static: the units would pass and the world would
quietly be poorer.

The golden test reads its oracle straight from the chunk payloads, so it
compares the new backend against the FORMAT rather than against itself, walks
only materialised chunks, reads each chunk once, and compares the whole
StaticTile rather than the id. Mutation-checked: adding 1 to the ground
projection makes it fail naming the cell.
The load-time gate caught this: the first cut called IntervalsAt for every cell,
which on the authored world is 133 million dictionary lookups into a dictionary
that is always empty, and cost 12% of the world load — outside the +10% budget.

Hoisting the question to once per chunk brings it back to +4.8% (median 828.3 ms
against a 790.7 ms baseline, same harness and protocol), with RAM +0.0023%
(400,176,800 bytes against 400,167,440).
The durable rule the volume model adds, into CLAUDE.md as an invariant and as
its Design-checklist bullet so the hand-maintained index stays in lockstep:
logical occupancy conservatively contains rendered terrain solid, the mesh may
cut through an occupied cell but never create solid outside it, logical cells
are never drawn, and simulation reads occupancy while only the client reads the
mesh.

The format doc gains the why — half-open spans, geological material separate
from the biome, the world floor and its structural-bedrock consequence, when a
column is legacy-simple and therefore free, and that the decoder rejects rather
than repairs. The byte layout stays with the code and its golden tests.
"Suppress the sheets above the support AND in the camera-to-player occlusion
region" still asked for a geometric occlusion search — which is precisely what
four rejected attempts were. The interval model already knows which spans
enclose the space the player stands in; F4 reads that and may not reintroduce
the search under another name.

Also stops treating OccluderCutaway as the baseline the replacement must match:
it was rejected on screen, so equivalence with it would inherit its mistakes.
F4's gates are its own transition and regression cases.
Seven tasks. A pure SurfaceResolver answers "stepping from here, which surface
do I arrive on"; movement calls it instead of CanStep; the mobile gains an
intrinsic absolute Z, persisted with its own version bumped and broadcast with
the protocol bumped to 21.

Two rules the design named but did not spell out are pinned as tests here. The
swept transition: stepping up needs room to stand WHERE YOU ARE first, or a
mover reaches a legal-looking support by passing through the rock over its own
head. And the tie-break: when several supports are within the step height the
nearest wins, so the answer is deterministic rather than whichever the loop saw
last.

Mobile.Position stays the cell as a derived view of the new point, the same
coexistence trick F1 used, so consumers that only want a cell are untouched.
A world point is a cell plus the absolute Z of the surface something stands on —
a different type from GridPosition, which stays the cell, because two entities
can share a cell on different levels. A cell was never a position; it only
looked like one while the world had a single surface.

SurfaceResolver answers the step in one pure function: gather the destination's
supports (the top of every span with void above it), keep those within the step
height, require head-clearance, and require the swept transition. Two rules
earned their tests the hard way:

Every constraint is applied to EACH candidate. Picking the nearest support and
checking clearance afterwards refuses a legal step, because a low floor with a
shelf just above it is nearer than the shelf and unusable.

Stepping UP needs room to stand where you already are. Without it a mover
reaches a perfectly legal-looking support by rising through the rock over its
own head, and a wall becomes a doorway.
Movement asks SurfaceResolver instead of CanStep, and the arrival Z it returns
is where the mobile goes. On a single-surface world every answer is the one
CanStep gave — all 346 server tests pass untouched — but the machinery
underneath now handles a cell with several surfaces.

A mobile's position becomes a WorldPoint, with Position kept as the cell view so
combat, items, AoI and spells are untouched. MoveTo is the self-validating
mutator: a mobile may not stand outside the world's bounds whatever a caller
computed.

Placement, teleport and save-restore all settle onto a real surface, because a
cell is not a position any more. The saved Z is a PREFERENCE, not an
instruction: a player restored where the ground was re-authored stands on the
new ground, and a pre-F2 blob carries no Z at all and takes the same path.

Protocol 21: PlayerState, PlayerMoved, MobileState and MobileMoved carry the
absolute Z. The client stores it and keeps drawing at the display height until
F4 gives it a level to draw.
The rule is blunt and screen-wide: a terrain fragment whose world Z stands above
the player's head disappears, with a couple of units of dithered feather so the
edge is not a ruled line. Terrain at or below their height — the ground they
stand on, the hills beside them — is untouched.

This is the sixth attempt and the first the owner accepted. Four were
screen-space shapes over a heightfield; the fifth traced a real line of sight
through the volume model's occupancy and answered "which rock hides you"
exactly. It worked, and was rejected on sight: it opens a slot through the
mountain, and a slot you can just about see through is worse to play with than a
clean horizon. Being right about which rock hides you turned out not to be the
goal.

The design doc said this rule was forbidden because it erases mountains that
were never in the way. It does, and that is UO's dungeon behaviour; the doc is
corrected rather than the game, with the reasoning recorded so nobody
re-litigates it from the old text.
feat(shared): rock is walkable where it lies flat, not where it rises
All checks were successful
ci / Lua content lint (pull_request) Successful in 11s
ci / Lint & Test (pull_request) Successful in 10m32s
56ddb317f8
A terrain row may declare maxSlopeZ: that ground is walkable only where its
height differs from its four neighbours by no more than that. Rocky, mountain
and volcanic ground take 2, so the same rock is crossable on a shore or a
plateau and impassable on a flank, at any height.

This closes a hole no step-height gate can. Every single step up a massif is
within MaxWalkStepZ and therefore legal; their sum is a wall you strolled up,
and the climb budget only slows that down. Conditioning the terrain on its own
steepness is UO's answer, with roads — their own terrain — as the way up.

Measured on the authored world: 99.6% of rock stays walkable (91.6% of rock
cells have no height difference to any neighbour at all), 0.4% closes, and that
0.4% is enough to make the rock summit unreachable on foot.

Also restores mountain_rock to walkable, which #278 had turned off outright: the
slope rule expresses what that change was reaching for without closing the flat
rock as well.
panda merged commit bf8f8f1d10 into main 2026-08-08 15:35:58 +02:00
panda deleted branch feat/volume-world-f1 2026-08-08 15:35:58 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo!283
No description provided.