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>
13 KiB
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.0instead 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:
- Client sends MoveToState with
HoldKey.Run(raw input). - Server computes RunRate from Run skill + encumbrance:
RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4Capped at 4.5 when runSkill >= 800. - Server broadcasts UpdateMotion with
ForwardSpeed = RunRate. - Client's
MotionInterpreter.apply_current_movement()storesMyRunRate = InterpretedState.ForwardSpeed. 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 existingapply_current_movement()will updateMyRunRate. get_state_velocity()already usesMyRunRate— no change needed in the velocity computation.- The MoveToState we send already has
ForwardSpeed: 1.0for 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:
- Spacebar press → start charging. Internal
extentrises from 0.0 toward 1.0 over ~1 second. - Spacebar release →
MotionInterpreter.jump(extent). jump()callsWeenieObj.InqJumpVelocity(extent, out vz).- Applies vertical velocity
vzto PhysicsBody. Clears ground contact. - 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 facesPhysicsBSP: BSPTree— BSP tree over those polygonsPhysicsSphere: 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 cylindersSphere: List<Sphere>— fallback collision spheresHeight, Radius— dimensionsStepUpHeight, 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 positionBeginPos / EndPos— start and end of this frame's movementCheckPos / CheckCell— current candidate position being testedWalkable— polygon currently being walked onStepDown / StepUpflagsWalkableAllowance— 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 descriptorCollisionInfo— results accumulator (ContactPlane, SlidingNormal)FindTransitionalPosition()— main entry point
TransitionState — per-step result:
OK— no collisionAdjusted— repositioned (step-up/down)Slid— sliding along surfaceCollided— 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:
- Read FUN_005387c0 (CTransition::find_collisions) from chunk_00530000.c
- Read the BSP traversal functions (0x539270, 0x53A550, 0x53A6A0)
- Write pseudocode in
docs/research/transition_pseudocode.md - Cross-reference ACE's
Transition.csfor naming - 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:
- Compute which cell(s) the entity's bounding sphere overlaps
- Register a ShadowObject entry in each cell's list
- 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:
- Broad phase: does player bounding sphere overlap object bounding sphere?
- 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
- If object has
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)