docs: movement deep dive — AC2D + holtburger cross-reference
Exhaustive analysis of two working AC clients revealing three critical findings that reshape acdream's movement system: 1. Server-authoritative Z: neither AC2D nor holtburger computes local terrain Z for the player. AC2D sends keys, receives position. Holtburger dead-reckons for smoothing but the server overrides. 2. Terrain split formula mismatch: AC2D and ACViewer's render path use 0x0CCAC033-based FSplitNESW; WorldBuilder (our source) uses a different 214614067-based physics formula. Our terrain mesh triangulation doesn't match the real AC client's, causing Z mismatches on slopes. 3. Movement deduplication: MoveToState sent once per state change, not per frame. AutonomousPosition heartbeat every 1 second. Also adds AC2D to CLAUDE.md reference repos section. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
29fe0c0714
commit
5cd776914a
2 changed files with 343 additions and 0 deletions
333
docs/research/2026-04-12-movement-deep-dive.md
Normal file
333
docs/research/2026-04-12-movement-deep-dive.md
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
# 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)
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue