diff --git a/docs/superpowers/plans/2026-04-13-movement-completion.md b/docs/superpowers/plans/2026-04-13-movement-completion.md
new file mode 100644
index 0000000..11fe5bb
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-13-movement-completion.md
@@ -0,0 +1,770 @@
+# Movement Completion 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:** Retail-faithful movement: correct run/walk speed from server RunRate, charged jump, BSP-based environment collision, and cell-based object collision.
+
+**Architecture:** Four layers built bottom-up. Layer 1 (speed+jump) wires existing MotionInterpreter to server data. Layer 2 loads PhysicsBSP from dats. Layer 3 ports the CTransition sphere-sweep pipeline from the decompiled client. Layer 4 adds cell-based object collision via ShadowObject lists.
+
+**Tech Stack:** C# .NET 10, Silk.NET, DatReaderWriter 2.1.4, decompiled acclient.exe (ground truth), ACE (cross-reference)
+
+**Spec:** `docs/superpowers/specs/2026-04-13-movement-completion-design.md`
+
+---
+
+## Task 1: Wire Server RunRate into MotionInterpreter
+
+The server sends `ForwardSpeed = RunRate` in UpdateMotion broadcasts.
+We currently ignore it for the local player's motion state.
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (OnLiveMotionUpdated handler)
+- Modify: `src/AcDream.App/Input/PlayerMovementController.cs` (expose MotionInterpreter)
+- Test: `tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs`
+
+- [ ] **Step 1: Write test for MyRunRate update**
+
+In `tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs`, add:
+
+```csharp
+[Fact]
+public void ApplyCurrentMovement_RunForward_SetsMyRunRate()
+{
+ var body = new PhysicsBody();
+ var mi = new MotionInterpreter(body);
+
+ // Simulate server setting ForwardSpeed to RunRate of 2.375 (Run skill 200)
+ mi.InterpretedState.ForwardCommand = MotionCommand.RunForward;
+ mi.InterpretedState.ForwardSpeed = 2.375f;
+ mi.apply_current_movement(cancelMoveTo: false, allowJump: true);
+
+ Assert.Equal(2.375f, mi.MyRunRate, precision: 3);
+
+ // get_state_velocity should produce RunAnimSpeed * RunRate
+ var vel = mi.get_state_velocity();
+ Assert.Equal(4.0f * 2.375f, vel.Y, precision: 2);
+}
+```
+
+- [ ] **Step 2: Run test to verify it passes (it should — the logic exists)**
+
+Run: `dotnet test tests/AcDream.Core.Tests --filter ApplyCurrentMovement_RunForward`
+Expected: PASS (the MotionInterpreter already has this logic at line 540)
+
+- [ ] **Step 3: Expose player MotionInterpreter for external ForwardSpeed updates**
+
+In `src/AcDream.App/Input/PlayerMovementController.cs`, add a public method:
+
+```csharp
+///
+/// Apply server-echoed ForwardSpeed (RunRate) to the motion interpreter.
+/// Called when we receive our own UpdateMotion back from the server.
+///
+public void ApplyServerRunRate(float forwardSpeed)
+{
+ _motion.InterpretedState.ForwardSpeed = forwardSpeed;
+ _motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
+}
+```
+
+- [ ] **Step 4: Feed server UpdateMotion into player controller**
+
+In `src/AcDream.App/Rendering/GameWindow.cs`, find the `OnLiveMotionUpdated` handler.
+When the UpdateMotion is for the player's own GUID, feed ForwardSpeed
+into the player controller:
+
+```csharp
+// Inside OnLiveMotionUpdated, after existing animation handling:
+if (_playerController is not null && update.Guid == _playerGuid
+ && update.MotionState.ForwardSpeed > 0f)
+{
+ _playerController.ApplyServerRunRate(update.MotionState.ForwardSpeed);
+}
+```
+
+Note: `_playerGuid` is the server GUID from login. Check if it's stored
+already — search for the player GUID field. It's likely `_liveSession.Characters[0].Id`
+or stored during `OnLiveEntitySpawned` for the player entity.
+
+- [ ] **Step 5: Verify UpdateMotion.MotionState has ForwardSpeed**
+
+Read `src/AcDream.Core.Net/Messages/UpdateMotion.cs` to confirm
+the parsed `MotionState` struct includes `ForwardSpeed`. If not,
+add ForwardSpeed parsing from the InterpretedMotionState packed fields.
+Cross-reference holtburger's `client/movement/types.rs` for the
+packed format.
+
+- [ ] **Step 6: Build + test green**
+
+Run: `dotnet build && dotnet test`
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A
+git commit -m "feat(movement): wire server RunRate into player MotionInterpreter
+
+UpdateMotion broadcasts from the server carry ForwardSpeed = RunRate
+(computed from Run skill + encumbrance). Feed this into the player's
+MotionInterpreter so get_state_velocity produces the correct speed.
+Previously hardcoded at 1.0 (4.0 m/s), now matches character's skill."
+```
+
+---
+
+## Task 2: Implement PlayerWeenie (IWeenieObj)
+
+The MotionInterpreter queries `IWeenieObj.InqRunRate()` and
+`InqJumpVelocity()` but no production implementation exists.
+
+**Files:**
+- Create: `src/AcDream.Core/Physics/PlayerWeenie.cs`
+- Modify: `src/AcDream.App/Input/PlayerMovementController.cs` (wire into MotionInterpreter)
+- Test: `tests/AcDream.Core.Tests/Physics/PlayerWeenieTests.cs`
+
+- [ ] **Step 1: Write tests for PlayerWeenie**
+
+```csharp
+public class PlayerWeenieTests
+{
+ [Fact]
+ public void InqRunRate_Skill200_ReturnsCorrectRate()
+ {
+ var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
+ Assert.True(pw.InqRunRate(out float rate));
+ // RunRate = (1.0 * (200 / (200+200)) * 11 + 4) / 4 = (5.5 + 4) / 4 = 2.375
+ Assert.Equal(2.375f, rate, precision: 3);
+ }
+
+ [Fact]
+ public void InqRunRate_Skill800_ReturnsCap()
+ {
+ var pw = new PlayerWeenie(runSkill: 800, jumpSkill: 100);
+ Assert.True(pw.InqRunRate(out float rate));
+ Assert.Equal(4.5f, rate, precision: 3);
+ }
+
+ [Fact]
+ public void InqRunRate_Skill0_ReturnsBase()
+ {
+ var pw = new PlayerWeenie(runSkill: 0, jumpSkill: 100);
+ Assert.True(pw.InqRunRate(out float rate));
+ // (1.0 * 0 + 4) / 4 = 1.0
+ Assert.Equal(1.0f, rate, precision: 3);
+ }
+
+ [Fact]
+ public void InqJumpVelocity_FullExtent_Skill100()
+ {
+ var pw = new PlayerWeenie(runSkill: 100, jumpSkill: 100);
+ Assert.True(pw.InqJumpVelocity(1.0f, out float vz));
+ // height = (100/(100+1300)) * 22.2 + 0.05 = 1.636
+ // vz = sqrt(1.636 * 19.6) = sqrt(32.07) = 5.663
+ Assert.Equal(5.663f, vz, precision: 1);
+ }
+
+ [Fact]
+ public void InqJumpVelocity_HalfExtent_HalvesHeight()
+ {
+ var pw = new PlayerWeenie(runSkill: 100, jumpSkill: 100);
+ Assert.True(pw.InqJumpVelocity(0.5f, out float vz));
+ // height = (100/1400) * 22.2 * 0.5 + 0.05 = 0.843
+ // But min height = 0.35, so 0.843 is fine
+ // vz = sqrt(0.843 * 19.6) = sqrt(16.52) = 4.065
+ Assert.Equal(4.065f, vz, precision: 1);
+ }
+
+ [Fact]
+ public void InqJumpVelocity_ZeroSkill_ClampsToMinHeight()
+ {
+ var pw = new PlayerWeenie(runSkill: 0, jumpSkill: 0);
+ Assert.True(pw.InqJumpVelocity(1.0f, out float vz));
+ // height = max(0.05, 0.35) = 0.35 → vz = sqrt(0.35 * 19.6) = sqrt(6.86) = 2.619
+ Assert.Equal(2.619f, vz, precision: 1);
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `dotnet test tests/AcDream.Core.Tests --filter PlayerWeenie`
+Expected: FAIL (class doesn't exist)
+
+- [ ] **Step 3: Implement PlayerWeenie**
+
+Create `src/AcDream.Core/Physics/PlayerWeenie.cs`:
+
+```csharp
+namespace AcDream.Core.Physics;
+
+///
+/// IWeenieObject implementation for the local player. Provides skill-based
+/// run rate and jump velocity calculations.
+///
+/// Formulas from decompiled acclient.exe, cross-referenced against
+/// ACE MovementSystem.GetRunRate and MovementSystem.GetJumpHeight.
+///
+public sealed class PlayerWeenie : IWeenieObject
+{
+ private int _runSkill;
+ private int _jumpSkill;
+ private float _burden; // 0.0 = unencumbered, 1.0 = at capacity, 2.0+ = overloaded
+
+ public PlayerWeenie(int runSkill = 0, int jumpSkill = 0, float burden = 0f)
+ {
+ _runSkill = runSkill;
+ _jumpSkill = jumpSkill;
+ _burden = burden;
+ }
+
+ public void SetSkills(int runSkill, int jumpSkill)
+ {
+ _runSkill = runSkill;
+ _jumpSkill = jumpSkill;
+ }
+
+ public void SetBurden(float burden) => _burden = burden;
+
+ public bool InqRunRate(out float rate)
+ {
+ rate = GetRunRate(_burden, _runSkill);
+ return true;
+ }
+
+ public bool InqJumpVelocity(float extent, out float vz)
+ {
+ float height = GetJumpHeight(_burden, _jumpSkill, extent);
+ vz = MathF.Sqrt(height * 19.6f);
+ return true;
+ }
+
+ public bool CanJump(float extent) => true; // burden/stamina checks deferred
+
+ ///
+ /// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4
+ /// Capped at 4.5 when runSkill >= 800.
+ /// Source: decompiled + ACE MovementSystem.GetRunRate
+ ///
+ public static float GetRunRate(float burden, int runSkill)
+ {
+ if (runSkill >= 800) return 18f / 4f; // 4.5 cap
+
+ float loadMod = GetBurdenMod(burden);
+ return (loadMod * ((float)runSkill / (runSkill + 200) * 11f) + 4f) / 4f;
+ }
+
+ ///
+ /// JumpHeight = burdenMod * (jumpSkill / (jumpSkill + 1300) * 22.2 + 0.05) * extent
+ /// Clamped to minimum 0.35m.
+ /// Source: decompiled + ACE MovementSystem.GetJumpHeight
+ ///
+ public static float GetJumpHeight(float burden, int jumpSkill, float extent)
+ {
+ extent = Math.Clamp(extent, 0f, 1f);
+ float loadMod = GetBurdenMod(burden);
+ float height = loadMod * ((float)jumpSkill / (jumpSkill + 1300f) * 22.2f + 0.05f) * extent;
+ return MathF.Max(height, 0.35f);
+ }
+
+ ///
+ /// Encumbrance modifier: 1.0 when unloaded, linearly decreasing to 0 at 200%.
+ /// Source: decompiled + ACE EncumbranceSystem.GetBurdenMod
+ ///
+ public static float GetBurdenMod(float burden)
+ {
+ if (burden < 1f) return 1f;
+ if (burden < 2f) return 2f - burden;
+ return 0f;
+ }
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `dotnet test tests/AcDream.Core.Tests --filter PlayerWeenie`
+Expected: ALL PASS
+
+- [ ] **Step 5: Wire PlayerWeenie into PlayerMovementController**
+
+In `PlayerMovementController` constructor, create a `PlayerWeenie` with
+default skill values (runSkill=200, jumpSkill=100 as reasonable defaults
+until we parse skills from CreateObject). Pass it to the MotionInterpreter:
+
+```csharp
+// In constructor:
+_weenie = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
+_motion = new MotionInterpreter(_body, _weenie);
+```
+
+Add a public method to update skills when we eventually parse them:
+```csharp
+public void SetCharacterSkills(int runSkill, int jumpSkill)
+{
+ _weenie.SetSkills(runSkill, jumpSkill);
+}
+```
+
+- [ ] **Step 6: Build + test green, commit**
+
+```bash
+dotnet build && dotnet test
+git add -A
+git commit -m "feat(physics): PlayerWeenie with retail Run/Jump formulas
+
+Implements IWeenieObject with GetRunRate and GetJumpHeight from
+decompiled client, cross-referenced against ACE MovementSystem.
+Default skills (Run=200, Jump=100) used until skill parsing ships."
+```
+
+---
+
+## Task 3: Implement Jump with Spacebar Charge
+
+**Files:**
+- Modify: `src/AcDream.App/Input/PlayerMovementController.cs` (charge state + jump on release)
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (spacebar key handling)
+- Test: manual visual verification (jump is visible)
+
+- [ ] **Step 1: Add jump charge state to PlayerMovementController**
+
+Add fields and logic to `PlayerMovementController`:
+
+```csharp
+// New fields:
+private bool _jumpCharging;
+private float _jumpExtent;
+private const float JumpChargeRate = 1.0f; // 0→1 in 1 second (verify against decompiled)
+
+// In Update(), after existing movement logic but before result:
+if (input.Jump && _isOnGround)
+{
+ if (!_jumpCharging)
+ {
+ _jumpCharging = true;
+ _jumpExtent = 0f;
+ }
+ _jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f);
+}
+else if (_jumpCharging && !input.Jump)
+{
+ // Released — execute jump
+ _motion.jump(_jumpExtent);
+ _jumpCharging = false;
+ _jumpExtent = 0f;
+ // Queue jump packet to server
+ outJumpExtent = _jumpExtent; // add to MovementResult
+}
+```
+
+- [ ] **Step 2: Add Jump field to MovementResult**
+
+In `PlayerMovementController.cs`, add to `MovementResult`:
+```csharp
+public readonly record struct MovementResult(
+ // ... existing fields ...
+ float? JumpExtent = null // non-null when jump was triggered this frame
+);
+```
+
+- [ ] **Step 3: Handle spacebar input in GameWindow**
+
+In `GameWindow.cs` where `MovementInput` is constructed from keyboard state,
+ensure `Jump = keyboard.IsKeyPressed(Key.Space)` (held, not just pressed once).
+The controller tracks charge state internally.
+
+- [ ] **Step 4: Send jump packet to server**
+
+In `GameWindow.cs` where `MovementResult` is consumed, if `result.JumpExtent.HasValue`:
+send a jump message to the server. Check holtburger's `client/movement/actions.rs`
+for the jump packet format. Cross-reference ACE's `GameActionJump` handler.
+
+- [ ] **Step 5: Build + test green, visual verification**
+
+Run the client, press and hold spacebar, release. Character should jump.
+Height should scale with hold duration. Verify ground contact detection
+works (can't double-jump).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A
+git commit -m "feat(movement): spacebar charged jump with skill-based height
+
+Hold spacebar to charge (0→1 over 1s), release to jump. Height from
+GetJumpHeight formula using Jump skill. Sends jump packet to server."
+```
+
+---
+
+## Task 4: Load PhysicsBSP from GfxObj Dats
+
+**Files:**
+- Create: `src/AcDream.Core/Physics/PhysicsDataCache.cs`
+- Create: `src/AcDream.Core/Physics/BSPQuery.cs` (BSP traversal queries)
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (populate cache during streaming)
+- Test: `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs`
+
+- [ ] **Step 1: Research — read DatReaderWriter BSP types**
+
+Read these files to understand the exact deserialized structure:
+- `references/DatReaderWriter/DatReaderWriter/Types/PhysicsBSPNode.cs`
+- `references/DatReaderWriter/DatReaderWriter/Types/BSPNode.cs`
+- `references/DatReaderWriter/DatReaderWriter/Types/BSPTree.cs`
+- `references/DatReaderWriter/DatReaderWriter/Types/Polygon.cs`
+- `references/DatReaderWriter/DatReaderWriter/Types/CylSphere.generated.cs`
+
+Document the exact property names and types. The BSP is already
+deserialized; we just need to traverse it.
+
+- [ ] **Step 2: Create PhysicsDataCache**
+
+Create `src/AcDream.Core/Physics/PhysicsDataCache.cs`:
+
+```csharp
+using System.Collections.Concurrent;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Caches physics collision data (BSP trees, polygons, collision volumes)
+/// extracted from dat records during streaming. Keyed by dat ID.
+/// Thread-safe for concurrent reads during collision queries.
+///
+public sealed class PhysicsDataCache
+{
+ /// Per-GfxObj physics data (BSP + polygons + bounding sphere).
+ private readonly ConcurrentDictionary _gfxObj = new();
+
+ /// Per-Setup collision volumes (CylSpheres, step heights).
+ private readonly ConcurrentDictionary _setup = new();
+
+ public void CacheGfxObj(uint gfxObjId, GfxObj gfxObj) { /* extract PhysicsBSP, PhysicsPolygons, bounding sphere */ }
+ public void CacheSetup(uint setupId, Setup setup) { /* extract CylSpheres, Height, Radius, step heights */ }
+ public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null;
+ public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null;
+}
+
+public sealed class GfxObjPhysics
+{
+ public PhysicsBSPTree? BSP;
+ public Dictionary? PhysicsPolygons;
+ public Sphere? BoundingSphere;
+}
+
+public sealed class SetupPhysics
+{
+ public List CylSpheres = new();
+ public List Spheres = new();
+ public float Height;
+ public float Radius;
+ public float StepUpHeight;
+ public float StepDownHeight;
+}
+```
+
+- [ ] **Step 3: Populate cache during streaming**
+
+In `GameWindow.cs`, wherever GfxObj is loaded (`_dats.Get(...)` calls),
+also call `_physicsDataCache.CacheGfxObj(id, gfxObj)`. Same for Setup.
+The cache extracts the physics fields — no new dat reads needed.
+
+- [ ] **Step 4: Write BSP sphere-intersects-poly query**
+
+Create `src/AcDream.Core/Physics/BSPQuery.cs`. Port the BSP traversal
+from decompiled FUN_00539270 (sphere_intersects_poly), cross-referencing
+ACE's `BSPNode.sphere_intersects_poly()`. This is the core query used
+by the Transition system:
+
+```csharp
+///
+/// BSP tree traversal queries ported from decompiled acclient.exe.
+/// Cross-referenced against ACE BSPNode.cs for naming.
+///
+public static class BSPQuery
+{
+ ///
+ /// Test if a sphere moving along a direction intersects any polygon in the BSP.
+ /// Returns the first hit polygon and collision time.
+ /// Decompiled: FUN_00539270 (chunk_00530000.c)
+ /// ACE: BSPNode.sphere_intersects_poly()
+ ///
+ public static bool SphereIntersectsPoly(
+ PhysicsBSPNode? node,
+ Dictionary polygons,
+ Sphere sphere,
+ Vector3 movement,
+ out ushort hitPolyId,
+ out float hitTime) { ... }
+}
+```
+
+The traversal algorithm:
+1. If node is null or sphere doesn't intersect node's BoundingSphere → return false
+2. Compute `dist = dot(SplittingPlane.Normal, sphere.Center) + SplittingPlane.D`
+3. `reach = sphere.Radius - epsilon`
+4. If `dist >= reach` → recurse PosNode only
+5. If `dist <= -reach` → recurse NegNode only
+6. Else (straddles) → recurse both
+7. At leaf nodes: iterate `node.Polygons`, call `CollisionPrimitives.SphereIntersectsPoly` on each
+
+- [ ] **Step 5: Write BSP tests using a known GfxObj**
+
+Test with a simple case: load a known GfxObj from the dats (e.g., a tree
+or wall), query its PhysicsBSP with a sphere that should/shouldn't hit.
+
+- [ ] **Step 6: Build + test green, commit**
+
+```bash
+dotnet build && dotnet test
+git add -A
+git commit -m "feat(physics): PhysicsDataCache + BSP sphere query
+
+Load PhysicsBSP and PhysicsPolygons from GfxObj dats during streaming.
+BSPQuery.SphereIntersectsPoly traverses the tree for collision detection.
+Ported from decompiled FUN_00539270, cross-ref ACE BSPNode."
+```
+
+---
+
+## Task 5: Decompile + Pseudocode CTransition
+
+**This is a research task, not a coding task.** Must be completed before
+Tasks 6-7.
+
+**Files:**
+- Read: `docs/research/decompiled/chunk_00530000.c` (lines around FUN_005387c0)
+- Read: `docs/research/decompiled/chunk_00500000.c` (CPhysicsObj callers)
+- Read: `references/ACE/Source/ACE.Server/Physics/Transition.cs`
+- Read: `references/ACE/Source/ACE.Server/Physics/SpherePath.cs`
+- Read: `references/ACE/Source/ACE.Server/Physics/Collision/CollisionInfo.cs`
+- Create: `docs/research/transition_pseudocode.md`
+
+- [ ] **Step 1: Read decompiled CTransition::find_collisions (FUN_005387c0)**
+
+Read chunk_00530000.c starting at FUN_005387c0. This is the main
+collision loop. Map local variables to ACE's `Transition` fields.
+Document the control flow.
+
+- [ ] **Step 2: Read decompiled BSP traversal functions**
+
+Read FUN_00539270 (sphere_intersects_poly), FUN_0053A550 (find_walkable),
+FUN_0053A6A0 (step_sphere_up), FUN_00538E20 (adjust_sphere_to_plane).
+These are called from within find_collisions.
+
+- [ ] **Step 3: Cross-reference ACE Transition.cs and SpherePath.cs**
+
+Map each decompiled function to its ACE equivalent. Note any differences.
+ACE field names become our C# names.
+
+- [ ] **Step 4: Write pseudocode**
+
+Create `docs/research/transition_pseudocode.md` with readable pseudocode
+for:
+- `FindTransitionalPosition()` — the main entry point
+- `TransitionalInsert()` — per-step collision check
+- `FindEnvCollisions()` — BSP query against environment
+- `StepUp()` / `StepDown()` — step height handling
+- `AdjustOffset()` — wall slide projection
+
+- [ ] **Step 5: Commit pseudocode**
+
+```bash
+git add docs/research/transition_pseudocode.md
+git commit -m "docs: CTransition pseudocode from decompiled FUN_005387c0"
+```
+
+---
+
+## Task 6: Port Transition System Core
+
+**Depends on:** Task 4 (PhysicsDataCache) and Task 5 (pseudocode)
+
+**Files:**
+- Create: `src/AcDream.Core/Physics/SpherePath.cs`
+- Create: `src/AcDream.Core/Physics/Transition.cs`
+- Create: `src/AcDream.Core/Physics/CollisionInfo.cs`
+- Create: `src/AcDream.Core/Physics/ObjectInfo.cs`
+- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs` (delegate to Transition)
+- Test: `tests/AcDream.Core.Tests/Physics/TransitionTests.cs`
+
+- [ ] **Step 1: Create SpherePath**
+
+Port from pseudocode (Task 5). Key fields:
+- `LocalSphere[2]`, `GlobalSphere[2]`, `GlobalCurrCenter[2]`
+- `BeginPos`, `EndPos`, `CheckPos`, `CheckCell`
+- `Walkable` (current ground polygon)
+- `StepDown`, `StepUp`, `Collide` flags
+- `WalkableAllowance = 0.7f`
+- `InsertType` enum: `Transition`, `Placement`
+- `CacheLocalSpaceSphere()` method
+
+- [ ] **Step 2: Create CollisionInfo and ObjectInfo**
+
+```csharp
+public sealed class CollisionInfo
+{
+ public Plane ContactPlane;
+ public uint ContactPlaneCellId;
+ public Vector3 SlidingNormal; // XY only (Z forced to 0)
+ public Vector3 CollisionNormal; // full 3D
+ public bool CollidedWithEnvironment;
+ public List CollideObjectGuids = new();
+}
+
+public sealed class ObjectInfo
+{
+ public bool OnWalkable;
+ public bool Contact;
+ public bool EdgeSlide;
+ public bool IsPlayer;
+ public float StepUpHeight;
+ public float StepDownHeight;
+ // ObjectInfoState flags matching decompiled
+}
+```
+
+- [ ] **Step 3: Create Transition core**
+
+Port `FindTransitionalPosition`, `TransitionalInsert`, `FindEnvCollisions`
+from the pseudocode in Task 5. Use `PhysicsDataCache` for BSP queries.
+Use `TerrainSurface` for outdoor terrain collision. Use `CellSurface`
+for indoor floor collision.
+
+```csharp
+public sealed class Transition
+{
+ public ObjectInfo ObjectInfo;
+ public SpherePath SpherePath;
+ public CollisionInfo CollisionInfo;
+
+ public TransitionState FindTransitionalPosition(
+ Vector3 beginPos, Vector3 endPos,
+ PhysicsEngine engine, PhysicsDataCache cache)
+ {
+ // Port from Task 5 pseudocode
+ }
+}
+
+public enum TransitionState { OK, Adjusted, Collided, Slid }
+```
+
+- [ ] **Step 4: Wire into PhysicsEngine.Resolve()**
+
+Replace the body of `PhysicsEngine.Resolve()` to create a `Transition`,
+call `FindTransitionalPosition`, and return the resolved position.
+Keep the same public API so `PlayerMovementController` doesn't change.
+
+- [ ] **Step 5: Write tests**
+
+Test with known scenarios:
+- Walk into a flat wall → position doesn't pass through, slides along it
+- Walk up a small step (< StepUpHeight) → climbs it
+- Walk off a ledge → step-down maintains ground contact
+- Walk on flat terrain → no change from current behavior (regression test)
+
+- [ ] **Step 6: Visual verification + commit**
+
+Run the client. Walk into the Holtburg Academy building exterior wall.
+You should stop or slide along it instead of walking through.
+
+```bash
+git add -A
+git commit -m "feat(physics): CTransition sphere-sweep collision pipeline
+
+Port CTransition::find_collisions from decompiled FUN_005387c0.
+Sphere-sweep with sub-stepping, BSP environment collision, step-up/down,
+wall sliding. Replaces terrain-only PhysicsEngine.Resolve()."
+```
+
+---
+
+## Task 7: Cell-Based ShadowObject Registration
+
+**Depends on:** Task 6 (Transition system)
+
+**Files:**
+- Create: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs`
+- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs` (register entities on add)
+- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs` (expose registry for Transition)
+
+- [ ] **Step 1: Create ShadowObjectRegistry**
+
+```csharp
+namespace AcDream.Core.Physics;
+
+///
+/// Cell-based spatial index for object collision. Each entity registers
+/// into the outdoor terrain cells (24m × 24m) it overlaps. The Transition
+/// system queries this to find nearby objects during collision detection.
+///
+/// Retail AC uses the same cell-based approach (no k-d tree / octree).
+/// Source: decompiled CPhysicsObj::calc_cross_cells + add_shadows_to_cell.
+///
+public sealed class ShadowObjectRegistry
+{
+ // Key: cell ID (landblock high 16 bits | cell index low 16 bits)
+ // Value: list of entity references in that cell
+ private readonly Dictionary> _cells = new();
+
+ public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, float radius) { ... }
+ public void Deregister(uint entityId) { ... }
+ public IReadOnlyList GetObjectsInCell(uint cellId) { ... }
+}
+
+public readonly record struct ShadowEntry(uint EntityId, uint GfxObjId, Vector3 Position, float Radius);
+```
+
+- [ ] **Step 2: Register entities during streaming**
+
+In `GpuWorldState.AddLandblock()` or the streaming pipeline, register
+each entity with a PhysicsBSP into the ShadowObjectRegistry based on
+its world position → cell mapping.
+
+- [ ] **Step 3: Wire into Transition.FindObjCollisions**
+
+After `FindEnvCollisions` in the transition loop, query the
+ShadowObjectRegistry for the current cell. For each shadow entry:
+1. Broad phase: `distance(playerSphere, objSphere) < playerRadius + objRadius`
+2. Narrow phase: `BSPQuery.SphereIntersectsPoly(objBSP, playerSphere, movement)`
+3. If hit: set `TransitionState.Slid` or `Collided`, compute SlidingNormal
+
+- [ ] **Step 4: Test with static objects**
+
+Walk into a tree in Holtburg. Should be blocked. Walk into the Academy
+building exterior. Should be blocked and slide along the wall.
+
+- [ ] **Step 5: Add CylSphere collision for creatures**
+
+For entities with a Setup that has CylSpheres (NPCs, creatures):
+test `CylSphere.Intersects(playerSphere)` instead of BSP query.
+Port `CylSphere::IntershectsSphere` from decompiled client.
+
+- [ ] **Step 6: Visual verification + commit**
+
+```bash
+git add -A
+git commit -m "feat(physics): cell-based object collision via ShadowObject
+
+Register entities into terrain cells during streaming. Transition
+system queries nearby objects and runs BSP/CylSphere collision.
+Player can no longer walk through trees, buildings, or NPCs."
+```
+
+---
+
+## Verification Checklist (after all tasks)
+
+- [ ] Run speed matches retail for character's Run skill
+- [ ] Walk speed is 3.12 m/s regardless of skill
+- [ ] Spacebar charge + release produces variable-height jump
+- [ ] Cannot walk through trees
+- [ ] Cannot walk through building exterior walls
+- [ ] Slides along walls instead of stopping dead
+- [ ] Step-up works on small ledges/stairs
+- [ ] Step-down maintains ground contact walking downhill
+- [ ] NPCs block movement (cylinder collision)
+- [ ] Other players' movement speed looks correct
+- [ ] `dotnet build` green, `dotnet test` green
+- [ ] No regression in existing terrain collision