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>
336 lines
13 KiB
Markdown
336 lines
13 KiB
Markdown
# 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)
|