# 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)
}
```
