diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 09f7652..fbf91c4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -621,7 +621,13 @@ public sealed class GameWindow : IDisposable return; } - var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + // Phase 6: resolve the entity's idle motion frame from its + // MotionTable chain. For creatures and characters this gives us + // the upright "Resting" pose instead of the Setup's Default + // (T-pose / aggressive crouch). Static items with no motion table + // get null and fall back to PlacementFrames in Flatten. + var idleFrame = AcDream.Core.Meshing.MotionResolver.GetIdleFrame(setup, _dats); + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup, idleFrame); // Apply the server's AnimPartChanges: "replace part at index N // with GfxObj M". This is how characters become clothed (head → diff --git a/src/AcDream.Core/Meshing/MotionResolver.cs b/src/AcDream.Core/Meshing/MotionResolver.cs new file mode 100644 index 0000000..932d756 --- /dev/null +++ b/src/AcDream.Core/Meshing/MotionResolver.cs @@ -0,0 +1,100 @@ +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. + /// + public static AnimationFrame? GetIdleFrame( + Setup setup, + DatCollection dats, + uint? motionTableIdOverride = null) + { + 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; + + // Step 1: find the substate that DefaultStyle maps to. + if (!mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var defaultSubstate)) + return null; + + // Step 2: compose the cycle key. ACViewer's encoding: + // cycle = (DefaultStyle << 16) | (substate & 0xFFFFFF) + // Cast through uint then int because Cycles is keyed by int. + uint defaultStyleVal = (uint)mtable.DefaultStyle; + uint substateVal = (uint)defaultSubstate; + int cycleKey = (int)((defaultStyleVal << 16) | (substateVal & 0xFFFFFF)); + + if (!mtable.Cycles.TryGetValue(cycleKey, out var motionData) || motionData is null) + return null; + if (motionData.Anims.Count == 0) return null; + + var animData = motionData.Anims[0]; + + // Step 3: load the Animation and grab the first frame. + 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; + + // animData.LowFrame is the start frame index of the cycle within the + // animation. Clamp defensively in case it's out of range. + int frameIdx = animData.LowFrame; + if (frameIdx < 0 || frameIdx >= animation.PartFrames.Count) + frameIdx = 0; + + return animation.PartFrames[frameIdx]; + } +} diff --git a/src/AcDream.Core/Meshing/SetupMesh.cs b/src/AcDream.Core/Meshing/SetupMesh.cs index 1be9baa..26470b4 100644 --- a/src/AcDream.Core/Meshing/SetupMesh.cs +++ b/src/AcDream.Core/Meshing/SetupMesh.cs @@ -14,24 +14,28 @@ public static class SetupMesh /// Does NOT walk ParentIndex — each part's transform is local to the setup root. /// This is simplification for Phase 2; complex hierarchical rigs are Phase 3. /// - public static IReadOnlyList Flatten(Setup setup) + public static IReadOnlyList Flatten(Setup setup, AnimationFrame? motionFrameOverride = null) { - // ACViewer's CreateMesh always calls SetPlacementFrame(0x65) = - // Placement.Resting after creating any mesh, regardless of object - // type. For creatures and characters this matters a lot — Default - // is the aggressive battle crouch pose with arms extended forward, - // and Resting is the upright idle pose. Without this preference, - // every drudge/skeleton/character renders mid-attack, including - // the Nullified Statue of a Drudge whose only placement frame is - // keyed by Resting. + // Pose source priority: + // 1. motionFrameOverride — caller has resolved an idle/animation + // frame from the entity's MotionTable (Phase 6). This is the + // best source for creatures and characters because their + // Setup.PlacementFrames don't define an upright idle pose. + // 2. Setup.PlacementFrames[Resting] — used by static objects + // that have a Resting frame defined (e.g. signs, doors). + // 3. Setup.PlacementFrames[Default] — fallback for everything + // else (most scenery), which is the only frame those setups + // define and renders correctly. // - // Fall back to Default if Resting isn't present (most scenery - // setups only define Default). The user-visible difference is - // most dramatic for creatures; static dat scenery is unaffected. - AnimationFrame? defaultAnim = null; - if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) + // Without an override, creatures used to render in their Default + // pose (T-pose-ish or aggressive crouch) because their MotionTable + // wasn't consulted. The Phase 6 MotionResolver provides the override + // by walking Setup.DefaultMotionTable → MotionTable.Cycles → + // Animation.PartFrames[LowFrame]. + AnimationFrame? defaultAnim = motionFrameOverride; + if (defaultAnim is null && setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) defaultAnim = resting; - else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var af)) + if (defaultAnim is null && setup.PlacementFrames.TryGetValue(Placement.Default, out var af)) defaultAnim = af; var result = new List(setup.Parts.Count);