acdream/docs/superpowers/specs/2026-04-13-movement-completion-design.md
Erik b5e21abe1b docs: movement completion design spec (B.2/B.3)
Four-layer design for retail-faithful movement: speed from RunRate,
charged jump, BSP collision from decompiled CTransition, cell-based
object collision. Decompile-first methodology per CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 23:01:18 +02:00

336 lines
13 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 — Design Spec
Complete Phase B.2/B.3: retail-faithful outdoor movement with proper
speed, jump, and full object collision.
**Methodology:** Decompile → pseudocode → cross-reference ACE → port.
The decompiled acclient.exe is ground truth. ACE (server) is an
interpretation aid. When they disagree, the decompiled code wins.
---
## Problem Statement
Current movement is a minimal MVP:
- Run speed hardcoded to `ForwardSpeed = 1.0` instead of skill-derived
RunRate. Player moves at 4.0 m/s regardless of Run skill.
- No jump implementation despite physics formulas being ported.
- No object collision — player walks through trees, buildings, NPCs,
and walls. CollisionPrimitives.cs is ported but completely unwired.
The result: movement doesn't feel like AC and the client is not
functionally playable.
---
## Architecture: Four Layers, Built Bottom-Up
```
Layer 4: Object-Object Collision (cell-based ShadowObject + per-object BSP)
Layer 3: Transition System (sphere-sweep pipeline from decompiled CTransition)
Layer 2: Physics Data Loading (PhysicsBSP + PhysicsPolygons from dats)
Layer 1: Movement Speed + Jump (wire skill values into existing physics)
```
Each layer ships independently with build+test green and visual
verification.
---
## Layer 1: Movement Speed + Jump
### Speed
**How retail works:**
1. Client sends MoveToState with `HoldKey.Run` (raw input).
2. Server computes RunRate from Run skill + encumbrance:
`RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4`
Capped at 4.5 when runSkill >= 800.
3. Server broadcasts UpdateMotion with `ForwardSpeed = RunRate`.
4. Client's `MotionInterpreter.apply_current_movement()` stores
`MyRunRate = InterpretedState.ForwardSpeed`.
5. `get_state_velocity()` produces: `velocity.Y = RunAnimSpeed(4.0) * ForwardSpeed`.
**What we change:**
- When we receive our own UpdateMotion from the server, feed the
ForwardSpeed into `MotionInterpreter.InterpretedState.ForwardSpeed`.
The existing `apply_current_movement()` will update `MyRunRate`.
- `get_state_velocity()` already uses `MyRunRate` — no change needed
in the velocity computation.
- The MoveToState we send already has `ForwardSpeed: 1.0` for run,
which is correct (raw input speed, server replaces with RunRate).
**Burden system (deferred):**
`GetBurdenMod(burden)` where `burden = encumbrance / capacity`. This
affects both run speed and jump height. For the initial implementation,
assume burden = 0 (unencumbered). Wire burden when inventory is
implemented (Phase B.4+).
### Jump
**Retail jump flow:**
1. Spacebar press → start charging. Internal `extent` rises from 0.0
toward 1.0 over ~1 second.
2. Spacebar release → `MotionInterpreter.jump(extent)`.
3. `jump()` calls `WeenieObj.InqJumpVelocity(extent, out vz)`.
4. Applies vertical velocity `vz` to PhysicsBody. Clears ground contact.
5. Client sends jump packet to server with extent value.
**Jump height formula (from decompiled + ACE cross-reference):**
```
GetJumpHeight(burden, jumpSkill, extent, scaling=1.0):
extent = clamp(extent, 0, 1)
height = GetBurdenMod(burden) * (jumpSkill / (jumpSkill + 1300) * 22.2 + 0.05) * extent
height = max(height, 0.35)
return height
InqJumpVelocity(extent):
height = GetJumpHeight(burden, jumpSkill, extent, 1.0)
vz = sqrt(height * 19.6) // v = sqrt(2*g*h), g=9.8
```
**IWeenieObj implementation:**
Create `PlayerWeenie : IWeenieObj` that reads Run skill and Jump skill
from the WorldSession's cached character properties (sent in
CreateObject). Wire into `MotionInterpreter` constructor.
**Jump bar UI:** Deferred to Phase D (UI overlay). Internally we track
the charge float and jump on key release.
### Acceptance
- Run speed matches retail for the character's Run skill level.
- Walk speed is 3.12 m/s (fixed, no skill scaling).
- Spacebar hold + release produces a jump whose height scales with
hold duration and Jump skill.
- Other players' movement speed looks correct (they use their own
ForwardSpeed from UpdateMotion).
---
## Layer 2: Physics Data Loading
### What to load from dats
DatReaderWriter already parses these types. We just don't read them.
**Per GfxObj (collision mesh):**
- `PhysicsPolygons: Dictionary<ushort, Polygon>` — low-poly collision faces
- `PhysicsBSP: BSPTree` — BSP tree over those polygons
- `PhysicsSphere: Sphere` — bounding sphere for broad-phase
**Per CellStruct (indoor rooms):**
- `PhysicsBSP: PhysicsBSPTree` — room collision BSP (walls, floors)
- `CellBSP: CellBSPTree` — containment/portal BSP (point-in-cell)
**Per Setup (creatures/players):**
- `CylSphere: List<CylSphere>` — collision cylinders
- `Sphere: List<Sphere>` — fallback collision spheres
- `Height, Radius` — dimensions
- `StepUpHeight, StepDownHeight` — movement tolerances
### Storage
`PhysicsDataCache` — singleton, keyed by dat ID. Populated during
streaming alongside render mesh uploads. Shared across all instances
of the same GfxObj (same pattern as TextureCache).
```csharp
public sealed class PhysicsDataCache
{
// Per-GfxObj physics mesh + BSP
ConcurrentDictionary<uint, GfxObjPhysics> _gfxObjPhysics;
// Per-CellStruct collision BSP (keyed by EnvCell dat ID)
ConcurrentDictionary<uint, CellPhysics> _cellPhysics;
}
```
### BSP Tree Representation
Port BSP node types from the dat format. DatReaderWriter already
deserializes `PhysicsBSPNode` / `PhysicsBSPLeaf`. We wrap these in
query-friendly classes:
```csharp
public class PhysicsBSPTree
{
BSPNode Root;
// Core queries (ported from decompiled client):
bool SphereIntersectsPoly(Sphere sphere, Vector3 movement, out CollisionResult result);
bool FindWalkable(Sphere sphere, out Polygon walkable, out float adjustedZ);
bool StepSphereUp(Sphere sphere, Vector3 up, out float stepHeight);
bool SphereIntersectsSolid(Sphere sphere); // for ethereal/placement checks
}
```
### Acceptance
- PhysicsBSP loaded for every GfxObj that has physics data.
- CellStruct physics BSP loaded for every EnvCell.
- Setup collision volumes (CylSphere) loaded for creature entities.
- No rendering impact — physics data is separate from render data.
---
## Layer 3: Transition System
### What it replaces
Current `PhysicsEngine.Resolve()` which only snaps Z to terrain/floor.
### Core types (from decompiled CTransition + ACE cross-reference)
**SpherePath** — movement descriptor threaded through the entire pipeline:
- `LocalSphere[0..1]` — player's 2 stacked collision spheres (body + head)
- `GlobalSphere[0..1]` — same in world space at check position
- `BeginPos / EndPos` — start and end of this frame's movement
- `CheckPos / CheckCell` — current candidate position being tested
- `Walkable` — polygon currently being walked on
- `StepDown / StepUp` flags
- `WalkableAllowance` — min Z-dot to count as walkable (~0.7)
- `CacheLocalSpaceSphere()` — transforms spheres into cell-local space for BSP queries
**Transition** — the driver:
- `ObjectInfo` — flags (OnWalkable, Contact, EdgeSlide, IsPlayer, step heights)
- `SpherePath` — movement descriptor
- `CollisionInfo` — results accumulator (ContactPlane, SlidingNormal)
- `FindTransitionalPosition()` — main entry point
**TransitionState** — per-step result:
- `OK` — no collision
- `Adjusted` — repositioned (step-up/down)
- `Slid` — sliding along surface
- `Collided` — hard block
### Algorithm: FindTransitionalPosition
Ported from decompiled FUN_005387c0:
```
1. Compute total_dist = distance(BeginPos, EndPos)
2. num_steps = ceil(total_dist / sphere_radius)
3. step_size = total_dist / num_steps
4. For each step:
a. Advance CheckPos by step_size along movement direction
b. TransitionalInsert(retries=3):
- InsertIntoCell(CheckPos) → finds the ObjCell for this position
- ObjCell.FindCollisions(transition):
- FindEnvCollisions: BSP query against cell geometry
- FindObjCollisions: iterate ShadowObject list, BSP each
- CheckOtherCells: check neighboring cells the sphere overlaps
c. If TransitionState == OK: continue to next step
d. If Adjusted: validate step-up/step-down, retry
e. If Slid: project velocity along SlidingNormal, retry
f. If Collided: reject movement, keep previous position
5. Final CheckPos becomes new position
```
### Decompile-first requirement
Before writing any code:
1. Read FUN_005387c0 (CTransition::find_collisions) from chunk_00530000.c
2. Read the BSP traversal functions (0x539270, 0x53A550, 0x53A6A0)
3. Write pseudocode in `docs/research/transition_pseudocode.md`
4. Cross-reference ACE's `Transition.cs` for naming
5. Port from pseudocode
### Integration
Replace `PhysicsEngine.Resolve()` internals with `Transition.FindTransitionalPosition()`.
Keep the `PhysicsEngine` as the public API but delegate to Transition internally.
`PlayerMovementController` calls the same `PhysicsEngine.Resolve()` — the interface doesn't change.
### Acceptance
- Player cannot walk through terrain features (hills block movement).
- Player slides along walls instead of stopping dead.
- Step-up works (stairs, small ledges up to StepUpHeight).
- Step-down works (walking downhill maintains ground contact).
- Indoor room walls block movement.
---
## Layer 4: Object-Object Collision
### How retail finds nearby objects
No k-d tree. Cell-based ShadowObject system:
- Every entity registers into each ObjCell it overlaps
- Outdoor cells: 24m × 24m terrain grid cells
- Indoor cells: EnvCells
- During collision, iterate the current cell's shadow list
### ShadowObject Registration
During streaming, when an entity is placed:
1. Compute which cell(s) the entity's bounding sphere overlaps
2. Register a ShadowObject entry in each cell's list
3. On entity removal, deregister
For static entities (trees, buildings), this happens once at load time.
For moving entities (NPCs), update when they move (throttled, not per-frame).
### Per-Object Narrow Phase
In `FindObjCollisions`, for each object in the cell's shadow list:
1. Broad phase: does player bounding sphere overlap object bounding sphere?
2. Narrow phase (if yes):
- If object has `PhysicsBSP`: full BSP query (buildings, trees, statics)
- If object has `CylSphere`: cylinder-sphere intersection (creatures)
- Else: sphere-sphere test
### Collision response
Same as environment: slide along the object's surface normal.
TransitionState `Slid` or `Collided` depending on angle.
### PK collision flag
Player-to-player collision only when both players are flagged as
Player Killers (`PlayerKillerStatus` from CreateObject properties).
Non-PK players pass through each other. This is a flag check in
`FindObjCollisions` before testing player-vs-player — easy to add
once the system works. **Deferred from initial implementation.**
### Acceptance
- Player cannot walk through trees.
- Player cannot walk through buildings (exterior walls block).
- Player cannot walk through static scenery objects.
- Player slides along objects (doesn't stop dead).
- NPCs block movement (cylinder collision).
---
## Implementation Order
| Step | Layer | What ships | Estimated complexity |
|------|-------|-----------|---------------------|
| 1 | L1 | RunRate from server, correct speed | Small — wire existing code |
| 2 | L1 | Jump with charge + skill-based height | Small — bind key, implement IWeenieObj |
| 3 | L2 | PhysicsDataCache populated from dats | Medium — read + cache BSP/polygons |
| 4 | L3 | Transition system core (env collision) | Large — decompile + pseudocode + port |
| 5 | L3 | Step-up / step-down / wall slide | Large — part of transition, complex response |
| 6 | L4 | ShadowObject registration | Medium — cell bucketing during streaming |
| 7 | L4 | Object collision in transition loop | Medium — wire into FindObjCollisions |
Steps 1-2 can ship immediately. Steps 3-5 are the critical path.
Steps 6-7 build on top of the working transition system.
---
## What We Already Have (reuse, don't rewrite)
- `CollisionPrimitives.cs` — 9 sphere/polygon functions, ported from decompiled. Wire into BSP leaf queries.
- `PhysicsBody.cs` — Euler integration, gravity, friction. Keep as-is.
- `MotionInterpreter.cs` — Motion state machine with correct constants. Wire IWeenieObj.
- `TerrainSurface.cs` — Heightmap Z sampling. Used by transition for outdoor cells.
- `CellSurface.cs` — Indoor floor Z. Used by transition for indoor cells.
- `PortalPlane.cs` — Portal crossing detection. Used by transition for cell transitions.
## What We Don't Build Yet
- Burden/encumbrance system (needs inventory, Phase B.4+)
- Jump stamina cost (needs stamina tracking)
- PK player-vs-player collision (flag check, easy to add later)
- Jump bar UI (Phase D)
- Dead reckoning for other players (separate issue, not collision)
- Projectile collision (spells/arrows, Phase E)