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