Most scenery objects (trees, rocks) use CylSphere collision from
their Setup, not PhysicsBSP. Register these in ShadowObjectRegistry
with a Cylinder collision type. FindObjCollisions now handles both:
- BSP: full polygon collision via BSPQuery (buildings, stabs)
- Cylinder: radial + vertical cylinder-sphere test (trees, NPCs)
Diagnostics showed 170 CylSphere entities vs 278 BSP entities in
the Holtburg landblock alone — this roughly doubles collision coverage.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace simplified BSP overlap test with retail-faithful 6-path
collision dispatcher. Sphere-intersects-poly now uses movement
vector for front-face culling (prevents wall penetration).
All paths: placement/ethereal, checkWalkable, stepDown, collide,
contact+onWalkable, and default (not in contact).
Ported from ACE BSPTree.cs/BSPNode.cs/BSPLeaf.cs/Polygon.cs,
cross-referenced against decompiled chunk_00530000.c.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace simplified push-out with retail-faithful SlideSphere and
AdjustOffset from transition_pseudocode.md. Crease-projection between
collision normal and contact plane produces smooth wall-sliding.
Object collision uses proper rotation transform to object-local space.
SlideSphere (section 6): computes crease direction via cross product
of collision normal and contact plane normal, projects displacement
onto the crease, then applies the correction offset. Handles three
cases: crease exists, parallel same-direction, parallel opposing.
AdjustOffset (section 6): adds safety check to keep sphere above
contact plane by computing signed distance and pushing up along Z
when the sphere dips below.
FindObjCollisions: removes ad-hoc penetration push-out, now calls
SlideSphere after BSP hit detection for proper wall-slide behavior.
Also fixes: ShadowEntry gains Rotation field, tests updated to match
Register signature, unused variables removed from GameWindow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Register static entities into terrain cells during streaming.
Transition system queries nearby objects and runs BSP collision.
Player can no longer walk through trees and buildings.
- ShadowObjectRegistry: 24m×24m cell index, Register/Deregister/
RemoveLandblock/GetNearbyObjects matching retail AC's approach
- PhysicsEngine: ShadowObjects property + DataCache wiring point;
RemoveLandblock now also clears shadow objects; TryGetLandblockContext
helper lets Transition resolve landblock id+offset for a world pos
- Transition.FindObjCollisions: queries registry, broad-phase sphere test,
narrow-phase BSPQuery.SphereIntersectsPoly in object-local space,
returns Slid on hit to redirect movement along the surface
- GameWindow.ApplyLoadedTerrainLocked: registers each static entity after
physics BSP data is cached; selects radius from BSP bounding sphere or
Setup.Radius; wires PhysicsDataCache into engine on OnLoad
- 16 new ShadowObjectRegistry unit tests, all 361 tests green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace simple Z-snap PhysicsEngine.Resolve with ResolveWithTransition
that uses the ported CTransition sphere-sweep pipeline. Movement is
subdivided into sphere-radius steps, terrain collision tested at each
step with step-down for ground contact maintenance.
Falls back to simple Resolve if transition fails. Player controller
now passes pre/post integration positions to the transition system.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AC heading convention: 0=West, 90=North, 180=East, 270=South.
Our internal yaw: 0=+X (East), PI/2=+Y (North).
Conversion: heading_deg = 180 - yaw_degrees, then holtburger's
from_heading formula: theta=(450-heading).toRad, w=cos(θ/2), z=sin(θ/2).
Previously sent raw Yaw as axis-angle rotation which was ~90° off.
Other clients now see correct facing direction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Build and send GameAction(Jump) with extent + world-space launch
velocity + sequence counters. Wire format from holtburger
JumpActionData::pack. Server can now validate and replicate jumps
to nearby clients.
Also compute RunRate locally via PlayerWeenie.InqRunRate when
running (server doesn't echo UpdateMotion ForwardSpeed to sender).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two fixes for jump physics:
- Skip ground-snap when velocity Z > 0 (prevents immediate re-landing
at high framerates where per-frame Z delta < 0.05 snap threshold)
- Guard apply_current_movement velocity write behind OnWalkable check
(prevents MotionInterpreter.DoMotion from zeroing jump velocity on
every frame while airborne)
- Guard PlayerMovementController velocity replacement behind OnWalkable
(preserves momentum during airborne flight)
Also fix run speed: compute RunRate locally via PlayerWeenie.InqRunRate
instead of waiting for server UpdateMotion echo (server doesn't echo
to sender). With Run skill 200, run speed is now 9.5 m/s instead of
4.0 m/s.
Strip all diagnostic logging from previous debug sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SmallVelocity threshold (0.25 m/s) in UpdatePhysicsInternal was zeroing
velocity every frame while airborne at the jump apex. With vel~0.01 m/s
and gravity adding only 0.012/frame, the zeroing won every frame and
the character got stuck at peak height forever.
Fix: only apply small-velocity zeroing when OnWalkable (grounded).
While airborne, gravity must accumulate freely through the zero-crossing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two fixes for jump physics:
- Skip ground-snap when velocity Z > 0 (prevents immediate re-landing
at high framerates where per-frame Z delta < 0.05 snap threshold)
- Guard apply_current_movement velocity write behind OnWalkable check
(prevents MotionInterpreter.DoMotion from zeroing jump velocity on
every frame while airborne)
- Guard PlayerMovementController velocity replacement behind OnWalkable
(preserves momentum during airborne flight)
Jump works locally but server packet not yet sent (BUG-002).
Facing direction mismatch logged as BUG-003.
RunRate not verified as BUG-004.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port FindTransitionalPosition, TransitionalInsert, FindEnvCollisions,
AdjustOffset, DoStepDown, ValidateTransition from transition_pseudocode.md.
Outdoor terrain collision with step-down ground contact. Indoor BSP and
object collision deferred to subsequent tasks.
Also adds PhysicsEngine.SampleTerrainZ() which dispatches the terrain Z
query to the right registered landblock by world-space XY position.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SpherePath, CollisionInfo, ObjectInfo, TransitionState, PhysicsGlobals.
Types match the pseudocode from transition_pseudocode.md, faithful to
decompiled CTransition + ACE Transition.cs naming.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Load PhysicsBSP and PhysicsPolygons from GfxObj dats during streaming.
BSPQuery.SphereIntersectsPoly traverses the tree for collision detection.
Ported from decompiled FUN_00539270, cross-ref ACE BSPNode.sphere_intersects_poly.
- PhysicsDataCache: thread-safe ConcurrentDictionary-backed cache of GfxObjPhysics
(BSP tree + polygon dict + vertex array) and SetupPhysics (capsule dimensions).
CacheGfxObj/CacheSetup are idempotent — safe to call at every dat load site.
- BSPQuery.SphereIntersectsPoly: recursive BSP descent with bounding-sphere broad
phase, leaf polygon test via existing CollisionPrimitives.SphereIntersectsPoly
(FUN_00539500), and splitting-plane classification for internal nodes.
- GameWindow: _physicsDataCache populated at all GfxObj/Setup dat load sites
(streaming worker path, live-spawn path, ApplyLoadedTerrain render-thread path).
- 6 new unit tests covering null node, bounding-sphere miss, leaf hit, no-contact,
internal node recursion, and empty cache behaviour. All 447 tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hold spacebar to charge (0→1 over 1s), release to jump. Height from
GetJumpHeight formula using Jump skill via PlayerWeenie. Jump physics
use MotionInterpreter.jump() → LeaveGround() → get_leave_ground_velocity().
JumpExtent is returned in MovementResult (non-null when jump fires this
frame) so GameWindow can log and eventually send the server jump packet.
Double-jump is prevented by jump_is_allowed() checking Contact+OnWalkable
flags before allowing another jump. Tests updated to use charge-then-release
pattern matching the new input model.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements IWeenieObject with GetRunRate and GetJumpHeight from
decompiled client, cross-referenced against ACE MovementSystem.
Default skills (Run=200, Jump=100) used until skill parsing ships.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parse ForwardSpeed from UpdateMotion (0xF74C) InterpretedMotionState.
Feed server-echoed RunRate into the player's MotionInterpreter so
get_state_velocity produces the correct speed. Previously hardcoded
at 1.0 (4.0 m/s), now matches character's Run skill.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four-layer design for retail-faithful movement: speed from RunRate,
charged jump, BSP collision from decompiled CTransition, cell-based
object collision. Decompile-first methodology per CLAUDE.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port ACME's EnvCellManager portal visibility system:
- New CellVisibility class: BFS portal traversal from camera cell,
portal-side clip-plane test, FindCameraCell with grace period
- LoadedCell data populated during streaming (portals, clip planes,
world/inverse transforms, local AABB from CellStruct vertices)
- WorldEntity.ParentCellId tags interior entities for filtering
- InstancedMeshRenderer.Draw accepts optional visibleCellIds set —
interior entities whose parent cell isn't visible are skipped
- Conditional depth clear between terrain and static mesh when
camera is inside a cell (ACME GameScene.cs pattern)
When camera is outdoors, all interiors render (visibleCellIds=null).
When camera enters a building, only BFS-reachable cells render.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Start with BUG-001: wrong cloth textures on characters (observed
during rendering rebuild verification).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace guessed sun direction (0.5, 0.4, 0.6) with ACME's verified
value (0.5, 0.3, -0.3) from GameScene.cs:238. Replace hardcoded
ambient/diffuse (0.25/0.75) with ACME's ambient intensity 0.45 from
LandscapeEditorSettings.cs:108.
Terrain shaders now match ACME Landscape.vert/frag pattern:
- Vertex shader computes Lambert term with xLightDirection uniform
- Fragment shader applies: color * (clamp(lambert, 0, 1) + xAmbient)
Static object shader matches ACME StaticObject.vert:
- LightingFactor = max(dot(N, -L), 0) + ambient
- Removed separate uDiffuseIntensity (ACME doesn't have one)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the single-giant-buffer TerrainRenderer with TerrainChunkRenderer
that groups landblocks into 16×16 chunks, each with its own VAO/VBO/EBO.
Matches ACME's ChunkMetrics/ChunkRenderData/TerrainGPUResourceManager
pattern:
- Pre-allocated max-size VBO (~3.75MB) + EBO per chunk with DynamicDraw
- Incremental glBufferSubData uploads per landblock slot (no full rebuild)
- Fixed slot layout: slot = (localX * 16 + localY), vertBase = slot * 384
- EBO contains only indices for occupied slots → tight draw calls
- One DrawElements per chunk with chunk-level AABB frustum culling
- Empty chunks auto-dispose GPU resources
Streaming-friendly: add/remove a single landblock touches only its slot
in the chunk buffer, not the entire terrain.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace 25 per-landblock VAOs with one shared buffer set. Vertex positions
are now baked in world space during AddLandblock (worldOrigin added to each
vertex), so uModel is eliminated from terrain.vert entirely. Buffer rebuild
happens on the cold path (landblock load/unload) via RebuildGpuBuffers.
Draw loop: bind VAO once, then one glDrawElements per visible landblock
into its sub-range of the shared EBO — same frustum-cull logic, no
VAO/VBO rebind overhead per landblock.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the per-entity glUniform uModel path with a shared instance VBO and
DrawElementsInstanced. All instance model matrices are uploaded to GPU once per
frame; the VAO's per-instance attribute pointers (locations 3–6, divisor=1) are
updated with a byte-offset re-point per group so a single VBO serves all groups
without requiring DrawElementsInstancedBaseInstance (not in Silk.NET 2.23).
Changes:
- InstancedMeshRenderer: add _instanceVbo, _instanceBuffer scratch; EnsureUploaded
sets up mat4 instance attrs (locs 3–6) from the shared VBO; Draw builds the flat
float[] of all instance matrices once then calls DrawElementsInstanced per sub-mesh.
Drops the unused uint TerrainLayer attribute (loc 3 from vertex VBO) — mesh shaders
never used it. Adds InstanceGroup helper to track per-group buffer offsets.
- mesh_instanced.frag: replace sampler2DArray+uTextureLayer with sampler2D uDiffuse,
matching the existing TextureCache / individual-texture pipeline.
- mesh_instanced.vert+frag: track as committed files (were untracked).
- Shader.cs: add SetVec3 helper needed for uLightDirection uniform.
- GameWindow.cs: switch mesh shader load from mesh.vert/.frag to
mesh_instanced.vert/.frag.
Visual output is identical: same entities, same textures, same lighting constants
(SUN_DIR=(0.5,0.4,0.6), AMBIENT=0.25, DIFFUSE=0.75 — moved from frag to vert).
Build: clean. Tests: 431/431 green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Groups all (entity, meshRef) pairs by GfxObjId before drawing so each
GfxObj's sub-meshes are processed as a contiguous batch. Still uses
per-entity uniform uModel — visual output is identical to the old
StaticMeshRenderer — but the _groups dict is the structural prerequisite
for swapping to DrawElementsInstanced in the follow-up commit.
Key changes:
- New InstancedMeshRenderer.cs with CollectGroups() that fills
_groups[gfxObjId] = List<InstanceEntry> each frame, reusing the
inner List<> objects to avoid per-frame allocation.
- Same two-pass (opaque+clipmap first, translucent second) draw logic
from StaticMeshRenderer, now iterating over groups rather than raw
entity/meshRef pairs.
- GameWindow.cs: field and constructor swapped from StaticMeshRenderer
to InstancedMeshRenderer — public API is identical.
- 431 tests green, 0 warnings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TWO root causes for "character disappears when walking far":
1. MarkPersistent stored SERVER GUID but RemoveLandblock checked LOCAL
entity.Id — different namespaces, never matched. Fixed by adding
WorldEntity.ServerGuid field and checking it in RemoveLandblock.
2. Even with rescue working, the player entity stays in its SPAWN
landblock's entity list forever. When the player walks to a new
landblock and the spawn landblock gets frustum-culled, the entity
disappears because neverCullLandblockId is computed from the
player's current position (new landblock) but the entity is stored
in the old landblock.
Fixed by calling GpuWorldState.RelocateEntity every frame in the
player-mode update loop. This moves the entity from whatever
landblock it's currently in to the one matching its actual position.
The scan is O(entities) but only runs for one entity per frame.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE: MarkPersistent(chosen.Id) stored the SERVER GUID (e.g.
0x5000000A) but RemoveLandblock checked entity.Id which is the LOCAL
sequential counter (e.g. 42). Different number spaces → never matched
→ persistent rescue never triggered → player entity lost on unload.
Fix:
- Added WorldEntity.ServerGuid field (0 for dat-hydrated scenery)
- Live entity spawn sets ServerGuid = spawn.Guid
- RemoveLandblock checks entity.ServerGuid against _persistentGuids
- MarkPersistent still stores the server GUID (correct)
This bug has been reported across multiple sessions as "character
disappears when walking far." The neverCullLandblockId fix only
prevented frustum culling but didn't prevent the entity from being
removed from the render list entirely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Sprint 2 of the audit remediation plan.
Re-enables the outdoor→indoor portal transition in PhysicsEngine with
an added containment check: after detecting a portal plane crossing,
verify the target cell's floor polygon actually covers the candidate
position AND the floor Z is within step height of the player's Z.
This prevents the wall-bounce bug (where portal planes on upper
floors captured outdoor positions) while allowing genuine doorway
transitions. Without full CellBSP, the SampleFloorZ + Z-proximity
check is the best available approximation per the indoor transition
research (docs/research/acclient_indoor_transitions_pseudocode.md).
Source: ACE EnvCell.find_transit_cells validates via
sphere_intersects_cell in the target cell's local space. Our
SampleFloorZ + Z check is the equivalent without BSP.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sprint 2 research for indoor transitions. Documents:
- ACE EnvCell.find_transit_cells: sphere-plane + BSP containment
- ACE SortCell/BuildingObj.check_building_transit: outdoor→indoor
- PortalSide semantics and portal polygon plane testing
- Gap analysis: acdream needs CellBSP, BldPortal list, VisibleCells
Key finding: full accuracy requires CellBSP (physics BSP from dat)
for sphere_intersects_cell. Current PortalPlane.IsCrossing is a valid
approximation. ACME's AABB PointInCell is an intermediate option.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Performed a side-by-side comparison of every LCG formula in SceneryGenerator.cs
against the decompiled retail acclient.exe (Ghidra output):
Scene-selection hash chunk_00530000.c:1144 — MATCH (0x2a7f2b89·x+0x6c1ac587)·y - 0x421be3bd·x + 0x7f8cda01
Per-object frequency chunk_00530000.c:1168-74 — MATCH accumulator pattern cellMat2*(0x5b67+j)
X displacement chunk_005A0000.c:4858-66 — MATCH offset 0xb2cd=45773
Y displacement chunk_005A0000.c:4871-78 — MATCH offset 0x11c0f=72719
Quadrant rotation chunk_005A0000.c:4880-4902 — MATCH constants 0x6f7bd965/0x421be3bd/-0x17fcedfd
Object rotation hash chunk_005A0000.c:4924-26 — MATCH offset 0xf697=63127
Scale hash ACViewer ObjectDesc.cs — MATCH offset 0x7f51=32593 (chunk not dumped)
Key finding: the decompiled client normalises signed-int LCG values with
"if (val < 0) val += 2^32" before dividing by 2^32. Our unchecked((uint)(...))
is exactly equivalent. ACViewer's reference omits this cast for some formulas
(displacement, rotation) and is subtly wrong for those; our implementation
already had the correct uint cast throughout.
Added inline decompiled-source citations to all five algorithm sites plus
an updated class-level doc comment noting the audit status and implementation note.
No behaviour change — comments only.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sprint 1a of the audit remediation plan.
Extracts the 4 movement sequence counters from inbound server messages
and echoes them in outbound MoveToState + AutonomousPosition instead
of hardcoded zeros:
- instanceSequence (slot 8 in CreateObject PhysicsData timestamps)
- teleportSequence (slot 4, also from PlayerTeleport 0xF751)
- serverControlSequence (slot 5)
- forcePositionSequence (slot 6, also from UpdatePosition 0xF748)
Source: holtburger player/types.rs:237-245, mutations.rs:182-706.
The server uses these to detect stale/reordered movement packets.
Previously all zeros → server couldn't distinguish epoch boundaries.
Changes:
- CreateObject.Parsed: +4 sequence fields extracted from timestamps
- UpdatePosition.Parsed: +3 sequence fields from trailing u16s
- WorldSession: tracks 4 counters, updates from CreateObject/
UpdatePosition/PlayerTeleport for the player's own GUID
- GameWindow: passes tracked values to MoveToState.Build and
AutonomousPosition.Build
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE of "twitching" / stuck-on-frame-0 for reverse animations
(TurnRight with negative dat framerate, StrafeRight, etc.):
The frame swap (StartFrame↔EndFrame for negative speed) made EndFrame=0,
and GetStartFramePosition returned (0+1)-eps = 0.999. The cursor
oscillated between 0.0 and 0.999 — floor() of anything in [0,1) is
always 0, so only frame 0 ever rendered.
Fix: DON'T swap. Keep StartFrame=0, EndFrame=N-1 regardless of speed
sign. GetStartFramePosition for negative speed returns (N-1+1)-eps ≈ N,
so the cursor starts near the high end and counts down through ALL
frames. The Advance loop's reverse boundary check uses StartFrame (the
low value) correctly without the swap.
Also strips diagnostic logging from AnimationSequencer and GameWindow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rewritten AnimationSequencer from the decompiled pseudocode, then
carefully integrated into GameWindow using the same surgical approach:
only replace the frame interpolation source, keep the transform
composition pipeline (scale → rotate → translate → entity scale →
MeshRef) UNTOUCHED.
Key differences from the reverted attempt (e4dae3d):
- Sequencer returns raw PartTransform (Origin, Orientation) — NOT
pre-composed matrices. The existing transform pipeline consumes
these identically to the legacy slerp output.
- Legacy slerp path is kept as explicit fallback for entities
without a MotionTable.
- SetCycle called from both UpdatePlayerAnimation and
OnLiveMotionUpdated — adjust_motion handles left→right remapping
internally with negative framerate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Complete ground-up rewrite of AnimationSequencer.cs using the retail AC client
pseudocode (docs/research/acclient_animation_pseudocode.md) as the direct
translation guide. Every key algorithmic difference from the previous patched
implementation is addressed:
1. _framePosition is now double (64-bit), matching Sequence+0x30 in the retail
client binary. Previously float, which accumulated rounding error over long
sessions.
2. FUN_005267E0 (multiply_framerate) is now correctly applied at node load time:
negative speedScale swaps startFrame↔endFrame so the advance loop counts DOWN
from (EndFrame+1)-epsilon toward EndFrame, exactly matching the retail layout.
3. update_internal (FUN_005261D0) is faithfully ported: one loop handles both
forward and reverse; boundary detection uses EndFrame as the lower bound for
reverse playback (matching the post-swap field semantics); remainder time
propagates correctly across node boundaries for large dt values.
4. GetStartFramePosition (FUN_00526880) and GetEndFramePosition (FUN_005268B0)
formulas are now correct: negative speed starts at (EndFrame+1)-epsilon,
ends at StartFrame; positive speed starts at StartFrame, ends at (EndFrame+1)-epsilon.
5. advance_to_next_animation (FUN_00525EB0) wraps to _firstCyclic when the
linked list is exhausted, matching the retail loop-forever semantics.
6. adjust_motion (ACE MotionInterp.cs:394-428) remapping is unchanged and
correct: TurnLeft→TurnRight, SideStepLeft→SideStepRight (negate speed),
WalkBackward→WalkForward (negate×0.65 BackwardsFactor).
7. SlerpRetailClient (FUN_005360d0) is unchanged — the pseudocode confirms the
existing implementation is correct.
AnimationSequencerTests grows from 9 to 17 tests:
- Negative-speed playback: TurnLeft remaps and cursor initializes near EndFrame+1
- Reverse frame position decreases (not increases) over time
- Reverse wrap at start boundary recovers and loops
- advance_to_next_animation: link node drains then enters cycle
- Cycle loops repeatedly without crash or position drift
All 431 tests green (109 net + 322 core).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Complete pseudocode translation of the retail AC client's animation
system, extracted from chunk_00520000.c. Covers:
- Sequence::update_internal (1021 bytes, the core frame advance loop)
- Sequence::advance_to_next_animation (node transitions)
- Sequence::append_animation (queue management)
- MotionTableManager::PerformMovement (1878 bytes, full state machine)
- AddAnimationsToSequence (transition link → sequence nodes)
- GetStartFramePosition / GetEndFramePosition (reverse playback support)
- AdjustNodeSpeed (negative speed = swapped start/end frames)
Key findings:
- framePosition is a 64-bit DOUBLE, not float
- Negative speedScale swaps startFrame↔endFrame at the node level
- update_internal handles both forward and reverse in one loop
- Frame triggers fire at every integer boundary crossing
- The keyframe slerp lives in the renderer, not the sequencer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Backward-playback nodes (TurnLeft, SideStepLeft) have LowFrame >
HighFrame after multiply_framerate swaps them. Math.Clamp(x, 19, 0)
throws because min > max.
Fix: compute rangeLo/rangeHi from Min/Max of Low/High, use those
for clamping. Also step nextIdx in the playback direction (forward
+1, backward -1) instead of always +1.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE FIX for missing left-side animations.
The AC client's MotionTable has NO cycles for TurnLeft (0x000E),
SideStepLeft (0x0010), or WalkBackward (0x0006). The real client
calls adjust_motion() which remaps these to their right-side
equivalents with NEGATIVE speed before looking up the cycle. Then
multiply_framerate() swaps LowFrame↔HighFrame so the animation
plays backward.
Source: ACE MotionInterp.cs:394-428, decompiled FUN_005267E0.
Changes:
- AnimationSequencer.SetCycle: adds adjust_motion block that remaps
left→right with speed *= -1 (TurnLeft, SideStepLeft) or
speed *= -0.65 (WalkBackward = BackwardsFactor)
- LoadAnimNode: when framerate < 0, swaps Low↔High (matching the
decompiled multiply_framerate)
- GameWindow.UpdatePlayerAnimation: passes original animCommand to
SetCycle (sequencer handles remapping internally), keeps legacy
fallback for non-sequencer entities
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Turn-left / strafe-left / walk-backward animations still not playing
despite the left→right fallback. Diagnostic logging added to
UpdatePlayerAnimation to capture exactly what happens when these
commands fire. Next session: check the log output, the issue may be
that UpdatePlayerAnimation is never called for these commands (the
priority logic skips turn when forward is active, etc.).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The left→right fallback resolved the cycle correctly but still passed
the original TurnLeft/SideStepLeft command to the sequencer. The
sequencer did its own internal cycle lookup with the left-side command
and found nothing → no animation played.
Fix: track which command actually resolved (after fallback) and pass
that to SetCycle so the sequencer's internal lookup matches.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AC MotionTables typically define cycles for TurnRight and SideStepRight
but not TurnLeft/SideStepLeft — the left variants reuse the right-side
animation played in reverse. When the left-side command doesn't resolve
to a cycle, fall back to the right-side equivalent so the player isn't
stuck in idle pose.
Fallback map:
TurnLeft (0x000E) → TurnRight (0x000D)
SideStepLeft (0x0010) → SideStepRight (0x000F)
WalkBackward (0x0006) → WalkForward (0x0005)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The sequencer was initialized at spawn but never notified when the
player started walking/running/turning. UpdatePlayerAnimation and
OnLiveMotionUpdated both updated the legacy slerp fields but the
sequencer path in TickAnimations reads from Sequencer.Advance(dt),
which stayed at the initial idle cycle.
Fix: both methods now call ae.Sequencer.SetCycle(style, motion) when
the sequencer exists, alongside the legacy field updates (which serve
as fallback for entities without a sequencer).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Surgical integration that ONLY replaces the frame interpolation path,
preserving the exact transform composition pipeline that builds MeshRefs.
The key insight from the reverted attempt: the sequencer's Advance(dt)
returns raw (Origin, Orientation) per part — these feed into the SAME
transform composition as before:
CreateScale(defaultScale) * CreateFromQuat(orientation) * CreateTranslation(origin)
then * scaleMat for entity ObjScale
then → MeshRef with template GfxObjId + SurfaceOverrides
What changed:
- AnimatedEntity gains optional Sequencer field
- DatCollectionLoader created once at dat-open time
- Entity registration creates sequencer when MotionTable is loadable
- TickAnimations: if sequencer exists, Advance(dt) produces per-part
transforms; otherwise falls back to Phase 6.5 manual slerp
- Transform composition + MeshRef building is UNCHANGED
What was NOT changed (the previous attempt broke these):
- Per-part defaultScale application
- Entity ObjScale (scaleMat) multiplication
- PartTemplate GfxObjId + SurfaceOverrides forwarding
- The entire MeshRef construction block
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wire the new AnimationSequencer into per-entity animation playback:
- Added `Sequencer` field to `AnimatedEntity`; populated at spawn time
by loading the entity's MotionTable and calling SetCycle on the resolved
idle (style, motion) so playback starts immediately.
- Added `_animLoader` (DatCollectionLoader) initialized alongside `_dats`
so sequencer instances share a single animation loader.
- `TickAnimations`: when an entity has a sequencer, calls `seq.Advance(dt)`
and reads back `PartTransform[]` instead of doing manual frame-index math
and Quaternion.Slerp. Falls back to the Phase 6.5 manual slerp for entities
whose MotionTable couldn't be loaded (missing dat / offline mode).
- `OnLiveMotionUpdated`: calls `sequencer.SetCycle(style, motion)` when
available so motion changes prepend transition-link frames for smooth blending.
- `UpdatePlayerAnimation`: same — calls `sequencer.SetCycle(NonCombatStyleFull, cmd)`
on motion changes; also creates a sequencer when the player entity is
lazy-registered for the first time.
The existing manual-slerp fallback is kept verbatim so behavior is unchanged
for any entity that can't be backed by a sequencer. Build clean, 426 tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>