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>
26 KiB
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:
[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:
/// <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:
// 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
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
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:
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:
// 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:
public void SetCharacterSkills(int runSkill, int jumpSkill)
{
_weenie.SetSkills(runSkill, jumpSkill);
}
- Step 6: Build + test green, commit
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:
// 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:
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
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.csreferences/DatReaderWriter/DatReaderWriter/Types/BSPNode.csreferences/DatReaderWriter/DatReaderWriter/Types/BSPTree.csreferences/DatReaderWriter/DatReaderWriter/Types/Polygon.csreferences/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:
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:
/// <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:
- If node is null or sphere doesn't intersect node's BoundingSphere → return false
- Compute
dist = dot(SplittingPlane.Normal, sphere.Center) + SplittingPlane.D reach = sphere.Radius - epsilon- If
dist >= reach→ recurse PosNode only - If
dist <= -reach→ recurse NegNode only - Else (straddles) → recurse both
- At leaf nodes: iterate
node.Polygons, callCollisionPrimitives.SphereIntersectsPolyon 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
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
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,Collideflags -
WalkableAllowance = 0.7f -
InsertTypeenum:Transition,Placement -
CacheLocalSpaceSphere()method -
Step 2: Create CollisionInfo and ObjectInfo
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.
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.
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
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:
- Broad phase:
distance(playerSphere, objSphere) < playerRadius + objRadius - Narrow phase:
BSPQuery.SphereIntersectsPoly(objBSP, playerSphere, movement) - If hit: set
TransitionState.SlidorCollided, 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
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 buildgreen,dotnet testgreen- No regression in existing terrain collision