# Phase B.2 — Player Movement Mode Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Walk the player character on the server with WASD, with collision-resolved terrain, walk/run/turn animations, third-person chase camera, and outbound movement messages that ACE accepts. **Architecture:** New `PlayerMovementController` reads input and drives `PhysicsEngine.Resolve` → position update → outbound `MoveToState` / `AutonomousPosition` server messages. New `ChaseCamera` follows the player. Tab toggles between fly mode and player mode. Walk/run/turn/idle animations resolved locally via `MotionResolver.GetIdleCycle`. **Tech Stack:** .NET 10, Silk.NET (input + windowing), System.Numerics, DatReaderWriter, xUnit. **Spec:** `docs/superpowers/specs/2026-04-12-player-movement-design.md` --- ## File structure ``` src/AcDream.Core.Net/ Messages/ MoveToState.cs [new] outbound message builder AutonomousPosition.cs [new] outbound heartbeat builder WorldSession.cs [modify] add SendGameAction + _gameActionSequence src/AcDream.App/ Input/ PlayerMovementController.cs [new] input → physics → position + message decisions Rendering/ ChaseCamera.cs [new] third-person camera implementing ICamera CameraController.cs [modify] add chase mode GameWindow.cs [modify] Tab toggle, wire controller + camera + animation tests/AcDream.Core.Net.Tests/ Messages/ MoveToStateTests.cs [new] AutonomousPositionTests.cs [new] tests/AcDream.Core.Tests/ Rendering/ ChaseCameraTests.cs [new] Input/ PlayerMovementControllerTests.cs [new] ``` --- ## Task 1: MoveToState + AutonomousPosition message builders **Files:** - Create: `src/AcDream.Core.Net/Messages/MoveToState.cs` - Create: `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` - Modify: `src/AcDream.Core.Net/WorldSession.cs` - Test: `tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs` - Test: `tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs` This task builds the outbound wire-format message constructors and adds `SendGameAction` to WorldSession. - [ ] **Step 1: Read holtburger reference files** Before writing any code, read these files to understand the exact byte layout: - `references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs` — MoveToStateActionData + AutonomousPositionActionData pack/unpack - `references/holtburger/crates/holtburger-protocol/src/messages/movement/types.rs` — RawMotionState + RawMotionFlags + WorldPosition pack/unpack - `references/holtburger/crates/holtburger-protocol/src/messages/game_action.rs` — how GameAction wraps these (opcode 0xF7B1 + sequence + action_type) Take notes on: - The exact field order for MoveToState: GameAction envelope → RawMotionState (flag-driven variable fields) → WorldPosition → sequences → contact byte → align - The exact field order for AutonomousPosition: GameAction envelope → WorldPosition → sequences → contact byte → align - WorldPosition layout: u32 cell_id → f32 x → f32 y → f32 z → f32 qw → f32 qx → f32 qy → f32 qz (32 bytes) - RawMotionState: u32 flags → conditional fields based on flags - [ ] **Step 2: Write MoveToState failing tests** Create `tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs`: ```csharp using System; using System.Buffers.Binary; using System.Numerics; using AcDream.Core.Net.Messages; using Xunit; namespace AcDream.Core.Net.Tests.Messages; public class MoveToStateTests { [Fact] public void Build_IdleState_ProducesValidGameAction() { var body = MoveToState.Build( gameActionSequence: 1, forwardCommand: null, forwardSpeed: null, sidestepCommand: null, sidestepSpeed: null, turnCommand: null, turnSpeed: null, holdKey: null, cellId: 0xA9B40001u, position: new Vector3(96f, 96f, 50f), rotation: Quaternion.Identity, instanceSequence: 0, serverControlSequence: 0, teleportSequence: 0, forcePositionSequence: 0); // First 4 bytes: GameAction opcode 0xF7B1 uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)); Assert.Equal(0xF7B1u, opcode); // Bytes 4-7: game action sequence uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)); Assert.Equal(1u, seq); // Bytes 8-11: MoveToState action type 0xF61C uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)); Assert.Equal(0xF61Cu, actionType); } [Fact] public void Build_WalkForward_IncludesForwardCommandInFlags() { var body = MoveToState.Build( gameActionSequence: 2, forwardCommand: 0x45000005u, // WalkForward forwardSpeed: 1.0f, sidestepCommand: null, sidestepSpeed: null, turnCommand: null, turnSpeed: null, holdKey: null, cellId: 0xA9B40001u, position: new Vector3(96f, 96f, 50f), rotation: Quaternion.Identity, instanceSequence: 0, serverControlSequence: 0, teleportSequence: 0, forcePositionSequence: 0); // After the 12-byte GameAction header comes RawMotionState. // First u32 is the flags. ForwardCommand flag = 0x4. uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); Assert.True((flags & 0x4u) != 0, "ForwardCommand flag should be set"); } } ``` - [ ] **Step 3: Write AutonomousPosition failing tests** Create `tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs`: ```csharp using System; using System.Buffers.Binary; using System.Numerics; using AcDream.Core.Net.Messages; using Xunit; namespace AcDream.Core.Net.Tests.Messages; public class AutonomousPositionTests { [Fact] public void Build_ProducesValidGameAction() { var body = AutonomousPosition.Build( gameActionSequence: 5, cellId: 0xA9B40001u, position: new Vector3(100f, 100f, 50f), rotation: Quaternion.Identity, instanceSequence: 0, serverControlSequence: 0, teleportSequence: 0, forcePositionSequence: 0); uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)); Assert.Equal(0xF7B1u, opcode); uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)); Assert.Equal(5u, seq); uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)); Assert.Equal(0xF753u, actionType); } [Fact] public void Build_ContainsCellIdAfterHeader() { var body = AutonomousPosition.Build( gameActionSequence: 1, cellId: 0xDEADBEEFu, position: Vector3.Zero, rotation: Quaternion.Identity, instanceSequence: 0, serverControlSequence: 0, teleportSequence: 0, forcePositionSequence: 0); // After the 12-byte GameAction header, the WorldPosition starts // with u32 cell_id. uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); Assert.Equal(0xDEADBEEFu, cellId); } } ``` - [ ] **Step 4: Implement message builders** Create `src/AcDream.Core.Net/Messages/MoveToState.cs`: The builder must produce the exact byte layout holtburger sends. Read the reference files from Step 1. The key structure: ``` GameAction envelope: u32 0xF7B1 (GameAction opcode) u32 gameActionSequence u32 0xF61C (MoveToState action type) RawMotionState: u32 flags (bitmask of which optional fields follow) [if CURRENT_HOLD_KEY (0x1)]: u32 holdKey [if CURRENT_STYLE (0x2)]: u32 style [if FORWARD_COMMAND (0x4)]: u32 forwardCommand [if FORWARD_SPEED (0x10)]: f32 forwardSpeed [if SIDE_STEP_COMMAND (0x20)]: u32 sidestepCommand [if SIDE_STEP_SPEED (0x80)]: f32 sidestepSpeed [if TURN_COMMAND (0x100)]: u32 turnCommand [if TURN_SPEED (0x400)]: f32 turnSpeed WorldPosition: u32 cellId f32 originX, originY, originZ f32 rotW, rotX, rotY, rotZ (AC wire order: W first) Sequences + contact: u16 instanceSequence u16 serverControlSequence u16 teleportSequence u16 forcePositionSequence u8 contactLongJump (1 = on ground) align to 4 bytes ``` Use `PacketWriter` (from `AcDream.Core.Net.Packets`) to build the byte array, same pattern as `GameActionLoginComplete.Build`. Create `src/AcDream.Core.Net/Messages/AutonomousPosition.cs`: Simpler — no RawMotionState, just the GameAction envelope + WorldPosition + sequences + contact + align. - [ ] **Step 5: Add SendGameAction to WorldSession** In `src/AcDream.Core.Net/WorldSession.cs`, add a new public method and counter: ```csharp private uint _gameActionSequence; /// /// Send a pre-built GameAction body (which already contains the 0xF7B1 /// envelope). Increments the game-action-level sequence counter. /// For movement messages (MoveToState, AutonomousPosition) called from /// the PlayerMovementController. /// public void SendGameAction(byte[] gameActionBody) { SendGameMessage(gameActionBody); } /// /// Get and increment the game action sequence for outbound messages. /// public uint NextGameActionSequence() => ++_gameActionSequence; ``` Note: `SendGameMessage` is currently private. Either make it internal or add this public wrapper. The wrapper is cleaner — it documents intent and lets us add action-specific logic later. - [ ] **Step 6: Run tests** Run: `dotnet test -c Debug --nologo` Expected: 243 + 4 = 247+ tests, no regressions. - [ ] **Step 7: Commit** ```bash git add src/AcDream.Core.Net/Messages/MoveToState.cs src/AcDream.Core.Net/Messages/AutonomousPosition.cs src/AcDream.Core.Net/WorldSession.cs tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs git commit -m "$(cat <<'EOF' feat(net): Phase B.2 — MoveToState + AutonomousPosition message builders Outbound GameAction message builders for player movement: - MoveToState (0xF61C): sent on motion state changes (start/stop walking, turn, speed change). Carries RawMotionState (flag-driven variable fields) + WorldPosition + sequence numbers. - AutonomousPosition (0xF753): periodic position heartbeat sent every ~200ms while moving. Both follow the GameAction envelope pattern (0xF7B1 + sequence + action type) established by GameActionLoginComplete. Wire format ported from references/holtburger movement protocol. Also adds SendGameAction + NextGameActionSequence to WorldSession. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 2: ChaseCamera **Files:** - Create: `src/AcDream.App/Rendering/ChaseCamera.cs` - Test: `tests/AcDream.Core.Tests/Rendering/ChaseCameraTests.cs` - [ ] **Step 1: Write failing tests** Create `tests/AcDream.Core.Tests/Rendering/ChaseCameraTests.cs`: ```csharp using System; using System.Numerics; using AcDream.App.Rendering; using Xunit; namespace AcDream.Core.Tests.Rendering; public class ChaseCameraTests { [Fact] public void Position_BehindPlayer_WhenYawIsZero() { var camera = new ChaseCamera { Aspect = 16f / 9f }; camera.Update(playerPosition: Vector3.Zero, playerYaw: 0f); // Yaw=0 means facing +X (cos(0)=1, sin(0)=0). // Camera should be BEHIND the player: negative X direction. Assert.True(camera.Position.X < -1f, $"Camera X={camera.Position.X} should be behind player (negative X)"); Assert.True(camera.Position.Z > 0f, $"Camera Z={camera.Position.Z} should be above player"); } [Fact] public void Position_BehindPlayer_WhenYawIsHalfPi() { var camera = new ChaseCamera { Aspect = 16f / 9f }; camera.Update(playerPosition: Vector3.Zero, playerYaw: MathF.PI / 2f); // Yaw=PI/2 means facing +Y. Camera should be behind: negative Y. Assert.True(camera.Position.Y < -1f, $"Camera Y={camera.Position.Y} should be behind player (negative Y)"); } [Fact] public void Position_FollowsPlayerPosition() { var camera = new ChaseCamera { Aspect = 16f / 9f }; var playerPos = new Vector3(100f, 200f, 50f); camera.Update(playerPosition: playerPos, playerYaw: 0f); // Camera should be near the player, not at the origin. Assert.InRange(camera.Position.X, 85f, 100f); // behind but close Assert.InRange(camera.Position.Y, 195f, 205f); // roughly same Y } [Fact] public void PitchAdjustment_ChangesHeight() { var camera = new ChaseCamera { Aspect = 16f / 9f }; camera.Update(playerPosition: Vector3.Zero, playerYaw: 0f); float z1 = camera.Position.Z; camera.AdjustPitch(0.2f); camera.Update(playerPosition: Vector3.Zero, playerYaw: 0f); float z2 = camera.Position.Z; Assert.True(z2 > z1, "Increasing pitch should raise the camera"); } [Fact] public void ImplementsICamera() { ICamera camera = new ChaseCamera { Aspect = 16f / 9f }; camera.ToString(); // just proves interface is implemented } } ``` - [ ] **Step 2: Implement ChaseCamera** Create `src/AcDream.App/Rendering/ChaseCamera.cs`: ```csharp using System; using System.Numerics; namespace AcDream.App.Rendering; /// /// Third-person chase camera that follows behind and above a player /// character. Implements so it plugs into the /// existing renderer pipeline. /// public sealed class ChaseCamera : ICamera { public Vector3 Position { get; private set; } public float Aspect { get; set; } = 16f / 9f; public float FovY { get; set; } = MathF.PI / 3f; /// Distance behind the player. public float Distance { get; set; } = 8f; /// Camera pitch above horizontal (radians). Positive = look down. public float Pitch { get; set; } = 0.35f; // ~20 degrees /// Vertical offset from the player's feet to the look-at point (eye height). public float EyeHeight { get; set; } = 1.5f; private const float PitchMin = 0.05f; private const float PitchMax = 1.4f; // ~80 degrees private float _playerYaw; private Vector3 _lookAt; public Matrix4x4 View => Matrix4x4.CreateLookAt(Position, _lookAt, Vector3.UnitZ); public Matrix4x4 Projection => Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f); /// /// Update the camera position to follow the player. /// public void Update(Vector3 playerPosition, float playerYaw) { _playerYaw = playerYaw; _lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight); // Camera offset: behind the player (-forward direction) and above. float forwardX = MathF.Cos(playerYaw); float forwardY = MathF.Sin(playerYaw); float horizontalDist = Distance * MathF.Cos(Pitch); float verticalDist = Distance * MathF.Sin(Pitch); Position = new Vector3( playerPosition.X - forwardX * horizontalDist, playerPosition.Y - forwardY * horizontalDist, playerPosition.Z + EyeHeight + verticalDist); } /// /// Adjust pitch by a delta (from mouse Y movement). /// public void AdjustPitch(float delta) { Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); } } ``` - [ ] **Step 3: Run tests, commit** Run: `dotnet test -c Debug --nologo` — all green. ```bash git add src/AcDream.App/Rendering/ChaseCamera.cs tests/AcDream.Core.Tests/Rendering/ChaseCameraTests.cs git commit -m "feat(app): Phase B.2 — ChaseCamera (third-person follow camera) Co-Authored-By: Claude Opus 4.6 (1M context) " ``` --- ## Task 3: PlayerMovementController **Files:** - Create: `src/AcDream.App/Input/PlayerMovementController.cs` - Test: `tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs` The controller takes input state + dt, drives `PhysicsEngine.Resolve`, updates the entity, decides when to send outbound messages, and tracks the current motion command for animation. - [ ] **Step 1: Write failing tests** Create `tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs`. Test the core logic with a fake PhysicsEngine (pass-through that returns the candidate position unchanged): ```csharp using System; using System.Numerics; using AcDream.App.Input; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Input; public class PlayerMovementControllerTests { private static PhysicsEngine MakeFlatEngine() { var engine = new PhysicsEngine(); var heights = new byte[81]; Array.Fill(heights, (byte)50); var heightTable = new float[256]; for (int i = 0; i < 256; i++) heightTable[i] = i * 1f; var terrain = new TerrainSurface(heights, heightTable); engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); return engine; } [Fact] public void Update_NoInput_PositionUnchanged() { var engine = MakeFlatEngine(); var controller = new PlayerMovementController(engine); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); var result = controller.Update(0.016f, new MovementInput()); Assert.Equal(96f, result.Position.X, precision: 1); Assert.Equal(96f, result.Position.Y, precision: 1); } [Fact] public void Update_ForwardInput_MovesInFacingDirection() { var engine = MakeFlatEngine(); var controller = new PlayerMovementController(engine); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.Yaw = 0f; // facing +X var input = new MovementInput { Forward = true }; var result = controller.Update(1.0f, input); // 1 second // Should have moved ~4 units in +X (walk speed). Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward"); } [Fact] public void Update_RunForward_MoveFasterThanWalk() { var engine = MakeFlatEngine(); var controller = new PlayerMovementController(engine); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.Yaw = 0f; var walkInput = new MovementInput { Forward = true }; var walkResult = controller.Update(1.0f, walkInput); float walkDist = walkResult.Position.X - 96f; controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); var runInput = new MovementInput { Forward = true, Run = true }; var runResult = controller.Update(1.0f, runInput); float runDist = runResult.Position.X - 96f; Assert.True(runDist > walkDist, $"Run ({runDist}) should be faster than walk ({walkDist})"); } [Fact] public void Update_TurnInput_ChangesYaw() { var engine = MakeFlatEngine(); var controller = new PlayerMovementController(engine); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); float initialYaw = controller.Yaw; var input = new MovementInput { TurnRight = true }; controller.Update(0.5f, input); Assert.NotEqual(initialYaw, controller.Yaw); } [Fact] public void MotionStateChanged_WhenStartingToWalk() { var engine = MakeFlatEngine(); var controller = new PlayerMovementController(engine); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); // First frame: idle (no input). controller.Update(0.016f, new MovementInput()); // Second frame: start walking. var input = new MovementInput { Forward = true }; var result = controller.Update(0.016f, input); Assert.True(result.MotionStateChanged); } } ``` - [ ] **Step 2: Implement PlayerMovementController** Create `src/AcDream.App/Input/PlayerMovementController.cs`: ```csharp using System; using System.Numerics; using AcDream.Core.Physics; namespace AcDream.App.Input; /// /// Input state for a single frame of player movement. /// public readonly record struct MovementInput( bool Forward = false, bool Backward = false, bool StrafeLeft = false, bool StrafeRight = false, bool TurnLeft = false, bool TurnRight = false, bool Run = false, float MouseDeltaX = 0f); /// /// Result of a single frame's movement update. /// public readonly record struct MovementResult( Vector3 Position, uint CellId, bool IsOnGround, bool MotionStateChanged, uint? ForwardCommand, uint? SidestepCommand, uint? TurnCommand, float? ForwardSpeed, float? SidestepSpeed, float? TurnSpeed); /// /// Per-frame player movement controller. Reads input, drives the /// physics engine, tracks motion state for animation + server messages. /// public sealed class PlayerMovementController { private readonly PhysicsEngine _physics; public float WalkSpeed { get; set; } = 4f; public float RunSpeed { get; set; } = 7f; public float TurnSpeed { get; set; } = 1.5f; public float MouseTurnSensitivity { get; set; } = 0.003f; public float StepUpHeight { get; set; } = 1.0f; public float Yaw { get; set; } public Vector3 Position { get; private set; } public uint CellId { get; private set; } // Previous frame's motion commands for change detection. private uint? _prevForwardCmd; private uint? _prevSidestepCmd; private uint? _prevTurnCmd; // Heartbeat timer. private float _heartbeatAccum; public const float HeartbeatInterval = 0.2f; // 200ms public bool HeartbeatDue { get; private set; } public PlayerMovementController(PhysicsEngine physics) { _physics = physics; } public void SetPosition(Vector3 pos, uint cellId) { Position = pos; CellId = cellId; } public MovementResult Update(float dt, MovementInput input) { // 1. Apply turning from keyboard + mouse. if (input.TurnRight) Yaw -= TurnSpeed * dt; if (input.TurnLeft) Yaw += TurnSpeed * dt; Yaw -= input.MouseDeltaX * MouseTurnSensitivity; // 2. Compute movement delta in the player's facing direction. float speed = input.Run ? RunSpeed : WalkSpeed; float forwardX = MathF.Cos(Yaw); float forwardY = MathF.Sin(Yaw); float rightX = MathF.Sin(Yaw); float rightY = -MathF.Cos(Yaw); float dx = 0f, dy = 0f; if (input.Forward) { dx += forwardX * speed * dt; dy += forwardY * speed * dt; } if (input.Backward) { dx -= forwardX * speed * dt * 0.65f; dy -= forwardY * speed * dt * 0.65f; } if (input.StrafeRight) { dx += rightX * speed * dt * 0.5f; dy += rightY * speed * dt * 0.5f; } if (input.StrafeLeft) { dx -= rightX * speed * dt * 0.5f; dy -= rightY * speed * dt * 0.5f; } var delta = new Vector3(dx, dy, 0f); // 3. Resolve via physics engine. var result = _physics.Resolve(Position, CellId, delta, StepUpHeight); Position = result.Position; CellId = result.CellId; // 4. Determine current motion commands. uint? forwardCmd = null; float? forwardSpeed = null; uint? sidestepCmd = null; float? sidestepSpeed = null; uint? turnCmd = null; float? turnSpeed = null; if (input.Forward) { forwardCmd = input.Run ? 0x44000007u : 0x45000005u; // RunForward / WalkForward forwardSpeed = input.Run ? RunSpeed / WalkSpeed : 1.0f; } else if (input.Backward) { forwardCmd = 0x45000005u; // WalkForward (backward is negative speed) forwardSpeed = -0.65f; } if (input.StrafeRight) { sidestepCmd = 0x6500000Fu; // SideStepRight sidestepSpeed = speed * 0.5f / WalkSpeed; } else if (input.StrafeLeft) { sidestepCmd = 0x65000010u; // SideStepLeft sidestepSpeed = speed * 0.5f / WalkSpeed; } if (input.TurnRight || input.MouseDeltaX > 0.5f) { turnCmd = 0x6500000Du; // TurnRight turnSpeed = TurnSpeed; } else if (input.TurnLeft || input.MouseDeltaX < -0.5f) { turnCmd = 0x6500000Eu; // TurnLeft turnSpeed = TurnSpeed; } // 5. Detect motion state change. bool changed = forwardCmd != _prevForwardCmd || sidestepCmd != _prevSidestepCmd || turnCmd != _prevTurnCmd; _prevForwardCmd = forwardCmd; _prevSidestepCmd = sidestepCmd; _prevTurnCmd = turnCmd; // 6. Heartbeat timer (only while moving). bool isMoving = forwardCmd is not null || sidestepCmd is not null || turnCmd is not null; if (isMoving) { _heartbeatAccum += dt; HeartbeatDue = _heartbeatAccum >= HeartbeatInterval; if (HeartbeatDue) _heartbeatAccum = 0f; } else { _heartbeatAccum = 0f; HeartbeatDue = false; } return new MovementResult( Position: result.Position, CellId: result.CellId, IsOnGround: result.IsOnGround, MotionStateChanged: changed, ForwardCommand: forwardCmd, SidestepCommand: sidestepCmd, TurnCommand: turnCmd, ForwardSpeed: forwardSpeed, SidestepSpeed: sidestepSpeed, TurnSpeed: turnSpeed); } } ``` - [ ] **Step 3: Run tests, commit** Run: `dotnet test -c Debug --nologo` — all green. ```bash git add src/AcDream.App/Input/PlayerMovementController.cs tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs git commit -m "$(cat <<'EOF' feat(app): Phase B.2 — PlayerMovementController (input → physics → motion state) Per-frame controller that reads MovementInput (WASD/ZX/Shift/mouse), drives PhysicsEngine.Resolve for collision, and tracks motion state changes for outbound server messages + animation switching. Walk (~4 u/s) and run (~7 u/s) speeds match AC retail. Heartbeat timer triggers AutonomousPosition every ~200ms while moving. 5 new tests covering idle, forward, run, turn, and state-change detection. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 4: CameraController chase mode **Files:** - Modify: `src/AcDream.App/Rendering/CameraController.cs` Add chase camera as a third mode alongside orbit and fly. - [ ] **Step 1: Read current CameraController** Read `src/AcDream.App/Rendering/CameraController.cs` to understand the existing toggle pattern. - [ ] **Step 2: Add ChaseCamera property + mode cycling** Add a `Chase` property and modify `ToggleFly` (or add a new method) so the mode cycles: orbit → fly → chase → orbit. OR add a separate `TogglePlayerMode()` method that switches between the current mode and chase. The simpler option: add `SetChaseMode()` and `ExitChaseMode()` methods that GameWindow calls on Tab press. ```csharp public ChaseCamera? Chase { get; set; } public bool IsChaseMode => Chase is not null && Active == Chase; public void EnterChaseMode(ChaseCamera chase) { Chase = chase; Active = chase; ModeChanged?.Invoke(false); // not fly mode } public void ExitChaseMode() { Active = Fly; // return to fly mode Chase = null; ModeChanged?.Invoke(true); // back to fly } ``` - [ ] **Step 3: Build, commit** ```bash git add src/AcDream.App/Rendering/CameraController.cs git commit -m "feat(app): Phase B.2 — CameraController chase mode Co-Authored-By: Claude Opus 4.6 (1M context) " ``` --- ## Task 5: GameWindow wiring (the big integration task) **Files:** - Modify: `src/AcDream.App/Rendering/GameWindow.cs` Wire Tab toggle, PlayerMovementController, ChaseCamera, local animation, and outbound messages into the game loop. - [ ] **Step 1: Add fields** ```csharp private PlayerMovementController? _playerController; private ChaseCamera? _chaseCamera; private bool _playerMode; private uint _playerServerGuid; // from CharacterList at login ``` - [ ] **Step 2: Store player GUID at login** In `TryStartLiveSession`, after `var chosen = ...`, store: ```csharp _playerServerGuid = chosen.Id; ``` - [ ] **Step 3: Add Tab key handler** In the existing key-press handler (near the F key toggle), add: ```csharp if (key == Key.Tab && _liveSession is not null) { _playerMode = !_playerMode; if (_playerMode) { // Enter player mode: find our entity, create controller + camera. if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)) { _playerController = new PlayerMovementController(_physicsEngine); _playerController.SetPosition(playerEntity.Position, 0x0001); // initial cell _chaseCamera = new ChaseCamera { Aspect = _window!.Size.X / (float)_window.Size.Y }; _cameraController!.EnterChaseMode(_chaseCamera); } } else { // Exit player mode. _cameraController!.ExitChaseMode(); _playerController = null; _chaseCamera = null; } } ``` - [ ] **Step 4: Wire controller into OnUpdate** In `OnUpdate`, after the existing fly-camera update block, add: ```csharp if (_playerMode && _playerController is not null && _chaseCamera is not null && _input is not null) { var kb = _input.Keyboards[0]; var mouse = _input.Mice.FirstOrDefault(); float mouseX = 0f; // Read mouse delta if captured. if (mouse is not null && _capturedMouse is not null) { // mouseX accumulated since last frame — implementation depends on // how Silk.NET exposes raw mouse delta. Check existing fly camera // mouse handling pattern. } var input = new MovementInput( Forward: kb.IsKeyPressed(Key.W), Backward: kb.IsKeyPressed(Key.S), StrafeLeft: kb.IsKeyPressed(Key.Z), StrafeRight: kb.IsKeyPressed(Key.X), TurnLeft: kb.IsKeyPressed(Key.A), TurnRight: kb.IsKeyPressed(Key.D), Run: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight), MouseDeltaX: mouseX); var result = _playerController.Update((float)dt, input); // Update the player entity's position + rotation. if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) { pe.Position = result.Position; pe.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, _playerController.Yaw); } // Update chase camera. _chaseCamera.Update(result.Position, _playerController.Yaw); // Send outbound messages. if (_liveSession is not null) { // Convert world position back to AC wire coordinates. int lbX = _liveCenterX + (int)MathF.Floor(result.Position.X / 192f); int lbY = _liveCenterY + (int)MathF.Floor(result.Position.Y / 192f); float localX = result.Position.X - (lbX - _liveCenterX) * 192f; float localY = result.Position.Y - (lbY - _liveCenterY) * 192f; uint wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (result.CellId & 0xFFFFu); var wirePos = new Vector3(localX, localY, result.Position.Z); var wireRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, _playerController.Yaw); if (result.MotionStateChanged) { var seq = _liveSession.NextGameActionSequence(); var body = MoveToState.Build( gameActionSequence: seq, forwardCommand: result.ForwardCommand, forwardSpeed: result.ForwardSpeed, sidestepCommand: result.SidestepCommand, sidestepSpeed: result.SidestepSpeed, turnCommand: result.TurnCommand, turnSpeed: result.TurnSpeed, holdKey: result.ForwardCommand is not null && (result.ForwardCommand == 0x44000007u) ? 1u : null, cellId: wireCellId, position: wirePos, rotation: wireRot, instanceSequence: 0, serverControlSequence: 0, teleportSequence: 0, forcePositionSequence: 0); _liveSession.SendGameAction(body); } if (_playerController.HeartbeatDue) { var seq = _liveSession.NextGameActionSequence(); var body = AutonomousPosition.Build( gameActionSequence: seq, cellId: wireCellId, position: wirePos, rotation: wireRot, instanceSequence: 0, serverControlSequence: 0, teleportSequence: 0, forcePositionSequence: 0); _liveSession.SendGameAction(body); } } // Update local animation based on motion command. UpdatePlayerAnimation(result); } ``` - [ ] **Step 5: Add UpdatePlayerAnimation helper** ```csharp private uint? _playerCurrentAnimCommand; private void UpdatePlayerAnimation(MovementResult result) { // Determine the animation command: forward takes priority, then sidestep, then turn, then idle. uint animCommand; if (result.ForwardCommand is { } fwd) animCommand = fwd; else if (result.SidestepCommand is { } ss) animCommand = ss; else if (result.TurnCommand is { } tc) animCommand = tc; else animCommand = 0x41000003u; // Ready (idle) if (animCommand == _playerCurrentAnimCommand) return; _playerCurrentAnimCommand = animCommand; // Re-resolve the animation cycle for the player entity. if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return; if (_dats is null) return; var setup = _dats.Get(pe.SourceGfxObjOrSetupId); if (setup is null) return; ushort cmdOverride = (ushort)(animCommand & 0xFFFF); var cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( setup, _dats, commandOverride: cmdOverride); if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) return; // Update or create the animated entity entry. if (_animatedEntities.TryGetValue(pe.Id, out var ae)) { ae.Animation = cycle.Animation; ae.LowFrame = Math.Max(0, cycle.LowFrame); ae.HighFrame = Math.Min(cycle.HighFrame, cycle.Animation.PartFrames.Count - 1); ae.Framerate = cycle.Framerate; ae.CurrFrame = ae.LowFrame; } } ``` - [ ] **Step 6: Build + test** Run: `cmd.exe /c "taskkill /F /IM AcDream.App.exe 2>nul" && dotnet build -c Debug && dotnet test -c Debug --nologo` Expected: 0 errors, all tests green. - [ ] **Step 7: Commit** ```bash git add src/AcDream.App/Rendering/GameWindow.cs git commit -m "$(cat <<'EOF' feat(app): Phase B.2 — wire player movement into GameWindow Tab toggles between fly mode and player-controlled ground movement. WASD walks/runs, A/D + mouse turns, Z/X strafes. PhysicsEngine resolves positions against terrain. MoveToState sent on motion state changes, AutonomousPosition heartbeats every 200ms while moving. Walk/run/turn/idle animations resolved locally via MotionResolver and played through the existing TickAnimations path. ChaseCamera follows in third-person. Co-Authored-By: Claude Opus 4.6 (1M context) EOF )" ``` --- ## Task 6: Roadmap update + visual verification - [ ] **Step 1: Update roadmap** Mark B.2 as shipped in `docs/plans/2026-04-11-roadmap.md`. - [ ] **Step 2: Commit roadmap** - [ ] **Step 3: Live verification** Launch the app, log in, press Tab, walk around Holtburg with WASD. Verify: 1. Character walks on terrain (not flying/sinking) 2. Walk animation plays 3. Other clients see the character move 4. ACE console shows no movement rejections 5. Tab returns to fly mode --- ## Self-review **Spec coverage:** - Tab toggle ✓ (Task 5 step 3) - WASD walk/run + Z/X strafe + A/D turn + mouse turn ✓ (Task 3) - PhysicsEngine collision ✓ (Task 3 uses B.3) - MoveToState on state change ✓ (Task 1 + Task 5) - AutonomousPosition heartbeat ✓ (Task 1 + Task 5) - Walk/run/turn/idle animation ✓ (Task 5 step 5) - Third-person chase camera ✓ (Task 2) - Camera mode cycling ✓ (Task 4) - AC wire coordinate conversion ✓ (Task 5 step 4) - Player GUID identification ✓ (Task 5 step 2) - Roadmap update ✓ (Task 6) **Placeholder scan:** None found. All code blocks complete. **Type consistency:** `MovementInput`, `MovementResult`, `PlayerMovementController`, `ChaseCamera`, `MoveToState.Build`, `AutonomousPosition.Build`, `WorldSession.SendGameAction`, `NextGameActionSequence` — all consistent across tasks.