# 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