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

13 KiB
Raw Blame History

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).

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:

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)