Comprehensive spec for completing the physics/movement system: CellPortal-based indoor/outdoor/room-to-room transitions via sphere-plane intersection, multi-landblock boundary crossing, momentum-preserving jump with gravity arc, portal-space state machine for teleports, Setup.StepUpHeight hookup, and scenery-on-road exclusion fix. Replaces the current "outdoor heightmap sampler with disabled indoor transitions" with the full world-navigation system AC's client uses. 12 acceptance criteria, ~20 new unit tests planned. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13 KiB
Phase B.3 Complete — Movement and World Navigation Design
Status: Spec, 2026-04-12, brainstormed with user.
Scope: Make the physics collision engine and movement system fully functional: CellPortal-based indoor/outdoor transitions, multi-landblock boundary crossing, jump with momentum-preserving gravity arc, portal-space state machine for teleports, Setup.StepUpHeight, and scenery-on-road fix. Replaces the current "outdoor heightmap sampler with disabled indoor transitions" with a complete world-navigation system.
Parent: docs/plans/2026-04-11-roadmap.md — Phase B (Gameplay), sub-piece B.3 completion.
Depends on: Phase B.2 (player movement mode) — already shipped. Phase B.3 partial (TerrainSurface, CellSurface, PhysicsEngine skeleton) — already shipped.
Goals
- Walk through a doorway into a building (outdoor→indoor via CellPortal plane crossing).
- Walk between rooms inside a building (indoor→indoor via CellPortal).
- Walk back out of a building (indoor→outdoor via CellPortal with OtherCellId=0xFFFF).
- Walk from one landblock to the next seamlessly.
- Jump while walking with momentum-preserving arc.
- Jump off a ledge and land on terrain below (gravity when not on ground).
- Enter a game portal → portal-space state → arrive at destination.
- Step-height from Setup.StepUpHeight (not hardcoded).
- No trees/stones on roads.
- ACE console shows no movement rejection errors.
Non-goals
- Charge jump (hold Space for higher jump) — deferred.
- Wall sliding (deflecting off walls at oblique angles) — deferred.
- Swimming / water depth detection — deferred.
- NPC/entity collision (only terrain + cell surfaces) — deferred.
- BSP tree acceleration for polygon lookups — brute-force polygon iteration is sufficient at cell-level polygon counts (< 20 per cell).
1. Cell Transition System
Data model
Each loaded EnvCell's portals are precomputed into portal planes at landblock-load time:
public readonly record struct PortalPlane(
Vector3 Normal, // plane normal (from portal polygon vertices)
float D, // plane distance (dot(normal, v0))
uint TargetCellId, // OtherCellId (0xFFFF = outdoor)
uint OwnerCellId, // the EnvCell that owns this portal
PortalFlags Flags); // PortalSide flag for disambiguation
Stored in PhysicsEngine per landblock alongside the existing TerrainSurface + CellSurface entries:
sealed record LandblockPhysics(
TerrainSurface Terrain,
IReadOnlyList<CellSurface> Cells,
IReadOnlyList<PortalPlane> Portals, // NEW: all portals across all cells
float WorldOffsetX,
float WorldOffsetY);
Portal planes are computed at load time in GameWindow.ApplyLoadedTerrainLocked from EnvCell.CellPortals + CellStruct.Polygons:
- For each EnvCell in the landblock
- For each CellPortal in the cell
- Look up
CellStruct.Polygons[portal.PolygonId] - Extract the first 3 vertex positions (from
CellStruct.VertexArray) - Transform to world space using
EnvCell.Position(orientation + origin + landblock offset) - Compute plane:
normal = normalize(cross(v1-v0, v2-v0)),d = -dot(normal, v0) - Store as
PortalPlanewith the portal'sOtherCellId,OwnerCellId, andFlags
Three transition types
Outdoor → Indoor (entering a building):
- Player is in an outdoor cell (cellId & 0xFFFF < 0x0100).
- Each frame, test the player's movement vector against all portal planes in the current landblock that belong to cells with
OtherCellId = 0xFFFF(outside-facing portals — these are the building entry points viewed from outside). - Crossing test:
sign(dot(normal, oldPos) + d) != sign(dot(normal, newPos) + d). - On crossing: transition to the
OwnerCellIdof the portal (the EnvCell the doorway belongs to). Switch to that cell'sCellSurfacefor Z resolution.
Indoor → Indoor (room to room):
- Player is in an indoor cell (cellId & 0xFFFF >= 0x0100).
- Test only the current cell's portals (not all portals in the landblock).
OtherCellId != 0xFFFF— points to the adjacent room.- On crossing: transition to the target cell's
CellSurface.
Indoor → Outdoor (exiting a building):
- Same as indoor→indoor test, but
OtherCellId = 0xFFFF. - On crossing: switch back to
TerrainSurface.SampleZfor Z resolution. Compute the outdoor cell ID from coordinates.
Outdoor → Outdoor (landblock crossing)
No portals involved. When the candidate position falls outside the current landblock's [0, 192) local coordinate range, the existing PhysicsEngine.Resolve landblock-lookup loop picks up the adjacent landblock's TerrainSurface. This should work with the existing code — needs verification testing.
2. Jump + Gravity
New state on PlayerMovementController
float VerticalVelocity; // current vertical speed (positive = up)
bool IsAirborne; // true from jump/fall until landing
const float JumpImpulse = 10f; // tunable initial upward velocity
const float Gravity = 20f; // downward acceleration per second squared
const float AirControlFactor = 0.2f; // horizontal steering while airborne
Per-frame logic
1. If on ground AND Space pressed AND NOT airborne:
verticalVelocity = jumpImpulse
isAirborne = true
2. If airborne:
verticalVelocity -= gravity * dt
candidateZ = position.Z + verticalVelocity * dt
// Resolve ground Z at the candidate XY
groundZ = resolve terrain/floor Z at (candidateX, candidateY)
if candidateZ <= groundZ:
// Landed
position.Z = groundZ
verticalVelocity = 0
isAirborne = false
else:
position.Z = candidateZ
// Skip terrain Z snap — still in the air
// Horizontal input at reduced speed
horizontal delta *= airControlFactor
3. If NOT airborne AND resolved terrain Z is significantly below
current Z (> stepUpHeight gap):
// Walked off a ledge — enter falling state
isAirborne = true
verticalVelocity = 0 // start falling, not jumping
Server interaction
While airborne, still send AutonomousPosition heartbeats with the actual position. The server tracks the player's "in-air" state separately. MoveToState is sent at the start of the jump (motion command changes).
3. Portal Space State Machine
States
enum PlayerState { InWorld, PortalSpace }
Flow
- Server sends
PlayerTeleport(sequence)game message. WorldSession.ProcessDatagramfires a newTeleportStarted(uint sequence)event.GameWindowhandler: setPlayerState = PortalSpace.- In PortalSpace:
- All WASD/mouse movement input is ignored.
- No
MoveToState/AutonomousPositionmessages are sent. - The player entity is optionally hidden (set a flag to skip rendering it).
- Server sends
UpdatePositionwith the new landblock + position. OnLivePositionUpdatedhandler detects the position is far from the current location (different landblock) and:- Recenters the streaming controller on the new landblock.
- Resolves the new position through PhysicsEngine.
- Snaps the player entity to the resolved position.
- Sets
PlayerState = InWorld.
- Send
LoginComplete(GameAction 0x00A1) to tell the server "I've finished loading" — same message as initial login, reused for teleport completion (per holtburger's PlayerTeleport handler). Reset the_loginCompleteSentlatch to allow this.
TeleportStarted event on WorldSession
New opcode handler in ProcessDatagram:
else if (op == 0xF7B0u) // PlayerTeleport (GameEvent container)
{
// Parse the teleport sequence from the event body.
// Fire TeleportStarted event.
}
Note: PlayerTeleport may arrive as a GameEvent (opcode 0xF7B0) with an inner event type, not as a standalone game message. Check holtburger's GameEvent enum for the exact inner opcode. Implementation will read the reference code to determine the wire layout.
4. Remaining Fixes
Setup.StepUpHeight
Setupdat has afloat StepUpHeightfield.- When entering player mode (Tab), read
setup.StepUpHeightand set it onPlayerMovementController.StepUpHeight. - If zero or missing, default to 2.0 (standard human height).
Scenery-on-road fix
In src/AcDream.Core/World/SceneryGenerator.cs, the terrain type is extracted per vertex:
uint terrainType = (uint)((raw >> 2) & 0x1F);
Road type in the raw terrain value has a specific bit pattern. Check if the vertex is a road terrain type and skip scenery generation:
// Road check: terrain types with road flag set should not spawn scenery.
// The exact road type value needs to be verified against the
// Region.TerrainInfo dat data, but the road bit is typically
// indicated by specific terrain type indices that the original
// client excludes from scenery placement.
Implementation will verify the exact road terrain type value from the dat and add the exclusion check.
PhysicsEngine refactoring
- Remove the
if (false)disabled indoor-transition branch. - Replace with real CellPortal plane-crossing logic from Section 1.
- Add
IReadOnlyList<PortalPlane> PortalstoLandblockPhysics. Resolvegains aVector3 oldPosparameter (or derives it fromcurrentPos) to test the movement vector against portal planes.- Cell tracking: the engine needs to know which cell the player is currently in to test only that cell's portals (for indoor→indoor transitions). The
cellIdparameter already serves this purpose.
Testing Strategy
Unit tests (~20 new)
Portal plane extraction:
- Given 3 known vertices, assert correct plane normal and distance.
- Test crossing detection: position on each side of the plane →
signdiffers. - Test no crossing: positions on the same side →
signmatches.
Indoor transition integration:
- Construct a fake two-cell building (cell A with portal to cell B at a known plane).
- Start in cell A, move through the portal → assert cell ID changes to B.
- Move back through the portal → assert cell ID returns to A.
Outdoor→indoor transition:
- Construct a fake EnvCell with an outside-facing portal (OtherCellId=0xFFFF).
- Start outdoors, move through the portal → assert cell ID changes to the EnvCell.
Indoor→outdoor transition:
- Start inside the cell, move through the outside-facing portal → assert cell ID changes to outdoor.
Landblock boundary crossing:
- Two adjacent landblocks registered in the engine.
- Walk from X=190 to X=194 → assert the engine picks up the second landblock's terrain Z.
Jump arc:
- Start at Z=50 on flat terrain, jump → assert Z increases above 50.
- Continue frames → assert Z peaks then decreases.
- Assert landing: Z returns to 50, IsAirborne = false.
Falling off ledge:
- Terrain drops from Z=50 to Z=30 at a step. Walk off the edge.
- Assert: IsAirborne becomes true, Z decreases toward 30, lands at Z=30.
Portal space state:
- Simulate TeleportStarted → assert PlayerState = PortalSpace.
- Simulate UpdatePosition with new location → assert PlayerState = InWorld, position updated.
Scenery road exclusion:
- Construct terrain data with a road-type vertex.
- Assert SceneryGenerator produces no scenery at that vertex.
Acceptance Criteria
Phase B.3 Complete is done when ALL of the following pass:
- Walk outdoors on terrain following heightmap Z.
- Walk from one landblock to the next seamlessly.
- Walk through a doorway into a building (outdoor→indoor via CellPortal).
- Walk around inside a building on the correct floor.
- Walk back out of a building (indoor→outdoor via CellPortal).
- Walk between rooms inside a building (indoor→indoor via CellPortal).
- Jump while walking (momentum-preserving arc).
- Jump off a ledge and land on terrain below.
- Enter a game portal → portal-space state → arrive at destination.
- Step-height read from Setup.StepUpHeight.
- No trees/stones on roads.
- ACE console shows no movement rejection errors.
- All new unit tests pass (~20). Total test count increases.
Open Questions (resolved during implementation)
- Portal polygon vertex winding: the plane normal direction determines which side is "inside." The
PortalFlags.PortalSideflag disambiguates. Implementation will read ACViewer'sEnvCell.csto confirm the convention. - Outdoor→indoor portal detection radius: testing every portal in the landblock each frame could be expensive with many buildings. If profiling shows this, add a spatial hash or AABB pre-filter. Expected to be fine for Holtburg (< 50 portals per landblock).
- PlayerTeleport wire format: may be a GameEvent (0xF7B0) with inner type, or a standalone message. Check holtburger's opcode table during implementation.
- Exact road terrain type value: verify from the Region.TerrainInfo dat during implementation.
- Jump impulse / gravity tuning: start with JumpImpulse=10, Gravity=20. Tune to match retail feel during visual verification.