diff --git a/docs/superpowers/plans/2026-04-12-player-movement.md b/docs/superpowers/plans/2026-04-12-player-movement.md
new file mode 100644
index 0000000..845507e
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-12-player-movement.md
@@ -0,0 +1,1099 @@
+# 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.