research: full animation pseudocode from decompiled acclient.exe

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>
This commit is contained in:
Erik 2026-04-13 12:43:44 +02:00
parent ca7ae45518
commit 8402aee703
2 changed files with 1142 additions and 128 deletions

View file

@ -91,18 +91,8 @@ public sealed class GameWindow : IDisposable
/// </summary>
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate;
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
/// <summary>
/// Optional AnimationSequencer for transition-link-aware playback.
/// When non-null, TickAnimations uses Advance(dt) for frame data
/// instead of the manual slerp path. Null for entities whose
/// MotionTable couldn't be loaded.
/// </summary>
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
}
/// <summary>Shared animation dat loader for all AnimationSequencer instances.</summary>
private AcDream.Core.Physics.DatCollectionLoader? _animLoader;
// Phase B.2: player movement mode.
private AcDream.App.Input.PlayerMovementController? _playerController;
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
@ -333,7 +323,6 @@ public sealed class GameWindow : IDisposable
_cameraController.ModeChanged += OnCameraModeChanged;
_dats = new DatCollection(_datDir, DatAccessType.Read);
_animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats);
uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
@ -833,25 +822,6 @@ public sealed class GameWindow : IDisposable
for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
// Try to create an AnimationSequencer for transition-link-aware
// playback. Requires loading the MotionTable dat separately.
AcDream.Core.Physics.AnimationSequencer? sequencer = null;
if (_animLoader is not null)
{
uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{
var mtable = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
if (mtable is not null)
{
sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
uint style = stanceOverride is > 0 ? (uint)stanceOverride.Value : (uint)mtable.DefaultStyle;
uint motion = commandOverride is > 0 ? (uint)commandOverride.Value : 0x41000003u; // Ready
sequencer.SetCycle(style, motion);
}
}
}
_animatedEntities[entity.Id] = new AnimatedEntity
{
Entity = entity,
@ -863,7 +833,6 @@ public sealed class GameWindow : IDisposable
Scale = scale,
PartTemplate = template,
CurrFrame = idleCycle.LowFrame,
Sequencer = sequencer,
};
}
@ -951,14 +920,6 @@ public sealed class GameWindow : IDisposable
if (!newCycleIsGood)
return;
// If the entity has a sequencer, use SetCycle for transition links.
if (ae.Sequencer is not null)
{
uint fullStyle = stance != 0 ? (0x80000000u | (uint)stance) : ae.Sequencer.CurrentStyle;
uint fullMotion = command is > 0 ? (uint)command.Value : 0x41000003u;
ae.Sequencer.SetCycle(fullStyle, fullMotion);
}
ae.Animation = newCycle!.Animation;
ae.LowFrame = Math.Max(0, newCycle.LowFrame);
ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
@ -1876,31 +1837,39 @@ public sealed class GameWindow : IDisposable
foreach (var kv in _animatedEntities)
{
var ae = kv.Value;
int span = ae.HighFrame - ae.LowFrame;
if (span <= 0) continue;
// ── Compute per-part (origin, orientation) ──────────────────────
// Two paths: if the entity has an AnimationSequencer, use it (gets
// transition links + retail slerp). Otherwise fall back to the
// Phase 6.5 manual slerp path.
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames = null;
if (ae.Sequencer is not null)
ae.CurrFrame += dt * ae.Framerate;
// Wrap into [LowFrame, HighFrame]. Use a guarded modulo so
// big dts (first frame after a stall) don't blow the loop.
if (ae.CurrFrame > ae.HighFrame)
{
seqFrames = ae.Sequencer.Advance(dt);
float over = ae.CurrFrame - ae.LowFrame;
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
}
else
else if (ae.CurrFrame < ae.LowFrame)
{
// Legacy fallback: manual frame advancement + slerp.
int span = ae.HighFrame - ae.LowFrame;
if (span <= 0) continue;
ae.CurrFrame = ae.LowFrame;
}
ae.CurrFrame += dt * ae.Framerate;
if (ae.CurrFrame > ae.HighFrame)
{
float over = ae.CurrFrame - ae.LowFrame;
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
}
else if (ae.CurrFrame < ae.LowFrame)
ae.CurrFrame = ae.LowFrame;
}
// Phase 6.5: blend between adjacent keyframes using the fractional
// part of CurrFrame so the animation is smooth at any framerate
// instead of snapping to integer frame indices.
int frameIdx = (int)Math.Floor(ae.CurrFrame);
if (frameIdx < ae.LowFrame || frameIdx > ae.HighFrame
|| frameIdx >= ae.Animation.PartFrames.Count)
frameIdx = ae.LowFrame;
int nextIdx = frameIdx + 1;
if (nextIdx > ae.HighFrame || nextIdx >= ae.Animation.PartFrames.Count)
nextIdx = ae.LowFrame; // cycle wraps within [LowFrame, HighFrame]
float t = ae.CurrFrame - frameIdx;
if (t < 0f) t = 0f; else if (t > 1f) t = 1f;
var partFrames = ae.Animation.PartFrames[frameIdx].Frames;
var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames;
int partCount = ae.PartTemplate.Count;
var newMeshRefs = new List<AcDream.Core.World.MeshRef>(partCount);
@ -1910,52 +1879,25 @@ public sealed class GameWindow : IDisposable
for (int i = 0; i < partCount; i++)
{
// Slerp between the current and next keyframe per part.
// Out-of-range parts get an identity transform — defensive
// for setups whose part count exceeds the animation's bone
// count.
System.Numerics.Vector3 origin;
System.Numerics.Quaternion orientation;
if (seqFrames is not null)
if (i < partFrames.Count)
{
// Sequencer path: per-part transforms already interpolated.
if (i < seqFrames.Count)
{
origin = seqFrames[i].Origin;
orientation = seqFrames[i].Orientation;
}
else
{
origin = System.Numerics.Vector3.Zero;
orientation = System.Numerics.Quaternion.Identity;
}
var f0 = partFrames[i];
var f1 = i < partFramesNext.Count ? partFramesNext[i] : f0;
origin = System.Numerics.Vector3.Lerp(f0.Origin, f1.Origin, t);
orientation = System.Numerics.Quaternion.Slerp(f0.Orientation, f1.Orientation, t);
}
else
{
// Legacy slerp path.
int frameIdx = (int)Math.Floor(ae.CurrFrame);
if (frameIdx < ae.LowFrame || frameIdx > ae.HighFrame
|| frameIdx >= ae.Animation.PartFrames.Count)
frameIdx = ae.LowFrame;
int nextIdx = frameIdx + 1;
if (nextIdx > ae.HighFrame || nextIdx >= ae.Animation.PartFrames.Count)
nextIdx = ae.LowFrame;
float t = ae.CurrFrame - frameIdx;
if (t < 0f) t = 0f; else if (t > 1f) t = 1f;
var partFrames = ae.Animation.PartFrames[frameIdx].Frames;
var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames;
if (i < partFrames.Count)
{
var f0 = partFrames[i];
var f1 = i < partFramesNext.Count ? partFramesNext[i] : f0;
origin = System.Numerics.Vector3.Lerp(f0.Origin, f1.Origin, t);
orientation = System.Numerics.Quaternion.Slerp(f0.Orientation, f1.Orientation, t);
}
else
{
origin = System.Numerics.Vector3.Zero;
orientation = System.Numerics.Quaternion.Identity;
}
origin = System.Numerics.Vector3.Zero;
orientation = System.Numerics.Quaternion.Identity;
}
var frame = new DatReaderWriter.Types.Frame { Origin = origin, Orientation = orientation };
// Per-part default scale from the Setup, matching SetupMesh.Flatten's
// composition order: scale → rotate → translate.
@ -1965,8 +1907,8 @@ public sealed class GameWindow : IDisposable
var partTransform =
System.Numerics.Matrix4x4.CreateScale(defaultScale) *
System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) *
System.Numerics.Matrix4x4.CreateTranslation(origin);
System.Numerics.Matrix4x4.CreateFromQuaternion(frame.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(frame.Origin);
// Bake the entity's ObjScale on top, matching the hydration
// order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned.
@ -2054,34 +1996,6 @@ public sealed class GameWindow : IDisposable
stanceOverride: NonCombatStance,
commandOverride: cmdOverride);
// The sequencer handles left→right remapping internally via
// adjust_motion (TurnLeft→TurnRight with negative speed, etc.).
// Pass the ORIGINAL animCommand — SetCycle does the remapping.
if (ae.Sequencer is not null)
{
uint fullStyle = 0x80000000u | (uint)NonCombatStance;
ae.Sequencer.SetCycle(fullStyle, animCommand);
}
// Legacy path fallback: for the non-sequencer slerp path, do the
// left→right remapping here since that path doesn't have adjust_motion.
if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame)
{
ushort fallback = cmdOverride switch
{
0x000E => 0x000D,
0x0010 => 0x000F,
0x0006 => 0x0005,
_ => (ushort)0,
};
if (fallback != 0)
cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats,
motionTableIdOverride: _playerMotionTableId,
stanceOverride: NonCombatStance,
commandOverride: fallback);
}
if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) return;
ae.Animation = cycle.Animation;