docs: comprehensive architecture plan for acdream

The single most important document in the project. Defines:

Architecture: 6-layer stack (Platform → Renderer → Network → World →
Game Objects → Plugin API). The code is modern C#; the behavior
matches the retail client exactly.

GameEntity: the unified entity class that replaces the current
scattered state (WorldEntity + AnimatedEntity + guid dicts + player
controller). Every world object is a GameEntity with PhysicsBody +
AnimationSequencer + CellTracker + MotionInterpreter + AppearanceState.

Per-frame update order: Network → Streaming → Input → Entity tick
(motion → physics → collision → cell → animation) → Render → Plugin.

Execution plan (R1-R8):
  R1: GameEntity refactor (unify scattered state)
  R2: Thin GameWindow (extract to proper systems)
  R3: CellBSP + wall collision (indoor transitions)
  R4: Complete animation state machine
  R5: Lighting from decompiled AdjustPlanes
  R6: Server compliance (authoritative Z, keepalive)
  R7: Interaction (doors, NPCs, chat, inventory)
  R8: Plugin API completion (Lua macros)

Also updates CLAUDE.md to establish the architect role and reference
the architecture doc as the single source of truth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 14:23:50 +02:00
parent a722c29759
commit adf626367e
3 changed files with 1199 additions and 13 deletions

View file

