# Movement System Deep Dive — AC2D + holtburger Cross-Reference ## Executive Summary After exhaustive analysis of two working AC clients (AC2D in C++, holtburger in Rust) plus ACViewer's dual-formula terrain code, three critical findings reshape how acdream should handle movement: 1. **The server is authoritative for Z.** Neither AC2D nor holtburger does local terrain Z-snapping for the player character. AC2D sends movement keys to the server and receives authoritative positions back. Holtburger does client-side dead-reckoning for smooth interpolation but the server's `UpdatePosition` overrides it. 2. **AC has TWO different terrain split formulas.** ACViewer's codebase reveals a render-path formula (`0x0CCAC033`) and a physics-path formula (`214614067 / 1813693831`). acdream uses the physics formula for both rendering AND Z-sampling, which means our visual terrain doesn't match the real AC terrain mesh, causing Z mismatches. 3. **Movement is a state machine, not a position pump.** Both clients send "I'm now walking forward" (MoveToState, once) and then periodic position heartbeats (AutonomousPosition, every 1s). They do NOT send a new MoveToState every frame. The server controls the motion; the client reports its state changes. --- ## Part 1: Movement Architecture Comparison ### AC2D (C++ — simple, server-authoritative) ``` WASD pressed → bAnimUpdate = true ↓ Per-frame: if bAnimUpdate: 1. Compute iFB, iStrafe, iTurn from key states 2. Call SendAnimUpdate(iFB, iStrafe, iTurn, !bShift) 3. Call SetMoveVelocities(iFB*3.0, iStrafe*-1.0, iTurn*1.5) ↓ SendAnimUpdate builds 0xF61C packet: - Flags bitmask (0x04=forward, 0x20=strafe, 0x100=turn, 0x01=running) - Motion commands (0x45000005=forward, etc.) - Full stLocation (32 bytes: landblock + XYZ + quaternion) - stMoveInfo (8 bytes: numLogins, moveCount, numPortals, numOverride) ↓ Server processes movement, echoes back: - 0xF748 (position update) with authoritative position ↓ Client: CalcPosition.CalcFromLocation(&server_location) ``` **Key insight:** AC2D does local prediction (SetMoveVelocities + per-frame UpdatePosition), but the server's F748 response OVERRIDES the local prediction. The client never computes terrain Z — it uses whatever Z the server sends. **Local prediction velocities:** - Forward/backward: `iFB * 3.0` AC units/sec (before /240 render division) - Strafe: `iStrafe * -1.0` - Turn: `iTurn * 1.5` (radians/sec multiplied by PI/2) **ACK timing:** periodic every 2000ms (not per-packet). ### holtburger (Rust — sophisticated, dead-reckoning) ``` PlayerDriveIntent queued (Manual, Autonomous, or Stop) ↓ MovementSystem.tick() every 30ms: 1. Expire timed drives 2. Ingest queued intents 3. Match active drive: - Manual(state) → send MoveToState (deduped, only on state change) - Autonomous(delta) → compute wire state, send MoveToState - Stop → send stop MoveToState 4. Every 1 second: send AutonomousPosition heartbeat ↓ SpatialPhysics.solve() every 30ms: - For local player: advance position by desired_world_delta - For entities: integrate velocity + omega (dead reckoning) - Landblock transitions via coordinate math (no portal detection) ↓ Server sends UpdatePosition → hard override of local position Server sends AutonomousPosition → sync sequence counters ``` **Key insight:** holtburger does client-side position advancement (the SpatialPhysics solver) but it's purely dead-reckoning — linear extrapolation of velocity, no terrain sampling, no collision. The server is still authoritative. The client advances locally for smooth rendering and sends heartbeats so the server knows the client's view of its position. **MoveToState deduplication:** only sent when `current_intent != last_sent_intent`. Not sent every frame. Not sent every tick. **AutonomousPosition heartbeat:** every 1 second (constant `AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL`). **ACK timing:** per-packet (every received packet with sequence > 0 gets an ACK queued and piggybacked on the next outbound packet). --- ## Part 2: Critical Protocol Details ### Sequence Counters (from holtburger) Every MoveToState and AutonomousPosition carries 4 sequence counters: ``` instance_sequence: u16 // object instantiation epoch server_control_sequence: u16 // server motion control epoch teleport_sequence: u16 // teleport epoch force_position_sequence: u16 // rubber-band epoch ``` These are initialized from the server's CreateObject/PlayerCreate message and echoed back in every outbound movement packet. The server uses them to detect stale/reordered packets. If a packet arrives with an old teleport_sequence (from before the last teleport), the server ignores it. acdream currently sends 0 for all four counters. This is likely why ACE accepts our movement grudgingly — it can't detect staleness, so it accepts everything, but the server-side validation may be looser than intended. ### RawMotionState Wire Format (from holtburger) The 0xF61C (MoveToState) payload has a complex packed format: ``` packed_flags: u32 bits 0-10: flag bitmask (which optional fields follow) bits 11-31: command list length Optional fields (each conditional on a flag bit): [bit 0] current_hold_key: u32 (1=None, 2=Run) [bit 1] current_style: u32 (MotionStance, e.g. 0x8000003D) [bit 2] forward_command: u32 [bit 3] forward_hold_key: u32 [bit 4] forward_speed: f32 [bit 5] sidestep_command: u32 [bit 6] sidestep_hold_key: u32 [bit 7] sidestep_speed: f32 [bit 8] turn_command: u32 [bit 9] turn_hold_key: u32 [bit 10] turn_speed: f32 Command items array (8 bytes each): command: u16 packed_sequence: u16 speed: f32 ``` ### Speed Constants (from holtburger) | Movement | Speed | |----------|-------| | Walk forward | base_walk_forward_velocity from MotionTable | | Run forward | base_run_forward_velocity * run_rate_scalar (default 4.5) | | Backstep | 1.0 (fixed) | | Strafe | 1.0 (fixed) | | Turn (walk) | 1.0 rad/s | | Turn (run) | 1.5 rad/s | ### Heading Convention AC heading: 0 = facing west (-X), PI/2 = facing north (+Y). Velocity from heading: `(-cos(heading) * speed, sin(heading) * speed, 0)`. This is DIFFERENT from our current convention where 0 = facing east (+X). --- ## Part 3: Terrain Split Formula Discrepancy ### The Two Formulas **Render formula** (AC2D + ACViewer render path): ```cpp DWORD dw = x * y * 0x0CCAC033 - x * 0x421BE3BD + y * 0x6C1AC587 - 0x519B8F25; bool splitNESW = (dw & 0x80000000) != 0; ``` Where x, y are GLOBAL cell coordinates: `(blockX * 8 + cellX, blockY * 8 + cellY)`. **Physics formula** (ACViewer physics path + WorldBuilder): ```csharp uint seedA = globalX * 214614067; uint seedB = globalY * 1109124029; float splitDir = (seedA + 1813693831 - seedB - 1369149221) * 2.3283064e-10f; bool splitSEtoNW = splitDir >= 0.5f; ``` **acdream currently uses:** the physics formula (from WorldBuilder). **Impact:** The render mesh has triangles split along different diagonals than the physics mesh in some cells. When we sample terrain Z via bilinear interpolation (which doesn't account for split direction at all), the result can be above or below the actual triangle surface. ### The Correct Fix For acdream, we should use the RENDER formula for the terrain mesh (since that's what the player sees) and match it in the physics Z sampling. This means: 1. Replace `CalculateSplitDirection` with the AC2D formula for rendering. 2. Make `TerrainSurface.SampleZ` triangle-aware: given the split direction, determine which triangle the query point falls in, then do barycentric interpolation within that triangle (not bilinear across the whole cell). --- ## Part 4: What acdream Should Change ### Movement System Redesign **Current (broken):** - Send MoveToState every frame when state changes - Local physics engine computes terrain Z via bilinear interpolation - No AutonomousPosition heartbeat - No sequence counters in movement packets - Heading convention mismatch (0 = east, should be 0 = west) **Proposed (matching holtburger's pattern):** 1. Send MoveToState ONCE when motion state changes (start walking, stop, turn, change gait). Deduplicate — if the intent hasn't changed, don't send. 2. Send AutonomousPosition every 1 second while moving. Include the server-echoed Z (not locally computed Z). 3. Track and include the 4 sequence counters in every movement packet. 4. Use server-sent Z from the last UpdatePosition/AutonomousPosition echo as the authoritative ground truth. Client-side Z is cosmetic only (for smooth interpolation between server updates). 5. Fix heading convention: 0 = west, PI/2 = north. 6. Keep local prediction for smooth rendering but let server override. ### Terrain Rendering Fix 1. Replace `CalculateSplitDirection` with the AC2D render formula. 2. Update `TerrainSurface.SampleZ` to do per-triangle barycentric Z interpolation using the render split direction. ### ACK Pattern Fix Current: per-packet ACK (correct, matches holtburger). Also add: 5-second keepalive ping (from holtburger) to prevent idle timeout. --- ## Part 5: Implementation Priority 1. **Fix terrain split formula** — use AC2D's 0x0CCAC033 render formula. This alone may fix the slope Z clipping because the terrain mesh and Z sampler will agree on triangle boundaries. 2. **Triangle-aware Z sampling** — replace bilinear interpolation with barycentric interpolation within the correct triangle. This eliminates the fundamental mismatch between the visual terrain and the physics Z. 3. **Server-authoritative Z** — stop fighting the local Z computation. Use the server's Z for the player's authoritative position and only use local Z for cosmetic smoothing between server updates. 4. **MoveToState deduplication** — send once per state change, not per frame. 5. **AutonomousPosition heartbeat** — every 1 second. 6. **Sequence counters** — extract from CreateObject/UpdatePosition and echo back in movement packets. 7. **Heading convention** — fix to AC standard (0 = west). 8. **Keepalive ping** — every 5 seconds of idle. --- ## Appendix A: Wire Format Reference ### 0xF61C — MoveToState (AC2D format, simpler) ``` DWORD opcode = 0xF61C DWORD flags [flag-dependent motion commands and speeds] stLocation (32 bytes) stMoveInfo (8 bytes): numLogins, moveCount, numPortals, numOverride DWORD 1 (unknown constant) ``` ### 0xF753 — AutonomousPosition ``` DWORD opcode = 0xF753 stLocation (32 bytes) stMoveInfo (8 bytes) DWORD 1 ``` ### stLocation (32 bytes) ``` DWORD landblock (XXYY + cell in low 16 bits) float x, y, z (local coords within landblock) float w, a, b, c (quaternion: w=cos, a=x, b=y, c=z) ``` ### stMoveInfo (8 bytes) ``` WORD numLogins (character login count) WORD moveCount (animation sequence from last F74C) WORD numPortals (portal transition count) WORD numOverride (force position count) ``` --- ## Appendix B: AC2D FSplitNESW (the CORRECT render formula) ```cpp bool FSplitNESW(DWORD x, DWORD y) { DWORD dw = x * y * 0x0CCAC033 - x * 0x421BE3BD + y * 0x6C1AC587 - 0x519B8F25; return (dw & 0x80000000) != 0; } // x = blockX * 8 + cellX (global cell X) // y = blockY * 8 + cellY (global cell Y) // true = NE/SW split, false = NW/SE split ``` ## Appendix C: holtburger Heading Convention ```rust // heading = 0 → facing west (-X) // heading = PI/2 → facing north (+Y) // heading = PI → facing east (+X) // heading = 3*PI/2 → facing south (-Y) fn planar_velocity_for_heading(heading: f32, speed: f32) -> Vector3 { Vector3::new(-heading.cos() * speed, heading.sin() * speed, 0.0) } ```