feat(world): terrain Z domain + elevation sidecar + quoted projection (Slice A of #224) #225

Merged
panda merged 1 commit from feat/terrain-z-domain into main 2026-07-31 12:22:18 +02:00
Collaborator

Slice A of #224 (authoritative terrain Z / UO heightfield). Pure domain + format + projection math — no visible change; the renderer is Slice B.

Summary

Lays the authoritative foundation for per-point terrain elevation, so later slices (renderer, editor, server walkability) all read one contract:

  • TileMap.GroundZAt(pos) — the read contract every consumer will depend on (renderer, editor, gameplay). Backed by a dense short[,] for now, but nothing depends on that concretely — a future chunked map can implement the same method. Out-of-bounds reads 0; a map with no elevation is entirely Z0.
  • TerrainZ — the valid signed range -225..255 declared once (storage is Int16; the authoring range is intentionally narrower/asymmetric, UO-style) and reused by loader + validation.
  • Elevation sidecar behind IMapFormatReadMap/WriteMap gain an elevation string (a world.zmap sidecar). It's sparse (x y z per non-Z0 cell, like statics) so a mostly-flat map stays tiny; retro-compatible (absent → Z0, so existing two-file maps load unchanged); bounds-checked (rejects Z outside range and out-of-map cells); exact round-trip. Callers read/write the file but never parse it. A 3-arg ReadMap convenience keeps flat call sites clean. Documented as a dev-phase text form — the world-scale binary format (#114) will carry Z in the terrain chunk.
  • TileMap.CanStep(from, into, maxStepZ) — the UO step-height gate: destination walkable AND |ΔZ| ≤ maxStepZ. The threshold is a parameter (a typed server option lands with the walkability wiring in Slice C), never hardcoded.
  • IsoProjection.ToScreen(x, y, z) — the quoted projection (-z·ZScale), ZScale = 4 px/Z in one shared constant. z = 0 is bit-identical to the flat overload (the Z0 back-compat guarantee).

Consumers (client/server) still use the flat 3-arg read (all Z0) — they get wired to the sidecar in the slices that render/enforce Z. ProtocolVersion untouched (no wire change).

Screenshots / recording

N/A — no visible surface. Z is not rendered until Slice B (the windowed grid mesh). Every behavior here is proven by unit tests below.

How it was tested

Built test-first (RED→GREEN watched for each behavior, including breaking CanStep/elevation back to stubs to confirm the tests catch the missing logic):

  • IsoProjectionTestsToScreen(x,y,0) == flat overload; +1 Z == Y − 4; Z never moves X; ZScale == 4.
  • ElevationTests — no sidecar → all Z0; sidecar parsed into GroundZAt; accepts -225/0/255, rejects -226/256 and out-of-map cells; WriteMap→ReadMap round-trips every Z exactly; a flat map writes an empty sidecar (no bloat).
  • CanStepTests — flat step allowed; step of exactly maxStepZ allowed; above maxStepZ rejected; steep descent rejected by the same absolute rule; step into a blocker rejected even when flat.
  • Whole solution builds 0 warnings / 0 errors; dotnet test all green (Shared 122, Client.Core 171, GameServer 271, …).

Checklist

  • just lint passes (CSharpier + analyzers, zero warnings)
  • just test is green
  • The whole solution builds (client and tools included)
  • Multi-platform preserved (pure .NET / System.Numerics; no OS-specific or MonoGame dependency added)
  • Tests added/updated for this change
  • Linked the related issue (#224) — this is Slice A of its 3-slice plan; DoD Phase 1 items met (authoritative Z, centralized range, old maps load Z0, -z*4 single source, round-trip)
**Slice A of #224** (authoritative terrain Z / UO heightfield). Pure domain + format + projection math — **no visible change**; the renderer is Slice B. ## Summary Lays the authoritative foundation for per-point terrain elevation, so later slices (renderer, editor, server walkability) all read one contract: - **`TileMap.GroundZAt(pos)`** — the read contract every consumer will depend on (renderer, editor, gameplay). Backed by a dense `short[,]` for now, but nothing depends on that concretely — a future chunked map can implement the same method. Out-of-bounds reads 0; a map with no elevation is entirely Z0. - **`TerrainZ`** — the valid signed range `-225..255` declared once (storage is `Int16`; the authoring range is intentionally narrower/asymmetric, UO-style) and reused by loader + validation. - **Elevation sidecar behind `IMapFormat`** — `ReadMap`/`WriteMap` gain an `elevation` string (a `world.zmap` sidecar). It's **sparse** (`x y z` per non-Z0 cell, like statics) so a mostly-flat map stays tiny; **retro-compatible** (absent → Z0, so existing two-file maps load unchanged); **bounds-checked** (rejects Z outside range and out-of-map cells); exact round-trip. Callers read/write the file but never parse it. A 3-arg `ReadMap` convenience keeps flat call sites clean. Documented as a dev-phase text form — the world-scale binary format (#114) will carry Z in the terrain chunk. - **`TileMap.CanStep(from, into, maxStepZ)`** — the UO step-height gate: destination walkable AND `|ΔZ| ≤ maxStepZ`. The threshold is a parameter (a typed server option lands with the walkability wiring in Slice C), never hardcoded. - **`IsoProjection.ToScreen(x, y, z)`** — the quoted projection `(-z·ZScale)`, `ZScale = 4` px/Z in one shared constant. `z = 0` is bit-identical to the flat overload (the Z0 back-compat guarantee). Consumers (client/server) still use the flat 3-arg read (all Z0) — they get wired to the sidecar in the slices that render/enforce Z. `ProtocolVersion` untouched (no wire change). ## Screenshots / recording N/A — no visible surface. Z is not rendered until Slice B (the windowed grid mesh). Every behavior here is proven by unit tests below. ## How it was tested Built test-first (RED→GREEN watched for each behavior, including breaking `CanStep`/elevation back to stubs to confirm the tests catch the missing logic): - **`IsoProjectionTests`** — `ToScreen(x,y,0)` == flat overload; `+1 Z` == `Y − 4`; Z never moves X; `ZScale == 4`. - **`ElevationTests`** — no sidecar → all Z0; sidecar parsed into `GroundZAt`; accepts `-225`/`0`/`255`, rejects `-226`/`256` and out-of-map cells; `WriteMap→ReadMap` round-trips every Z exactly; a flat map writes an empty sidecar (no bloat). - **`CanStepTests`** — flat step allowed; step of exactly `maxStepZ` allowed; above `maxStepZ` rejected; steep descent rejected by the same absolute rule; step into a blocker rejected even when flat. - Whole solution builds **0 warnings / 0 errors**; `dotnet test` all green (Shared 122, Client.Core 171, GameServer 271, …). ## 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 (pure .NET / System.Numerics; no OS-specific or MonoGame dependency added) - [x] Tests added/updated for this change - [x] Linked the related issue (#224) — this is Slice A of its 3-slice plan; DoD Phase 1 items met (authoritative Z, centralized range, old maps load Z0, `-z*4` single source, round-trip)
feat(world): terrain Z domain + elevation sidecar format + quoted projection (#224)
All checks were successful
ci / Lua content lint (pull_request) Successful in 27s
ci / Lint & Test (pull_request) Successful in 5m45s
e73fdae309
Slice A of the UO heightfield: authoritative per-cell signed Z on TileMap (GroundZAt),
centralized TerrainZ bounds (-225..255), a sparse retro-compatible elevation sidecar behind
IMapFormat (absent -> Z0, round-trip, bounds-checked), CanStep height gate, and the quoted
IsoProjection.ToScreen(x,y,z) (-z*ZScale, ZScale=4). No visible change; renderer is Slice B.
panda merged commit c66c6da117 into main 2026-07-31 12:22:18 +02:00
panda deleted branch feat/terrain-z-domain 2026-07-31 12:22:18 +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!225
No description provided.