# Phase B.2 — Player Movement Mode Design **Status:** Spec, 2026-04-12, brainstormed with user. **Scope:** A new player-controlled movement mode where WASD walks the character on collision-resolved terrain, the server sees valid movement, other clients see the character at the correct position, and the character plays walk/run/turn/idle animations locally. Toggled with Tab; coexists with the existing fly and orbit camera modes. **Parent:** `docs/plans/2026-04-11-roadmap.md` — Phase B (Gameplay), sub-piece B.2. **Depends on:** Phase B.3 (physics collision engine) — already shipped. ## Goals 1. Walk the player character on the server with WASD controls. 2. The server accepts the movement (no rejection / rubber-banding). 3. Other clients (original AC client or another acdream instance) see the character at the correct position. 4. The character plays walk/run/turn/idle animations locally (not sliding). 5. Third-person chase camera follows behind the character. 6. Mouse + A/D keyboard turning rotates the character; the turn is sent to the server. 7. Toggle between player mode and fly mode with Tab. ## Non-goals - Jump (physics arc + landing — adds gravity state complexity, deferred). - Combat stance switching (deferred to Phase B.4+). - Indoor movement (B.3's PhysicsEngine supports it; we just don't test it in the acceptance criteria). - NPC/entity collision (only terrain + EnvCell floor collision via PhysicsEngine). - Smooth position interpolation for other players' movement (existing `UpdatePosition` snap behavior is acceptable for now). ## Input Mapping (Player Mode Active) | Key | Action | |---|---| | **W / S** | Walk forward / backward (~4 u/s) | | **Shift + W / Shift + S** | Run forward / backward (~7 u/s) | | **A / D** | Turn left / right (keyboard turning) | | **Z / X** | Strafe left / right | | **Mouse X** | Turn (mouse turning, same yaw axis as A/D) | | **Mouse Y** | Adjust chase camera pitch (look up/down) | | **Tab** | Exit player mode, return to fly mode | Speed values are approximate — the exact values should match what ACE's `MovementHandler` accepts. Check `references/ACE/Source/ACE.Server/` and `references/holtburger/` for the validated run/walk rates. ## Outbound Server Messages ### `MoveToState` (GameAction, opcode 0xF61C) Sent when the player's motion state *changes*: starts walking, stops, starts turning, changes speed (walk↔run), starts strafing. **Payload (per holtburger `MoveToStateActionData`):** - `RawMotionState`: - `flags: RawMotionFlags` — bitmask of which fields are present - `current_hold_key: Option` — hold key (Run = shift held) - `current_style: Option` — combat stance (NonCombat for MVP) - `forward_command: Option` — WalkForward (0x0005) / RunForward (0x0007) - `forward_speed: Option` — movement speed multiplier - `sidestep_command: Option` — SideStepRight (0x000F) / SideStepLeft (0x0010) - `sidestep_speed: Option` - `turn_command: Option` — TurnRight (0x000D) / TurnLeft (0x000E) - `turn_speed: Option` - `WorldPosition`: - `cell_id: u32` — full landblock+cell ID (from PhysicsEngine.Resolve) - `position: Vector3` — world XYZ - `rotation: Quaternion` — world orientation - Sequence numbers: `instance_sequence`, `server_control_sequence`, `teleport_sequence`, `force_position_sequence` (all u16) - `contact_long_jump: u8` — ground contact flag ### `AutonomousPosition` (GameAction, opcode 0xF753) Periodic heartbeat sent every ~200ms while the player is moving. Carries the current `WorldPosition` + sequence numbers + ground contact flag. Does NOT carry motion commands — the server already knows those from `MoveToState`. ### Wrapping in GameAction Both messages are sent as `GameAction` (game message opcode 0xF7B1): ``` u32 game_message_opcode = 0xF7B1 u32 action_sequence = next game action sequence u32 action_type_opcode = 0xF61C (MoveToState) or 0xF753 (AutonomousPosition) ... payload ... ``` This matches how `GameActionLoginComplete` (0x00A1) is already sent in Phase 4.10. The difference is that MoveToState/AutonomousPosition carry a structured payload after the action type opcode. **Note on game action sequence:** holtburger increments a `game_action_sequence` counter per outbound game action. WorldSession already has `_fragmentSequence` for fragment-level sequencing; a new `_gameActionSequence` counter is needed for the action-level sequence field. ## Third-Person Chase Camera New `ChaseCamera` class implementing `ICamera`: - **Position:** behind + above the player at a configurable offset. In player-local space: `(-distance * cos(pitch), 0, distance * sin(pitch))` rotated by the player's yaw. Default distance ~8 units, default pitch ~20 degrees above horizontal. - **LookAt:** the player's position + a small vertical offset (eye height, ~1.5 units). - **Update per frame:** receives the player's current world position + yaw + mouse-Y-driven pitch delta. Recomputes the camera's world position from the offset math. - **Projection:** same parameters as FlyCamera (FovY = PI/3, near = 1, far = 5000). Camera does NOT collide with terrain/buildings — if the camera clips through a hill behind the player, that's acceptable for the MVP. Camera collision is Phase C polish. ## Walk Animation (Local) When player mode is active: - Detect which motion command the current input maps to: - W held → `WalkForward` (0x45000005), or `RunForward` (0x44000007) if Shift held - S held → `WalkForward` with negative speed (backward) - Z held → `SideStepLeft` (0x65000010) - X held → `SideStepRight` (0x6500000F) - A held (no mouse) → `TurnLeft` (0x6500000E) - D held (no mouse) → `TurnRight` (0x6500000D) - Nothing held → `Ready` (0x41000003, idle breathing) - Use `MotionResolver.GetIdleCycle` with the command as `commandOverride` to resolve the animation cycle from the player's motion table. - Register/update the player entity in `_animatedEntities` so `TickAnimations` plays the resolved cycle with slerp interpolation (existing infrastructure from Phase 6.4/6.5). - Only update the cycle when the motion command *changes* (idle→walk, walk→run, etc.) — don't re-resolve every frame. **Priority when multiple keys are held:** forward/backward takes priority over sidestep; sidestep takes priority over turn. Matches retail client behavior. ## Architecture ### New Files | File | Responsibility | |---|---| | `src/AcDream.App/Input/PlayerMovementController.cs` | Per-frame input → physics → position update + outbound message decision logic | | `src/AcDream.App/Rendering/ChaseCamera.cs` | Third-person camera implementing `ICamera` | | `src/AcDream.Core.Net/Messages/MoveToState.cs` | Outbound `MoveToState` message builder | | `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` | Outbound `AutonomousPosition` heartbeat builder | ### Modified Files | File | Changes | |---|---| | `src/AcDream.App/Rendering/GameWindow.cs` | Tab toggle, wire PlayerMovementController + ChaseCamera into OnUpdate/OnRender, local animation cycle switching, identify player entity by guid | | `src/AcDream.App/Rendering/CameraController.cs` | Add chase camera as a third mode (orbit / fly / chase) | | `src/AcDream.Core.Net/WorldSession.cs` | Add `_gameActionSequence` counter, add `SendGameAction(byte[])` method that wraps payload in the 0xF7B1 GameAction envelope with incrementing sequence | ### Data Flow (Per Frame, Player Mode) ``` OnUpdate: 1. Read keyboard (WASD/ZX/Shift/AD) + mouse delta 2. PlayerMovementController.Update(dt, inputState): a. Compute yaw delta from mouse X + A/D keyboard turn b. Update player yaw c. Compute movement delta from W/S + Z/X in the player's facing direction (yaw-rotated), scaled by walk/run speed * dt d. PhysicsEngine.Resolve(currentPos, currentCellId, delta, stepUpHeight) → resolvedPos, resolvedCellId, isOnGround e. Update playerEntity.Position = resolvedPos f. Update playerEntity.Rotation = Quaternion.CreateFromAxisAngle(UnitZ, yaw) g. Detect motion state change (compare current vs previous command) h. If changed: build MoveToState → WorldSession.SendGameAction i. Every ~200ms: build AutonomousPosition → WorldSession.SendGameAction 3. Update walk animation on playerEntity: - If motion command changed: re-resolve cycle via MotionResolver - Update _animatedEntities entry 4. ChaseCamera.Update(playerEntity.Position, yaw, cameraPitch) OnRender: - _cameraController.Active returns ChaseCamera when in player mode - Frustum culling + terrain + static mesh draw as usual ``` ### Player Entity Identification The player's own character is identified by matching the `EntitySpawn.Guid` from the `CreateObject` received at login against the character's GUID from `CharacterList`. GameWindow already stores `_entitiesByServerGuid` — the player entity is `_entitiesByServerGuid[chosenCharacterGuid]`. Store the player's server guid as a field on GameWindow when `EnterWorld` is called. ### Coordinate Space for Server Messages AC positions on the wire use `(landblock cell ID, local XY, Z, rotation)`. The cell ID is the full 32-bit value: `(landblockX << 24) | (landblockY << 16) | cellIndex`. `PhysicsEngine.Resolve` currently returns a cell index (0x0001–0x0040 outdoor, 0x0100+ indoor) without the landblock prefix. The message builder needs to compose the full cell ID from the entity's world position (to get the landblock coordinates) + the engine's cell index. acdream's internal world coordinates are center-landblock-relative. Converting back to AC wire coordinates requires: - `landblockX = _liveCenterX + floor(worldPos.X / 192)` - `landblockY = _liveCenterY + floor(worldPos.Y / 192)` - `localX = worldPos.X - (landblockX - _liveCenterX) * 192` - `localY = worldPos.Y - (landblockY - _liveCenterY) * 192` - `localZ = worldPos.Z` - `cellId = (landblockX << 24) | (landblockY << 16) | resolvedCellIndex` This is the inverse of the `OnLiveEntitySpawned` coordinate translation. ## Testing Strategy ### Unit Tests - **`PlayerMovementControllerTests`** — with a fake PhysicsEngine: - Forward input → position advances in facing direction - Shift+forward → position advances at run speed (faster) - Turn input → yaw changes - Motion state change detection: idle→walk→idle transitions - Heartbeat timing: AutonomousPosition sent every ~200ms during movement - **`MoveToStateTests`** — message builder: - Build with a known RawMotionState + WorldPosition → assert byte layout - Verify GameAction envelope (0xF7B1 + sequence + 0xF61C) - **`AutonomousPositionTests`** — message builder: - Build with a known WorldPosition → assert byte layout - Verify GameAction envelope (0xF7B1 + sequence + 0xF753) - **`ChaseCameraTests`** — offset math: - Player at origin, yaw=0 → camera at (-8, 0, ~3) - Player at origin, yaw=PI/2 → camera at (0, -8, ~3) - Pitch adjustment changes camera height ### Integration / Visual Verification - Log in at Holtburg. - Press Tab to enter player mode. - Walk with W → character walks forward on terrain, walk animation plays. - Shift+W → run animation, faster movement. - A/D → character turns, camera follows. - Release all keys → character returns to idle breathing. - Open original AC client with different account → see acdream's character at the correct position, visually moving. - ACE console shows no movement rejections or errors. - Close acdream → graceful logout (already working). ## Acceptance Criteria Phase B.2 is done when: 1. Tab toggles between fly mode and player mode. 2. WASD/ZX walks/strafes the character on collision-resolved terrain. 3. A/D + mouse turns the character. 4. Shift toggles walk/run speed. 5. Walk/run/turn/idle animations play correctly on the player's character. 6. The server accepts the movement (no ACE-side rejections in the console log). 7. Other clients see the character at the correct position. 8. Third-person chase camera follows the character smoothly. 9. All new unit tests pass. Total test count increases by ~15-20. ## Open Questions (Resolved During Implementation) - **Exact walk/run speed values:** check ACE's `MovementHandler` for the server-side validation thresholds. Use holtburger's default speeds as a starting point. - **RawMotionState flag encoding:** the exact bitmask layout comes from holtburger's `RawMotionFlags`. Implementation will read `references/holtburger/crates/holtburger-protocol/src/messages/movement/types.rs` for the canonical encoding. - **Sequence number management:** the four sequence counters (instance, server_control, teleport, force_position) need to be tracked per-session. Start at the values ACE sends in the initial handshake and increment as holtburger does. If exact behavior is unclear, start at 0 and adjust if ACE rejects. - **WorldPosition rotation encoding:** AC wire quaternion order is (W, X, Y, Z). Our internal `System.Numerics.Quaternion` is (X, Y, Z, W). The message builder must swap the order — same transform already used in `OnLiveEntitySpawned`.