From 090d265a4ef5cd84c441df6b60f38d19f7859a7a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 18:36:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(core+app):=20Phase=206.1=20=E2=80=94=20res?= =?UTF-8?q?olve=20idle=20motion=20frame=20from=20MotionTable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks each entity's Setup → MotionTable → Animation chain to get the per-part frame for its default idle pose, then uses that frame in SetupMesh.Flatten instead of the static PlacementFrames lookup. For creatures and characters this should produce the upright "Resting" pose (e.g. for the Nullified Statue of a Drudge) instead of the Setup-default crouch. This is a minimal Phase 6 cut: render the FIRST frame of the IDLE motion as a static pose. No per-frame interpolation, no walking, no attack motions, no transitions. Those are larger pieces tracked in docs/plans/2026-04-11-roadmap.md under Phase 6. Algorithm ported from references/ACViewer/.../Physics/Animation/ MotionTable.SetDefaultState: 1. Look up Setup.DefaultMotionTable (0x09XXXXXX). 0 → no motion, fall back to PlacementFrames. 2. MotionTable.StyleDefaults[DefaultStyle] → default substate. 3. cycleKey = (DefaultStyle << 16) | (substate & 0xFFFFFF) 4. MotionTable.Cycles[cycleKey] → MotionData. 5. MotionData.Anims[0].AnimId → Animation dat. 6. Animation.PartFrames[animData.LowFrame] → AnimationFrame containing the per-part transforms for the idle pose. Added: - Core/Meshing/MotionResolver.cs: pure function GetIdleFrame(setup, dats) that walks the chain and returns an AnimationFrame or null. null is the "no motion data" sentinel and means caller should fall back to PlacementFrames. - SetupMesh.Flatten now takes an optional AnimationFrame override parameter. Pose source priority is: override → PlacementFrames[Resting] → PlacementFrames[Default] So existing call sites that don't pass an override get the Phase 5d Resting-fallback behavior unchanged. Static scenery is unaffected. - GameWindow.OnLiveEntitySpawned (live-mode hydrator) calls MotionResolver.GetIdleFrame and passes the result to Flatten. Other Flatten call sites (offline scenery, interior EnvCells, scenery generator) NOT yet wired — those use static dat hydration where the entities don't have meaningful motion tables. The user-visible win from this commit is in the live spawn pipeline only. Things I'm not certain about and will check via the live run: - Whether Animation.PartFrames are in entity-root-relative space (matching PlacementFrames) or parent-relative (would need a parent walk we don't do). ACViewer's UpdateParts applies frames per-part without walking parents, suggesting root-relative — same convention as PlacementFrames. - Whether the resolver's null fallback is hit for creatures whose Setup.DefaultMotionTable happens to be 0 (would silently regress to Default placement). Worth checking if drudge looks the same. Tests: 77 core + 83 net = 160, all green. No new tests yet because the change is data-driven and best validated end-to-end via the live run rather than synthetic dat fixtures (which would require fabricating a complete MotionTable + Animation chain just to test the lookup). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 8 +- src/AcDream.Core/Meshing/MotionResolver.cs | 100 +++++++++++++++++++++ src/AcDream.Core/Meshing/SetupMesh.cs | 34 +++---- 3 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 src/AcDream.Core/Meshing/MotionResolver.cs 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);