# 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` — 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` — collision cylinders - `Sphere: List` — 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 _gfxObjPhysics; // Per-CellStruct collision BSP (keyed by EnvCell dat ID) ConcurrentDictionary _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)