From 5cd776914a928cc4f86530e45f6e8d99a7c1487d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 21:52:12 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20movement=20deep=20dive=20=E2=80=94=20AC?= =?UTF-8?q?2D=20+=20holtburger=20cross-reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 10 + .../research/2026-04-12-movement-deep-dive.md | 333 ++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 docs/research/2026-04-12-movement-deep-dive.md diff --git a/CLAUDE.md b/CLAUDE.md index 2ac14f0..2dc8f45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,6 +182,16 @@ these, ideally all four: the message-builder layer. ACE shows what the server expects; holtburger shows what a real client actually sends. +- **`references/AC2D/`** — **C++ AC client emulator.** Oldest reference, + fixed-function OpenGL, but has the **real AC terrain split formula** + (`FSplitNESW` with constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`, + `0x519B8F25`) which differs from WorldBuilder's physics-path formula. + Also has the complete `0xF61C` movement packet format with flag bits + and the `stMoveInfo` sequence counters. Key lesson from AC2D: it does + NOT do client-side terrain Z — it sends movement keys to the server + and uses the server's authoritative Z. See + `docs/research/2026-04-12-movement-deep-dive.md` for the full analysis. + Pattern: when you encounter an unknown behavior, grep all four for the relevant term, read each hit, and compose a multi-source understanding BEFORE writing acdream code. A single reference can be misleading; the diff --git a/docs/research/2026-04-12-movement-deep-dive.md b/docs/research/2026-04-12-movement-deep-dive.md new file mode 100644 index 0000000..df5cd2a --- /dev/null +++ b/docs/research/2026-04-12-movement-deep-dive.md @@ -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) +} +```