acdream/docs/superpowers/plans/2026-04-13-movement-completion.md
Erik 4988ea02c0 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>
2026-04-13 23:08:48 +02:00

770 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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