@ -0,0 +1,343 @@
# acdream — Comprehensive Architecture Plan
## Vision
A modern C# .NET 10 Asheron's Call client that:
- **Behaves identically to the retail client** — same physics, same
animations, same terrain, same collision, same network protocol
- **Looks identical to the retail client** — same meshes, same textures,
same lighting, same blending, rendered via modern Silk.NET OpenGL
- **Adds a plugin API** the retail client never had — native C# plugins
+ Lua macro scripting for player automation
- **Is NOT a 1:1 C++ port** — uses modern C# patterns (composition over
inheritance, interfaces, dependency injection) while matching retail
behavior exactly
## Guiding Principle
**The code is modern. The behavior is retail.**
Every AC-specific algorithm is ported faithfully from the decompiled retail
client (docs/research/decompiled/, 688K lines). The code AROUND those
algorithms is modern C# with clean architecture. The plugin API exposes
game state through well-defined interfaces that the retail client never had.
---
## Layer Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ LAYER 5: Plugin API │
│ IGameState, IEvents, IActions, IPacketPipeline, IOverlay │
│ Plugin host (ALC), Lua macro engine (MoonSharp) │
│ ► acdream-unique — not in retail client │
├──────────────────────────────────────────────────────────────┤
│ LAYER 4: Game Objects │
│ GameEntity (one per world object) │
│ ├── PhysicsBody (ported from decompiled) │
│ ├── AnimSequencer (ported from decompiled) │
│ ├── CellTracker (ported from decompiled) │
│ ├── AppearanceState (ObjDesc: palettes, textures, parts)│
│ └── MotionState (ported from decompiled) │
│ ► behavior matches retail, code is modern C# composition │
├──────────────────────────────────────────────────────────────┤
│ LAYER 3: World Systems │
│ TerrainSystem (heightmap, blending, scenery) │
│ CellSystem (LandCells, EnvCells, portals, BSP) │
│ StreamingSystem (background loading, LOD, frustum cull) │
│ ► behavior matches retail, streaming is acdream-unique │
├──────────────────────────────────────────────────────────────┤
│ LAYER 2: Network │
│ WorldSession (ISAAC, fragments, game messages) │
│ MessageRouter (opcode dispatch, sequence tracking) │
│ ► wire-format identical to retail │
├──────────────────────────────────────────────────────────────┤
│ LAYER 1: Renderer │
│ Silk.NET OpenGL 4.3 core profile │
│ TerrainRenderer, StaticMeshRenderer, TextureCache │
│ Shaders (terrain blending, mesh lighting, translucency) │
│ ► completely different from retail (D3D7), same visual │
│ output │
├──────────────────────────────────────────────────────────────┤
│ LAYER 0: Platform │
│ .NET 10, Silk.NET window/input, DatReaderWriter │
│ ► acdream-unique infrastructure │
└──────────────────────────────────────────────────────────────┘
```
---
## Project Structure (target)
```
src/
AcDream.Core/ Layer 2-4: no GL, no Silk.NET, pure logic
Physics/
PhysicsBody.cs ← ported from decompiled (done)
CollisionPrimitives.cs ← ported from decompiled (done)
MotionInterpreter.cs ← ported from decompiled (done)
AnimationSequencer.cs ← ported from decompiled (done)
CellBsp.cs ← TODO: port from decompiled
Transition.cs ← TODO: port from decompiled
TerrainSurface.cs ← verified against ACME (done)
World/
GameEntity.cs ← TODO: unified entity (replaces scattered state)
WorldState.cs ← TODO: owns all entities
CellTracker.cs ← TODO: per-entity cell management
SceneryGenerator.cs ← verified against decompiled (done)
LandblockLoader.cs ← done
Terrain/
LandblockMesh.cs ← verified against ACME (done)
TerrainBlending.cs ← verified against ACME (done)
Meshing/
GfxObjMesh.cs ← cross-checked against ACME (done)
SetupMesh.cs ← cross-checked (done)
Textures/
SurfaceDecoder.cs ← done
Dat/
MotionResolver.cs ← done (move here from Meshing/)
AcDream.Core.Net/ Layer 2: networking
WorldSession.cs ← done (wire-compatible with ACE)
NetClient.cs ← done
Messages/ ← done (CreateObject, MoveToState, etc.)
AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces
IAcDreamPlugin.cs ← done
IPluginHost.cs ← done
IGameState.cs ← done
IEvents.cs ← done
AcDream.App/ Layer 1 + Layer 4 wiring
Rendering/
GameWindow.cs ← TODO: thin down to GL calls only
TerrainRenderer.cs ← done
StaticMeshRenderer.cs ← done
TextureCache.cs ← done
ChaseCamera.cs ← done
FlyCamera.cs ← done
Streaming/
StreamingController.cs ← done
GpuWorldState.cs ← done
Input/
PlayerMovementController.cs ← done (uses ported physics)
Plugins/
AppPluginHost.cs ← done
```
---
## GameEntity: The Unified Entity (TODO — the big refactor)
Currently, entity state is scattered across:
- `WorldEntity` (position, rotation, mesh refs)
- `AnimatedEntity` (animation frame, setup, sequencer)
- `_entitiesByServerGuid` dict (server GUID lookup)
- `GpuWorldState._loaded[lb].Entities` (per-landblock lists)
- `_playerController` (player-specific movement)
This should become ONE class:
```csharp
public sealed class GameEntity
{
// Identity
public uint ServerGuid { get; }
public uint SetupId { get; }
public string? Name { get; }
// Spatial (ported from CPhysicsObj)
public PhysicsBody Physics { get; } // position, velocity, gravity
public CellTracker Cell { get; } // which cell we're in
// Appearance (ported from CPartArray)
public AnimationSequencer Animation { get; } // frame playback
public AppearanceState Appearance { get; } // ObjDesc overrides
// Motion (ported from CMotionInterp)
public MotionInterpreter Motion { get; } // walk/run/turn state
// Render output (consumed by StaticMeshRenderer)
public IReadOnlyList<MeshRef> MeshRefs { get; }
// Per-frame update (matches retail update_object)
public void Update(float dt)
{
Motion.ApplyCurrentMovement(); // set velocity from motion state
Physics.UpdateObject(dt); // integrate position
// TODO: Transition.FindValidPosition // collision resolve
Cell.UpdateCell(Physics.Position); // check cell transitions
Animation.Advance(dt); // advance animation frames
RebuildMeshRefs(); // compute per-part transforms
}
}
```
Every entity in the world — player, NPC, monster, lifestone, door, chest —
is a `GameEntity`. The renderer iterates them and draws. The plugin API
exposes them as `WorldEntitySnapshot`. GameWindow becomes thin.
---
## Per-Frame Update Order (matches retail)
```
1. Network tick
└── Drain inbound queue → process CreateObject, UpdateMotion,
UpdatePosition, PlayerTeleport → create/update GameEntities
2. Streaming tick
└── Compute observer position → load/unload landblocks →
create terrain + scenery GameEntities
3. Input tick (player mode only)
└── Read WASD/mouse → MotionInterpreter.DoMotion →
send MoveToState/AutonomousPosition to server
4. Entity tick (ALL entities, 30Hz fixed step)
└── For each GameEntity: entity.Update(dt)
This runs: motion → physics → collision → cell → animation
5. Render tick
└── For each GameEntity: read MeshRefs, draw
TerrainRenderer.Draw, StaticMeshRenderer.Draw
(frustum cull, translucency pass, etc.)
6. Plugin tick
└── Fire IEvents, drain IActions queue
```
---
## Execution Plan: How to Get There
### Phase R1: GameEntity Refactor (the foundation)
**Goal:** Replace the scattered entity state with unified GameEntity.
1. Create `GameEntity` class in `AcDream.Core/World/`
2. Move `AnimatedEntity` fields into `GameEntity.Animation`
3. Move `WorldEntity` fields into `GameEntity.Physics` + position
4. Move `_entitiesByServerGuid` into `WorldState`
5. Move animation tick from `GameWindow.TickAnimations` into `GameEntity.Update`
6. GameWindow.OnRender reads `GameEntity.MeshRefs` instead of `WorldEntity.MeshRefs`
**Test:** Everything looks the same as before. No visual change.
### Phase R2: Thin GameWindow
**Goal:** GameWindow does only GL calls + input dispatch.
1. Extract entity creation from `OnLiveEntitySpawned` into `WorldState.SpawnEntity`
2. Extract motion updates from `OnLiveMotionUpdated` into `WorldState.UpdateMotion`
3. Extract player movement from the giant OnUpdate block into `PlayerController`
4. GameWindow.OnUpdate calls: network.Tick → streaming.Tick → input.Tick → worldState.Tick → render
**Test:** Everything works the same. GameWindow.cs drops from 2000+ to ~500 lines.
### Phase R3: CellBSP + Wall Collision
**Goal:** Entities can't walk through walls.
1. Port CellBSP from decompiled code (sphere_intersects_cell)
2. Port Transition.FindValidPosition (swept sphere collision)
3. Wire into GameEntity.Update between physics and cell tracking
4. Indoor transitions become correct (wall stops you, doorway lets you through)
**Test:** Walk into building wall → stopped. Walk through doorway → enter.
### Phase R4: Complete Animation State Machine
**Goal:** Every animation works for every entity type.
1. Port full MotionInterp.PerformMovement from decompiled (all 5 movement types)
2. Port Links table resolution for smooth transitions
3. Port idle modifiers (fidgets)
4. Jump animation (wire jump motion command through the pipeline)
**Test:** All entity types animate correctly. Transitions are smooth.
### Phase R5: Lighting from Retail
**Goal:** Sun, ambient, per-vertex lighting match retail.
1. Port AdjustPlanes (FUN_00532440) — face normals + per-vertex lighting
2. Extract global lighting constants from decompiled DAT addresses
3. Replace hardcoded shader constants with ported values
**Test:** Side-by-side with retail client shows matching lighting.
### Phase R6: Server Compliance
**Goal:** ACE accepts all movement, no rubber-banding.
1. Server-authoritative Z (trust server position, local is cosmetic)
2. Proper MoveToState with full RawMotionState packing
3. Keepalive ping (5s idle)
4. Graceful session management
**Test:** Walk around, other clients see smooth movement. No ACE errors.
### Phase R7: Interaction
**Goal:** Click NPCs, open doors, pick up items, chat.
1. Use/UseWithTarget game actions
2. Door open animation (server sends UpdateMotion → animate)
3. Chat send/receive
4. Basic inventory (pickup/drop)
**Test:** Open a door, talk to an NPC, send a chat message.
### Phase R8: Plugin API Completion
**Goal:** Plugins can observe and control everything.
1. IGameState exposes all GameEntity fields
2. IEvents fires for all world changes
3. IActions covers: Move, Cast, Use, Say, Pickup, Drop
4. IPacketPipeline hooks all 4 stages
5. Lua macro engine (MoonSharp) ships as a built-in plugin
**Test:** A Lua script auto-loots gems. A C# plugin displays an overlay.
---
## Development Workflow (mandatory for ALL work)
```
For every AC-specific behavior:
1. DECOMPILE → Find the function in docs/research/decompiled/
2. CROSS-CHECK → Verify against ACE + ACME + holtburger
3. PSEUDOCODE → Translate to readable pseudocode
4. PORT → Faithful C# translation
5. TEST → Conformance test against decompiled golden values
6. INTEGRATE → Surgical wiring into the existing system
7. VERIFY → Visual + functional test
```
For acdream-specific code (renderer, plugin API, streaming):
- Design for clean interfaces
- Test independently
- No AC-specific magic — those live in the ported layer
---
## Reference Hierarchy
| Domain | Primary Oracle | Secondary |
|--------|---------------|-----------|
| Physics/collision | Decompiled acclient.exe | ACE Physics/ |
| Animation | Decompiled + ACE Animation/ | — |
| Terrain | ACME ClientReference.cs | Decompiled |
| Rendering | WorldBuilder (Silk.NET) | ACViewer |
| Protocol | holtburger | AC2D |
| Server behavior | ACE | — |
---
## Success Criteria
The client is "done" when:
1. You can log in to an ACE server
2. Walk around the entire world (streaming loads new areas)
3. Enter and exit buildings through doorways
4. See all NPCs, monsters, and players animated correctly
5. Open doors, talk to NPCs, pick up items
6. Send and receive chat
7. A Lua macro can automate gameplay
8. Side-by-side with the retail client, the world looks the same

View file

@ -0,0 +1,832 @@
# AC Client — Complete Architecture Map
**Sources used:**
- `docs/research/acclient_function_map.md` — 70+ decompiled function addresses
- `docs/research/acclient_animation_pseudocode.md` — full animation system pseudocode
- `docs/research/2026-04-12-movement-deep-dive.md` — movement cross-reference
- `references/ACE/Source/ACE.Server/Physics/` — ACE's C# physics port (read directly)
---
## System Overview (dependency graph)
```
PhysicsEngine
└─ iterates PhysicsObj list → update_object()
PhysicsObj (root entity)
├─ Position (ObjCellID + AFrame)
├─ CurCell (ObjCell ptr)
├─ CurLandblock (Landblock ptr)
├─ PartArray (skeleton + animation)
│ ├─ Setup (geometry: GfxObj parts, spheres, BSP)
│ ├─ Sequence (animation playback state machine)
│ └─ MotionTableManager (transition resolver)
├─ MovementManager
│ ├─ MotionInterp (raw→interpreted motion state machine)
│ └─ MoveToManager (pathfinding / move-to target)
├─ PositionManager (sticky/constraint)
├─ WeenieObject (game-logic bridge — callbacks into WorldObject)
├─ Children / Parent (attachment hierarchy)
└─ ShadowObjects (multi-cell presence)
Cell hierarchy (ObjCell subtypes):
ObjCell (abstract base)
├─ SortCell (has a BuildingObj for building collision)
│ └─ LandCell (outdoor cell: 2 terrain polygons, water)
└─ EnvCell (indoor/dungeon cell: portals, static objs, visibility list)
Terrain hierarchy:
LandblockStruct (raw height/terrain arrays, polygon list, SWtoNEcut flags)
└─ Landblock (8x8 grid of LandCells, static object list)
└─ LandCell[64] (per-cell collision polygon pair)
```
---
## 1. PhysicsObj — Root Physics Entity
**ACE file:** `PhysicsObj.cs`
**Decompiled:** `chunk_00510000.c`, `chunk_00500000.c` — base at 0x510000
### Data owned
| Field | Type | Decompiled offset | Purpose |
|-------|------|-------------------|---------|
| ID | uint | — | Object identity (GUID) |
| Position | Position | — | ObjCellID + AFrame (origin + quaternion) |
| CurCell | ObjCell | — | Current home cell |
| CurLandblock | Landblock | — | Current landblock |
| State | PhysicsState | +0xA8 | Bitmask: Static, Hidden, Gravity, Ethereal, HasPhysicsBSP, Missile, Frozen... |
| TransientState | TransientStateFlags | +0xAC | Contact, OnWalkable, Sliding, Active, CheckEthereal... |
| Elasticity | float | +0xB0 | Bounce coefficient |
| UpdateTime | double | +0xD8 | LastUpdateTime for delta accumulation |
| Velocity | Vector3 | +0xE0/E4/E8 | World-space velocity (m/s) |
| Acceleration | Vector3 | +0xEC/F0/F4 | World-space acceleration (gravity = -9.8 Z when Gravity flag set) |
| Omega | Vector3 | +0xF8/FC/100 | Angular velocity |
| WeenieObject | ptr | +0x12C | Callback bridge into game logic |
| ContactPlane | Plane | — | Ground contact plane from last collision |
| SlidingNormal | Vector3 | — | Surface normal during slide |
| CachedVelocity | Vector3 | — | Velocity between position samples (for rendering) |
| PartArray | PartArray | — | Skeleton and animation state |
| MovementManager | MovementManager | — | Owns MotionInterp + MoveToManager |
| Children / Parent | ChildList / PhysicsObj | — | Attachment tree |
| ShadowObjects | Dict<uint, ShadowObj> | — | Per-cell presence records |
| CollisionTable | Dict<uint, CollisionRecord> | — | Active obj-obj collision records |
| Scale | float | — | Uniform scale factor |
### Behavior (key methods)
| Method | Address | What it does |
|--------|---------|--------------|
| `update_object()` | 0x515020 | Per-frame entry: computes deltaTime, calls `UpdateObjectInternal` in fixed-step loop (MaxQuantum slices) |
| `UpdateObjectInternal(quantum)` | — | Calls `UpdatePositionInternal``transition()``SetPositionInternal`. Also calls `PartArray.HandleMovement`, `MovementManager.UseTime`, `PositionManager.UseTime` |
| `UpdatePositionInternal(quantum)` | 0x513730 | Calls `PartArray.Update` (animation frame advance) → `PositionManager.AdjustOffset` → builds `newFrame` → calls `UpdatePhysicsInternal` |
| `UpdatePhysicsInternal(quantum)` | 0x5111D0 | Euler: `pos += vel*dt + 0.5*accel*dt²`; applies friction; clamps max velocity (50.0) |
| `calc_acceleration()` | 0x511420 | Sets gravity (-9.8 Z) when Gravity flag set |
| `set_velocity()` | 0x511EC0 | Stores velocity, clamps to MaxVelocity |
| `set_local_velocity()` | 0x511FA0 | Body→world transform then set_velocity |
| `set_on_walkable()` | 0x511DE0 | Sets/clears OnWalkable transient flag |
| `report_collision_start()` | 0x511560 | Fires WeenieObj.HandleEnvironmentCollision |
| `report_collision_end()` | 0x513AC0 | Fires WeenieObj.HandleCollisionEnd |
| `handle_obj_collision()` | 0x513B60 | Obj-obj dispatch → WeenieObj.HandleCollisionStart or HandleMissileCollision |
| `handle_collision()` | 0x515280 | Elasticity bounce response |
| `update_animation()` | — | Animation-only update path (for entities that animate but don't move) |
| `UpdateAnimationInternal(quantum)` | 0x5111D0 area | `PartArray.Update(quantum)``set_frame()``PartArray.HandleMovement()` |
| `DestroyObject()` | — | `leave_cell``remove_shadows_from_cells``leave_world``exit_world` |
| `DoMotion(motion, params)` | — | Routes through `MovementManager.PerformMovement` |
| `DoInterpretedMotion(motion, params)` | — | Routes through `PartArray.DoInterpretedMotion` |
### Dependencies
- Calls INTO: `PartArray`, `MovementManager`, `PositionManager`, `WeenieObject` (vtable callbacks), `ObjCell` (via transition), `Transition`
- Called BY: `PhysicsEngine.update()` (top-level loop)
---
## 2. PartArray — Skeleton + Animation Frame State
**ACE file:** `PartArray.cs`
**Decompiled:** (embedded within CPhysicsObj chunk)
### Data owned
| Field | Type | Purpose |
|-------|------|---------|
| State | uint | Bitmask (HasPhysicsBSP = 0x10000) |
| Owner | PhysicsObj | Back-pointer |
| Sequence | Sequence | Animation playback state machine |
| MotionTableManager | MotionTableManager | Transition resolver (wraps MotionTable) |
| Setup | Setup | Dat resource: GfxObj parts, sphere collision shapes, BSP |
| NumParts | int | Part count |
| Parts | List\<PhysicsPart\> | Individual mesh parts (each has a Position) |
| Scale | Vector3 | Part-level scale |
| LastAnimFrame | AnimationFrame | Most recently computed pose |
### Behavior
| Method | What it does |
|--------|--------------|
| `CreateMesh(owner, setupDID)` | Factory: loads Setup from dat, creates parts, sets placement frame 0x65 |
| `CreateSetup(owner, setupDID, createParts)` | Full creature setup factory |
| `Update(quantum, ref offsetFrame)` | Calls `Sequence.Update(quantum, ref offsetFrame)` — advances animation, accumulates velocity |
| `SetFrame(frame)` | Applies AFrame to all Parts |
| `HandleMovement()` | Calls `MotionTableManager.CheckForCompletedMotions()` |
| `DoInterpretedMotion(motion, params)` | Routes into `MotionTableManager.DoInterpretedMotion` |
| `AddPartsShadow(cell, count)` | Registers all parts into a shadow cell (multi-cell presence) |
| `AnimationDone(success)` | Notifies MotionTableManager that a one-shot completed |
### Three PartArray creation factories
1. `CreateMesh` — simple static mesh (no animation MotionTableManager)
2. `CreateParticle` — particle emitter (special Setup, no skeleton)
3. `CreateSetup` — full creature (has MotionTableManager + Sequence)
---
## 3. Sequence — Animation Playback State Machine
**ACE file:** `Animation/Sequence.cs`
**Decompiled struct:** `chunk_00520000.c` (FUN_00526110, FUN_005261D0, FUN_00525EB0)
### Data owned
```
Sequence {
AnimList LinkedList<AnimSequenceNode> // pending animation queue
FirstCyclic LinkedListNode<AnimSequenceNode> // marks start of looping section
CurrAnim LinkedListNode<AnimSequenceNode> // currently playing node
Velocity Vector3 // accumulated linear velocity from animation
Omega Vector3 // accumulated angular velocity
HookObj PhysicsObj // entity to fire events on
FrameNumber float // current fractional frame position
PlacementFrame AnimationFrame // static pose when no animation active
PlacementFrameID int
IsTrivial bool // optimization: skip update if idle
}
```
Decompiled layout (raw offsets in AnimNode / Sequence structs):
```
Sequence +0x04 = listHead (AnimNode* front)
Sequence +0x08 = listTail (AnimNode* back)
Sequence +0x0C = current (currently playing ptr)
Sequence +0x10..0x24 = linearVelocity + angularVelocity (3+3 floats)
Sequence +0x28 = hasCallback
Sequence +0x30 = framePosition (double)
Sequence +0x38 = activeNode
Sequence +0x3C = overrideFrame
```
### AnimSequenceNode (ACE) / AnimNode (decompiled)
```
AnimNode (28 bytes = 0x1C) {
vtable* // AnimData vtable
next* // forward link in doubly-linked list
prev* // backward link
frameCount // total frames in this animation
speedScale // playback speed multiplier (negative = reverse)
startFrame // inclusive start (swap with endFrame when speedScale < 0)
endFrame // inclusive end
}
```
### Behavior (key methods)
| Method | Address | What it does |
|--------|---------|--------------|
| `Update(quantum, ref offsetFrame)` | — | Calls `update_internal(frameRate, ...)` |
| `update_internal(frameRate, ...)` | 0x5261D0 | Core per-frame loop: advance `framePosition` by `rate*dt`; fire frame-trigger events; call `advance_to_next_animation` on boundary |
| `advance_to_next_animation(...)` | 0x525EB0 | Pops exhausted node; loads next; seeds new `framePosition` from GetStartFramePosition |
| `AppendAnimation(node)` | 0x526110 | Push AnimNode onto pending list tail |
| `GetCurrAnimFrame()` | — | Returns `CurrAnim.get_part_frame(frameNumber)` or `PlacementFrame` |
| `clear_animations()` | — | Empties AnimList, resets CurrAnim |
| `clear_physics()` | — | Zeros Velocity + Omega |
| `is_first_cyclic()` | — | True if at or before the FirstCyclic marker (idle check) |
| `CombinePhysics(vel, omega)` | — | Adds to Velocity + Omega accumulators |
### Frame position arithmetic
- **Forward** (speedScale > 0): framePosition advances from `startFrame` toward `endFrame+1`
- **Reverse** (speedScale < 0): framePosition advances from `endFrame+1-epsilon` toward `startFrame`
- **Boundary**: when `floor(framePosition) > endFrame` (forward) or `< startFrame` (reverse), node is exhausted; `advance_to_next_animation` is called with remaining time
- **Events**: at each whole-frame crossing, frame-trigger events fire (`FireApproachEvent` forward, `FireLeaveEvent` reverse)
---
## 4. MotionInterp — Motion State Machine
**ACE file:** `Animation/MotionInterp.cs`
**Decompiled:** `chunk_00520000.c` (FUN_00528xxx, FUN_005293xx)
### Data owned
| Field | Type | Decompiled offset |
|-------|------|-------------------|
| WeenieObj | WeenieObject | +0x04 |
| PhysicsObj | PhysicsObj | +0x08 |
| RawState | RawMotionState | +0x14 — wire format: ForwardCmd, SidestepCmd, TurnCmd, speeds, HoldKey |
| InterpretedState | InterpretedMotionState | +0x44 — resolved state after interpretation |
| CurrentSpeedFactor | float | — |
| StandingLongJump | bool | +0x70 |
| JumpExtent | float | +0x74 |
| MyRunRate | float | +0x7C |
| PendingMotions | LinkedList\<MotionNode\> | — |
### Constants
```
BackwardsFactor = 0.65
MaxSidestepRate = 3.0
RunAnimSpeed = 4.0
RunTurnFactor = 1.5
SidestepAnimSpeed = 1.25
SidestepFactor = 0.5
WalkAnimSpeed = 3.12
```
### Behavior
| Method | Address | What it does |
|--------|---------|--------------|
| `PerformMovement(mvs)` | 0x529A90 | Top-level dispatch (switch on MovementType 1-5) |
| `DoMotion(motion, params)` | 0x529930 | Raw motion command → adjust_motion → DoInterpretedMotion |
| `DoInterpretedMotion(motion, params)` | 0x528F70 | Core: contact_allows_move check → PhysicsObj.DoInterpretedMotion → add_to_queue → InterpretedState.ApplyMotion |
| `StopInterpretedMotion(motion, params)` | 0x529080 | Stop specific interpreted motion |
| `StopMotion(motion, params)` | 0x529140 | Stop specific raw motion |
| `StopCompletely()` | 0x528A50 | Reset to Ready/idle, clear all pending |
| `adjust_motion(motion, speed, holdKey)` | 0x5287F0 | Apply speed adjustments for run/walk/strafe |
| `apply_raw_movement()` | 0x5293F0 | Convert RawMotionState → InterpretedMotionState |
| `apply_current_movement()` | 0x529210 | Set physics velocity from InterpretedState |
| `get_state_velocity(motion)` | 0x528960 | Compute velocity for current motion (calls WeenieObj.InqRunRate) |
| `contact_allows_move(motion)` | 0x528DD0 | Slope angle check: can we move in this direction given terrain normal? |
| `jump(params)` | 0x529390 | Initiate jump sequence |
| `jump_is_allowed(extent)` | 0x528EC0 | Full jump permission check |
| `get_leave_ground_velocity()` | 0x528CD0 | Compute 3D launch vector |
| `LeaveGround()` | 0x529710 | Called when becoming airborne; resets jump state |
| `HitGround()` | 0x5296D0 | Landing handler; fires WeenieObj landing callbacks |
### Motion command bit flags (from PerformMovement — 0x523400)
```
Bit 31 set (int < 0): STANCE CHANGE transition to new stance/style
Bit 30 set (0x40000000): CYCLE MOTION — looping (Walk, Run, TurnLeft...)
Bit 28 set (0x10000000): LINKED MOTION — one-shot that links back to cycle
Bit 29 set (0x20000000): SUBSTATE ANIM — play a substate override
```
---
## 5. MotionTable / MotionTableManager — Animation Data + Transition Resolution
**ACE files:** `Animation/MotionTable.cs`, `Animation/MotionTableManager` (implicit in PartArray)
**Decompiled:** `chunk_00520000.c` (FUN_00521770, FUN_005231D0, FUN_005232B0, FUN_00523000, FUN_00523400)
### MotionTable data (loaded from dat resource 0x09xxxxxx)
```
MotionTable {
ID uint
DefaultStyle uint // default stance (e.g. NonCombat)
StyleDefaults Dict<style, defaultSubstate>
Cycles Dict<(style<<16)|substate, MotionData>
Modifiers Dict<motionId, MotionData>
Links Dict<style, Dict<substate, MotionData>>
}
```
Each `MotionData` (= `TransitionLink` in decompiled) contains:
- `animId` — dat animation resource to play
- `speedScale` — playback speed multiplier
- `startFrame`, `endFrame`, `frameCount`
- Linear/angular velocity of the animation
- Chain of AnimNode entries (for multi-step transitions)
### Transition lookup — `FindBestTransition` (0x5232B0)
```
1. Direct lookup: (fromStyle<<16 | substate) (toStyle or substate)
2. Speed-sensitive variant: same but uses exact speed matching
3. Fallback via defaultMotionId
4. Fallback via defaultTransMap
```
The composite hash key encodes both states in a single uint, looked up via `LookupTransitionNode` (0x5231D0).
### PerformMovement dispatch — `FUN_00523400`
```
1. Look up current animation ID for current state
2. Guard: already in this cycle? Early-out.
3. Branch on motion command type (stance/cycle/linked/substate):
STANCE CHANGE:
a. Find exit-substate transition → AddAnimationsToSequence
b. Find enter-new-stance transition → AddAnimationsToSequence
c. Update currentState
CYCLE MOTION:
a. Find exit-current-cycle transition (one-shot)
b. Find enter-new-cycle transition (sets FirstCyclic marker)
c. AddAnimationsToSequence for both
LINKED MOTION:
a. Find link animation
b. Append after current FirstCyclic marker
SUBSTATE ANIM:
a. Find substate animation
b. Append with override flag
```
`AddAnimationsToSequence` (0x523000):
- Applies linear/angular velocity from the TransitionLink to the Sequence
- Iterates the chain of AnimNode entries
- For each: `BuildTempAnimNode``Sequence.AppendAnimation`
---
## 6. Transition — Collision / Movement Resolution
**ACE file:** `Animation/Transition.cs` (note: namespace is Animation not Collision)
**Decompiled:** `chunk_00530000.c` (FUN_005384E0, FUN_005387C0, FUN_00538180)
### Data owned
```
Transition {
ObjectInfo ObjectInfo // what's moving (state flags: IsPlayer, IsViewer, Ethereal...)
SpherePath SpherePath // the swept sphere path from old→new position
CollisionInfo CollisionInfo // accumulates contact plane, sliding normal, hit objects
CellArray CellArray // cells the sphere path passes through
}
```
`SpherePath` tracks:
- `GlobalSphere[]` — world-space sphere(s) representing the object's collision volume
- `CheckPos` — current position being tested
- `CurPos` — resolved valid position
- `CurCell` — resolved cell
- `StepDown` — flag for step-down terrain contact
- `PlacementAllowsSliding` — whether sliding is allowed
`CollisionInfo` tracks:
- `ContactPlane` — ground/wall plane
- `SlidingNormal` — lateral deflection normal
- `CollidedWithEnvironment` — hit static geo
- `AddObject(obj, state)` — record object collision
### Key collision methods
| Method | Address | What it does |
|--------|---------|--------------|
| `FindValidPosition()` | — | Main loop: sweep sphere through cells, test polygon collision |
| `find_collisions()` | 0x5387C0 | Iterates CellArray, calls cell.FindCollisions on each |
| `collide_with_point()` | 0x538180 | Point collision handler |
| `AdjustOffset(offset)` | — | Projects movement vector along contact/sliding planes |
| `Sphere.slide_sphere()` | 0x538EB0 | Slide along a surface |
| `Sphere.land_on_sphere()` | 0x538F50 | Step down to surface |
| `Polygon.sphere_intersects_poly()` | 0x539500 | Sphere-polygon contact test |
| `Polygon.find_walkable_collision()` | 0x53A040 | Returns edge normal for walkable surface |
| `Polygon.find_time_of_collision()` | 0x539BA0/0x539DF0 | Ray-plane-polygon t (sphere/cylinder variants) |
### Transition outcomes
```
TransitionState {
Invalid = 0 // bad input
OK = 1 // no collision / passthrough
Collided = 2 // hit solid surface
Adjusted = 3 // position corrected (slide/step)
Slid = 4 // sliding along surface
}
```
---
## 7. LandCell / EnvCell / SortCell — Cell Management
### Class hierarchy
```
ObjCell (abstract base)
ID, WaterType, Pos, ObjectList, ShadowObjectList
ClipPlanes, VisibleCells, VoyeurTable
→ AddObject / RemoveObject
→ abstract FindCollisions(Transition)
→ abstract find_transit_cells(...)
SortCell : ObjCell
Building (BuildingObj — handles building collision + transit cell lookup)
→ FindCollisions: delegates to Building.find_building_collisions
LandCell : SortCell
Polygons List<Polygon> // always exactly 2 (the two terrain triangles)
InView bool
→ FindCollisions: FindEnvCollisions (terrain polygon) → base (building) → FindObjCollisions
→ FindEnvCollisions: find_terrain_poly → ValidateWalkable
EnvCell : ObjCell
CellStructure CellStruct // geometry from Environment dat
Portals List<CellPortal>
StaticObjectIDs List<uint>
StaticObjectFrames List<AFrame>
StaticObjects List<PhysicsObj>
VisibleCellIDs List<ushort>
VisibleCells Dict<uint, EnvCell>
Flags EnvCellFlags
SeenOutside bool
→ PostInit: build_visible_cells + init_static_objects
→ FindCollisions: CellStruct BSP collision
```
### ObjCell key fields
```
ID uint // cell ID: high 16 = landblock (0xXXYY), low 16 = cell index
// outdoor: 0x0001..0x0040 (64 outdoor cells per LB)
// indoor: 0x0100..0xFFFD (EnvCell IDs)
Pos Position // cell's own origin frame
ObjectList List<PhysicsObj> // entities currently in this cell
```
### Cell ID conventions
```
outdoor cell: (blockX << 24) | (blockY << 16) | cellIndex where cellIndex = 0x0001..0x0040
indoor cell: (blockX << 24) | (blockY << 16) | cellIndex where cellIndex = 0x0100..0xFFFD
special: 0xFFFF = "lost" / no cell
```
---
## 8. CLandBlock / CLandBlockStruct — Terrain
**ACE file:** `Common/LandblockStruct.cs`, `Common/Landblock.cs`
**Decompiled:** `chunk_00530000.c` (0x530690, 0x531780, 0x531D10, 0x532A50, 0x532EB0, 0x532D10)
### LandblockStruct (raw terrain data)
```
LandblockStruct {
ID uint
TransDir LandDefs.Direction
SideVertexCount, SidePolyCount, SideCellCount int
WaterType LandDefs.WaterType
Height List<byte> // 9×9 = 81 height values (0..127 → 0..63.5 meters)
Terrain List<ushort> // 8×8 = 64 terrain type codes (palCode)
VertexArray VertexArray // computed vertices (9×9 = 81 positions)
Polygons List<Polygon> // computed polygons (8×8×2 = 128 triangles)
SWtoNEcut List<bool> // per-cell split direction (64 entries)
LandCells ConcurrentDict<int, ObjCell> // 64 outdoor cells
}
```
**Key algorithms (decompiled addresses):**
| Method | Address | What it does |
|--------|---------|--------------|
| `IsSWtoNECut(x, y)` | 0x531D10 | Inner split test with 0xCCAC033 constants |
| `ConstructPolygons()` | 0x532A50 | Outer 8×8 loop using FSplitNESW formula |
| `GetCellRotation / ConstructUVs` | 0x532EB0 | PalCode computation for UV rotation |
| `unpack()` | 0x532D10 | Deserialize from dat stream |
| `get_packed_size()` | 0x531F10 | Returns 0xF4 (244 bytes) |
| `AdjustPlanes()` | 0x532440 | Normal accumulation + lighting |
| `CalcCellWater()` | 0x532290 | Water depth check |
**CRITICAL — terrain split formula:**
The CORRECT render formula (from AC2D, confirmed by ACME `ClientReference.cs`):
```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 coordinates)
// y = blockY * 8 + cellY
// true = NE/SW split (SW corner → NE corner diagonal)
```
### Landblock (manager)
```
Landblock {
// manages the 8×8 grid of outdoor LandCells
// static object list (from LandBlockInfo dat)
// links to neighbor landblocks for cross-boundary movement
}
```
**Key methods (decompiled):**
| Method | Address | What it does |
|--------|---------|--------------|
| `Init / constructor` | 0x530690 | Initialize landblock fields |
| `release_all()` | 0x5307E0 | Free all resources |
| `init_static_objs()` | 0x531780 | Load static objects from LandBlockInfo dat |
| `release_visible_cells()` | 0x531000 | Free cell data |
| `grab_visible_cells()` | 0x5301E0 | BFS neighbor expansion |
| `add_server_object()` | 0x530650 | Add entity to landblock |
---
## 9. Position / ObjCell — Coordinate System
**ACE file:** `Common/Position.cs`
### Position
```
Position {
ObjCellID uint // which cell this position is in
Frame AFrame // origin (Vector3) + orientation (Quaternion)
}
```
### AFrame
```
AFrame {
Origin Vector3 // landblock-local XYZ (meters within landblock = 192×192m area)
Orientation Quaternion // WXYZ quaternion
LocalToGlobal(pt) // rotate point by orientation, add origin
GlobalToLocal(pt) // inverse: subtract origin, inverse-rotate
Combine(a, b) // concatenate two frames
GRotate(omega*dt) // apply angular velocity
}
```
### Coordinate conventions
```
Landblock size: 192 meters × 192 meters (8×8 cells, each 24m × 24m)
Height range: 0..127 height units → 0..63.5 meters (scale × 0.5)
Cell terrain polygon: each cell has 2 triangles (SW or NE split)
Global origin: (0,0) = SW corner of landblock grid
AC heading: 0 = facing west (-X), PI/2 = north (+Y), PI = east (+X)
Gravity: Z-down, gravity = -9.8 m/s²
```
### LandDefs (coordinate utilities) (0x5AAA30 area)
| Method | Address | What it does |
|--------|---------|--------------|
| `get_vars()` | 0x5AAA30 | Set 8 coordinate constants |
| `get_outside_lcoord()` | 0x5AABB0 | Cell ID → world coord |
| `AdjustToOutside()` | 0x5AAC70 | Normalize position to outdoor coords |
| `get_block_dir()` | 0x5AAB50 | Quadrant → Direction enum |
---
## 10. WeenieObject — Game Logic Bridge
**ACE file:** `Common/WeenieObject.cs`
### Data owned
```
WeenieObject {
ID uint
UpdateTime double
WorldObjectInfo WorldObjectInfo // weak reference to WorldObject
IsMonster bool
IsCombatPet bool
Faction1Bits FactionBits
PlayerKillerStatus PlayerKillerStatus
}
```
### Vtable callbacks (called FROM physics INTO game logic)
| Vtable offset | ACE method | Called from physics when... |
|--------------|-----------|---------------------------|
| +0x30 | `InqJumpVelocity(extent)` | Computing jump Z velocity |
| +0x34 | `InqRunRate()` | Computing movement speed for entity |
| +0x3C | `CanJump(extent)` | Checking if jump is permitted |
| +0x4C | `HandleCollisionEnd(obj)` | Object collision ended |
| +0x50 | `HandleCollisionStart(obj)` | Object collision started |
| +0x54 | `HandleMissileCollision(obj)` | Missile hit entity |
| +0x58 | `HandleEnvironmentCollision(normal)` | Hit static geometry |
### Key predicates
```
IsPlayer() — ID in 0x50000001..0x5FFFFFFF
IsCreature() — has creature WorldObject
IsMonster — creature and not combat pet
IsPK() — PlayerKillerStatus == PK
```
---
## Per-Frame Update Order
### Client entity update (static/NPC — `update_animation` path)
```
1. PhysicsEngine.update()
└─ for each active PhysicsObj:
└─ update_animation()
├─ compute deltaTime = now - UpdateTime
├─ while deltaTime > MaxQuantum (0.034s):
│ └─ UpdateAnimationInternal(MaxQuantum)
│ ├─ PartArray.Update(quantum, ref newFrame)
│ │ └─ Sequence.update_internal(frameRate, ...)
│ │ ├─ advance framePosition by rate*dt
│ │ ├─ fire frame-trigger events
│ │ └─ advance_to_next_animation on boundary
│ ├─ set_frame(newFrame) // update all parts' positions
│ └─ PartArray.HandleMovement() // check completed motions
└─ UpdateTime = now
```
### Moving entity update (player — `update_object` path)
```
1. PhysicsEngine.update()
└─ for each active PhysicsObj:
└─ update_object()
├─ compute deltaTime
├─ while deltaTime > MaxQuantum:
│ └─ UpdateObjectInternal(quantum)
│ ├─ UpdatePositionInternal(quantum, ref newFrame)
│ │ ├─ PartArray.Update(quantum, ref offsetFrame) // animation → root motion
│ │ ├─ PositionManager.AdjustOffset(offsetFrame) // sticky/constraint
│ │ ├─ build newFrame = Combine(Position.Frame, offsetFrame)
│ │ └─ UpdatePhysicsInternal(quantum, ref newFrame) // Euler integration
│ │ ├─ pos += vel*dt + 0.5*accel*dt²
│ │ ├─ calc_friction
│ │ └─ vel += accel*dt
│ ├─ transition(oldPos, newPos) // collision resolve
│ │ ├─ build SpherePath (swept sphere)
│ │ ├─ find_transit_cells → CellArray
│ │ ├─ for each cell: cell.FindCollisions(transition)
│ │ │ ├─ LandCell: terrain polygon test → ValidateWalkable
│ │ │ ├─ SortCell/BuildingObj: building BSP test
│ │ │ └─ ObjCell: obj-obj collision
│ │ └─ AdjustOffset → clamp to contact/sliding planes
│ ├─ SetPositionInternal(transit) // apply resolved pos
│ │ ├─ change_cell if needed
│ │ └─ update ShadowObjects
│ ├─ MovementManager.UseTime() // motion interp tick
│ │ └─ MotionInterp.apply_current_movement()
│ │ └─ set_local_velocity from InterpretedState
│ ├─ PartArray.HandleMovement() // completed motion check
│ ├─ PositionManager.UseTime()
│ ├─ DetectionManager.CheckDetection() (server only)
│ └─ TargetManager.HandleTargetting() (server only)
└─ UpdateTime = now
2. Rendering (acdream-specific, AFTER physics):
└─ For each PhysicsObj:
├─ PartArray.Sequence.GetCurrAnimFrame() // get current pose
├─ apply bone transforms
└─ submit to GPU
```
### Animation-only entities (static NPCs, breathing creatures)
Use `update_animation` instead of `update_object`. Same inner loop but:
- No physics integration (no velocity/gravity)
- No collision testing
- No cell change detection
---
## Entity Lifecycle
### Creation (from CreateObject network message)
```
1. Network receives CreateObject (opcode 0xF745)
2. Parse PhysicsDesc → position, state flags, scale, motion table ID
3. Parse WeenieDesc → name, type, weenie class
4. PhysicsObj.Create()
├─ PartArray.CreateSetup(owner, setupDID) // load Setup dat resource
│ ├─ load GfxObj parts (mesh + textures)
│ ├─ load sphere/cylsphere collision shapes
│ └─ set MotionTableManager (loads MotionTable dat resource)
├─ set_initial_frame(position.Frame)
├─ enter_cell(position) // attach to ObjCell
│ ├─ CurCell = cell
│ └─ cell.AddObject(this)
├─ make_moveable() if not static // allocate MovementManager
│ └─ MovementManager.Init(this, weenieObj)
│ └─ MotionInterp.Init(this, weenieObj)
├─ set_default_script() if HasDefaultScript
├─ set_default_animation() if HasDefaultAnimation
└─ StartTimer() // seed UpdateTime
```
### Per-frame updates (see above)
### Motion state change (from UpdateMotion / local input)
```
Client input / network:
DoMotion(motion, params)
└─ MovementManager.PerformMovement(RawCommand, motion, params)
└─ MotionInterp.DoMotion(motion, params)
├─ adjust_motion (speed, hold key)
├─ DoInterpretedMotion(interpretedMotion, params)
│ ├─ contact_allows_move check
│ ├─ PhysicsObj.DoInterpretedMotion()
│ │ └─ PartArray.DoInterpretedMotion()
│ │ └─ MotionTableManager.GetObjectSequence()
│ │ └─ MotionTable.get_link() → AddAnimationsToSequence()
│ │ └─ Sequence.AppendAnimation(animNode)
│ ├─ add_to_queue(contextID, motion, jump_error)
│ └─ InterpretedState.ApplyMotion(motion, params)
└─ RawState.ApplyMotion(motion, params)
```
### Position update (from server UpdatePosition / AutonomousPosition)
```
Network: UpdatePosition (0xF748) or AutonomousPosition
→ SetPosition(newPos, SetPositionFlags)
├─ Validate cell ID
├─ AdjustPosition → find correct ObjCell
├─ ForceIntoCell or transition()
└─ update_position()
├─ set all part positions
└─ sync ShadowObjects
```
### Cell transition (crossing landblock/cell boundary)
```
During transition():
if transit.SpherePath.CurCell != CurCell:
change_cell(newCell)
├─ oldCell.RemoveObject(this)
├─ CurCell = newCell
├─ newCell.AddObject(this)
└─ (if crossing landblock boundary)
├─ CurLandblock.remove_entity(this)
└─ newLandblock.add_entity(this)
calc_cross_cells() // update ShadowObjects for multi-cell presence
```
### Destruction (from DeleteObject / leave world)
```
DestroyObject()
├─ leave_cell(false) // cell.RemoveObject + clear ShadowObjects
├─ remove_shadows_from_cells()
├─ leave_world() // notify landblock
├─ exit_world() // notify ObjMaint
└─ ObjMaint.DestroyObject()
```
---
## System Dependency Matrix
| System | Calls Into | Called By |
|--------|-----------|----------|
| PhysicsEngine | PhysicsObj.update_object / update_animation | Server tick loop |
| PhysicsObj | PartArray, MovementManager, PositionManager, WeenieObject, Transition, ObjCell | PhysicsEngine |
| PartArray | Sequence, MotionTableManager, Setup | PhysicsObj |
| Sequence | AnimSequenceNode (dat AnimData), frame trigger hooks | PartArray, MotionTableManager |
| MotionTableManager | MotionTable, Sequence.AppendAnimation | PartArray, MotionInterp |
| MotionTable | dat loader (DatLoader.FileTypes.MotionTable) | MotionTableManager |
| MotionInterp | PhysicsObj.DoInterpretedMotion, WeenieObject.InqRunRate | MovementManager |
| MovementManager | MotionInterp, MoveToManager | PhysicsObj |
| WeenieObject | WorldObject (game logic layer) | PhysicsObj (callbacks) |
| Transition | ObjCell.FindCollisions, Polygon, Sphere | PhysicsObj.UpdateObjectInternal |
| ObjCell/LandCell | Polygon, LandblockStruct | Transition, PhysicsObj.enter_cell |
| EnvCell | CellStruct (BSP), Portal list, static PhysicsObjs | Transition, Landblock |
| LandblockStruct | Polygon, VertexArray, LandDefs | LandCell, Landblock |
| LandDefs | (math utilities only) | LandblockStruct, Position, ObjCell |
| Position | AFrame | PhysicsObj, ObjCell, Transition |
---
## acdream Implementation Notes
### What acdream currently has vs. what it needs
| System | Status | Gap |
|--------|--------|-----|
| Position / AFrame | Complete | None known |
| LandblockStruct | Complete (render side) | Split formula may still use physics formula — verify against AC2D formula |
| Terrain Z sampling | Broken | Needs per-triangle barycentric (not bilinear) using AC2D FSplitNESW formula |
| Sequence | Integrated (AnimationSequencer) | Need to verify boundary handling matches decompiled update_internal |
| MotionTable | Integrated (SetCycle) | Need to verify transition chain building (AddAnimationsToSequence) |
| MotionInterp | Not ported | Need to port DoMotion/DoInterpretedMotion → SetCycle/SetLinkedMotion call chain |
| Transition / Collision | Partially ported (CollisionPrimitives, PhysicsBody) | Not yet wired into per-frame update_object |
| Cell management | LandCell / EnvCell exist | No runtime cell transition logic |
| WeenieObject callbacks | Not ported | Server handles this; client side is game state queries only |
| Physics integration | Not done | Euler integration + gravity missing from client |
| Movement deduplication | Broken | Sends MoveToState every frame instead of on state change |
| AutonomousPosition | Missing | Not sent |
| Sequence counters | Zeroed | Must extract from CreateObject and echo back |
### Priority for B.3 / next phase
1. **MotionInterp port** — DoMotion → apply_current_movement → set_local_velocity: this is what drives physics velocity from animation state
2. **Terrain FSplitNESW fix** — swap to AC2D render formula, add per-triangle Z sampling
3. **update_object per-frame loop** — wire Transition into position update (already have CollisionPrimitives)
4. **Cell transition** — change_cell / calc_cross_cells when moving between cells
5. **MoveToState deduplication** — send once per state change, not per frame
6. **AutonomousPosition heartbeat** — every 1 second
7. **Sequence counters** — extract from CreateObject messages