From b5e21abe1b7a36483257a891d04c1cd2b7b50623 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Apr 2026 23:01:18 +0200 Subject: [PATCH] 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) --- .../2026-04-13-movement-completion-design.md | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-13-movement-completion-design.md diff --git a/docs/superpowers/specs/2026-04-13-movement-completion-design.md b/docs/superpowers/specs/2026-04-13-movement-completion-design.md new file mode 100644 index 0000000..af07d52 --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-movement-completion-design.md @@ -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` — 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)