# 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
/// <summary>
/// Apply server-echoed ForwardSpeed (RunRate) to the motion interpreter.
/// Called when we receive our own UpdateMotion back from the server.
/// </summary>
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;

/// <summary>
/// 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.
/// </summary>
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

    /// <summary>
    /// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4
    /// Capped at 4.5 when runSkill >= 800.
    /// Source: decompiled + ACE MovementSystem.GetRunRate
    /// </summary>
    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;
    }

    /// <summary>
    /// JumpHeight = burdenMod * (jumpSkill / (jumpSkill + 1300) * 22.2 + 0.05) * extent
    /// Clamped to minimum 0.35m.
    /// Source: decompiled + ACE MovementSystem.GetJumpHeight
    /// </summary>
    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);
    }

    /// <summary>
    /// Encumbrance modifier: 1.0 when unloaded, linearly decreasing to 0 at 200%.
    /// Source: decompiled + ACE EncumbranceSystem.GetBurdenMod
    /// </summary>
    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;

/// <summary>
/// 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.
/// </summary>
public sealed class PhysicsDataCache
{
    /// <summary>Per-GfxObj physics data (BSP + polygons + bounding sphere).</summary>
    private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();

    /// <summary>Per-Setup collision volumes (CylSpheres, step heights).</summary>
    private readonly ConcurrentDictionary<uint, SetupPhysics> _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<ushort, Polygon>? PhysicsPolygons;
    public Sphere? BoundingSphere;
}

public sealed class SetupPhysics
{
    public List<CylSphere> CylSpheres = new();
    public List<Sphere> 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<GfxObj>(...)` 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
/// <summary>
/// BSP tree traversal queries ported from decompiled acclient.exe.
/// Cross-referenced against ACE BSPNode.cs for naming.
/// </summary>
public static class BSPQuery
{
    /// <summary>
    /// 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()
    /// </summary>
    public static bool SphereIntersectsPoly(
        PhysicsBSPNode? node,
        Dictionary<ushort, Polygon> 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<uint> 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;

/// <summary>
/// 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.
/// </summary>
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<uint, List<ShadowEntry>> _cells = new();

    public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, float radius) { ... }
    public void Deregister(uint entityId) { ... }
    public IReadOnlyList<ShadowEntry> 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
