using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Meshing;
///
/// Resolves an entity's idle / default-state per-part animation frame from
/// its Setup → MotionTable → Animation chain. Used by the rendering
/// hydration path so creatures and characters render in their proper idle
/// pose instead of the static Setup.PlacementFrames[Default] pose
/// (which for drudges and most creatures is "T-pose-ish" or aggressive
/// crouch — the wrong starting point for a calm idle).
///
///
/// The resolution algorithm matches ACViewer's
/// Physics/Animation/MotionTable.SetDefaultState for the static
/// (non-animated) case: pick the first frame of the default cycle's
/// first AnimData. We don't run the animation forward over time yet —
/// the goal here is "render the FIRST frame of the IDLE motion" which
/// gives us a sensible static pose.
///
///
///
/// References (per CLAUDE.md cross-reference rule):
///
/// - references/ACViewer/.../Physics/Animation/MotionTable.cs::SetDefaultState
/// — the algorithm we port
/// - references/ACE/.../FileTypes/MotionTable.cs
/// — server-side parser, confirms key encoding
/// - references/DatReaderWriter/.../Generated/DBObjs/MotionTable.generated.cs
/// — exact field names and types
///
///
///
public static class MotionResolver
{
///
/// Walk an entity's motion-table chain and return the per-part
/// animation frame for its default idle state. Returns null
/// if any link in the chain is missing — caller should fall back
/// to Setup.PlacementFrames.
///
/// The entity's base Setup dat.
/// Dat collection used to load the linked MotionTable + Animation.
///
/// Optional override for the motion table id. Defaults to
/// Setup.DefaultMotionTable. The server's CreateObject can
/// supply a per-instance MotionTable id via PhysicsDescriptionFlag.MTable
/// — pass that here when present so character outfits/states use the
/// correct table.
///
///
/// Per-entity idle-cycle playback descriptor returned by
/// . Holds the resolved Animation plus the
/// frame range and framerate from the MotionTable's AnimData entry.
/// Phase 6.4 uses this to drive per-frame playback (so creatures
/// breathe instead of being frozen on a single frame).
///
public sealed record IdleCycle(
Animation Animation,
int LowFrame,
int HighFrame,
float Framerate);
///
/// Same resolution algorithm as but
/// returns the full cycle metadata so the caller can advance frames
/// over time. Returns null if any link in the chain is missing.
///
public static IdleCycle? GetIdleCycle(
Setup setup,
DatCollection dats,
uint? motionTableIdOverride = null,
ushort? stanceOverride = null,
ushort? commandOverride = null)
{
var resolved = ResolveIdleCycleInternal(setup, dats, motionTableIdOverride, stanceOverride, commandOverride);
if (resolved is null) return null;
var (anim, ad) = resolved.Value;
// Sentinel resolution — matches ACViewer's AnimSequenceNode.set_animation_id
// (references/ACViewer/.../Physics/Animation/AnimSequenceNode.cs:96-113).
//
// AnimData in the dats encodes a few sentinel values that the raw int
// fields can't represent directly:
// * HighFrame == -1 → "play the whole animation" (use NumFrames - 1)
// * HighFrame > NumFrames - 1 → clamp (defensive for malformed data)
// * LowFrame >= NumFrames → clamp to NumFrames - 1
// * LowFrame > HighFrame after clamping → collapse to a single frame
//
// Our Phase 6.1 code naively used the raw LowFrame/HighFrame and then
// rejected the cycle if `HighFrame <= LowFrame`, which silently threw
// away every animated creature whose AnimData used the -1 sentinel —
// i.e. almost all of them. Resolving here makes the IdleCycle carry
// the real frame range, so downstream playback/filtering sees a span
// it can advance through.
int numFrames = anim.PartFrames.Count;
int lowFrame = ad.LowFrame;
int highFrame = ad.HighFrame;
if (highFrame < 0)
highFrame = numFrames - 1;
if (lowFrame >= numFrames)
lowFrame = numFrames - 1;
if (highFrame >= numFrames)
highFrame = numFrames - 1;
if (lowFrame < 0)
lowFrame = 0;
if (lowFrame > highFrame)
highFrame = lowFrame;
return new IdleCycle(anim, lowFrame, highFrame, ad.Framerate);
}
public static AnimationFrame? GetIdleFrame(
Setup setup,
DatCollection dats,
uint? motionTableIdOverride = null,
ushort? stanceOverride = null,
ushort? commandOverride = null)
{
var resolved = ResolveIdleCycleInternal(setup, dats, motionTableIdOverride, stanceOverride, commandOverride);
if (resolved is null) return null;
var (animation, animData) = resolved.Value;
int frameIdx = animData.LowFrame;
if (frameIdx < 0 || frameIdx >= animation.PartFrames.Count)
frameIdx = 0;
return animation.PartFrames[frameIdx];
}
///
/// Shared cycle-resolution path for both
/// and . Returns the loaded Animation +
/// the AnimData that describes its frame range and framerate, or
/// null if any link in the chain is missing.
///
private static (Animation, AnimData)? ResolveIdleCycleInternal(
Setup setup,
DatCollection dats,
uint? motionTableIdOverride,
ushort? stanceOverride,
ushort? commandOverride)
{
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(dats);
uint mtableId = motionTableIdOverride ?? (uint)setup.DefaultMotionTable;
if (mtableId == 0) return null;
var mtable = dats.Get(mtableId);
if (mtable is null) return null;
// Resolve (style, substate) with priority:
// 1. Server-sent stance + command (CreateObject MovementData) — needed
// for entities like the Foundry's drudge statue, which override the
// MotionTable default with an aggressive crouch.
// 2. Server-sent stance only — substate falls back to that style's
// StyleDefaults entry.
// 3. MotionTable.DefaultStyle + StyleDefaults — the upright/Ready
// idle for everything else.
uint styleVal;
uint substateVal;
// Helper: pick the table's default (style, substate) — used as the
// ultimate fallback when caller-supplied overrides don't resolve.
bool TryGetTableDefault(out uint styleOut, out uint substateOut)
{
if (mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var defaultSubstate))
{
styleOut = (uint)mtable.DefaultStyle;
substateOut = (uint)defaultSubstate;
return true;
}
styleOut = 0;
substateOut = 0;
return false;
}
if (stanceOverride is { } stance && stance != 0)
{
styleVal = stance;
if (commandOverride is { } cmd && cmd != 0)
{
substateVal = cmd;
}
else if (mtable.StyleDefaults.TryGetValue((DatReaderWriter.Enums.MotionCommand)styleVal, out var subFromStyle))
{
substateVal = (uint)subFromStyle;
}
else
{
// The server gave us a stance the motion table doesn't recognize
// (e.g. NPCs that re-broadcast a "ready" stance with a substate
// value where the table only has a style entry). Don't return
// null — fall back to the table default so the entity at least
// animates with its idle cycle. Returning null here was the
// bug that silently un-registered Pathwarden / Town Crier from
// _animatedEntities the moment ACE sent a post-spawn motion
// update with stance=0x0003 cmd=0x0000.
if (!TryGetTableDefault(out styleVal, out substateVal))
return null;
}
}
else
{
if (!TryGetTableDefault(out styleVal, out substateVal))
return null;
}
// ACViewer's cycle key encoding (Physics/Animation/MotionTable.cs:191):
// cycle = (style << 16) | (substate & 0xFFFFFF)
int cycleKey = (int)((styleVal << 16) | (substateVal & 0xFFFFFF));
// Try the server-supplied combo first; if it doesn't resolve, fall back
// to the table's default style + that style's default substate. This
// matters when the server sends a (stance, command) pair the table
// doesn't have a cycle entry for — better an upright pose than nothing.
if (!mtable.Cycles.TryGetValue(cycleKey, out var motionData) || motionData is null
|| motionData.Anims.Count == 0)
{
if (mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var fallbackSub))
{
int fallbackKey = (int)(((uint)mtable.DefaultStyle << 16) | ((uint)fallbackSub & 0xFFFFFF));
if (!mtable.Cycles.TryGetValue(fallbackKey, out motionData) || motionData is null)
return null;
if (motionData.Anims.Count == 0) return null;
}
else
{
return null;
}
}
var animData = motionData.Anims[0];
// Load the Animation referenced by the cycle.
uint animId = (uint)animData.AnimId;
if (animId == 0) return null;
var animation = dats.Get(animId);
if (animation is null) return null;
if (animation.PartFrames.Count == 0) return null;
return (animation, animData);
}
}