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); } }