docs: movement completion implementation plan (7 tasks)

Layer 1: wire server RunRate + PlayerWeenie + charged jump
Layer 2: PhysicsDataCache + BSP sphere query from dats
Layer 3: decompile CTransition pseudocode + port transition system
Layer 4: cell-based ShadowObject registration + object collision

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 23:08:48 +02:00
parent b5e21abe1b
commit 4988ea02c0

View file

@ -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
/// <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