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>
This commit is contained in:
parent
cffc3ee343
commit
b5e21abe1b
1 changed files with 336 additions and 0 deletions
336
docs/superpowers/specs/2026-04-13-movement-completion-design.md
Normal file
336
docs/superpowers/specs/2026-04-13-movement-completion-design.md
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
# 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue