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:
parent
ca7ae45518
commit
8402aee703
2 changed files with 1142 additions and 128 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue