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:
parent
b5e21abe1b
commit
4988ea02c0
1 changed files with 770 additions and 0 deletions
770
docs/superpowers/plans/2026-04-13-movement-completion.md
Normal file
770
docs/superpowers/plans/2026-04-13-movement-completion.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue