From 6bce9b801944e9398d888ad73c7dfa9f67abf95d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Apr 2026 15:32:52 +0200 Subject: [PATCH] fix(anim): jump animation via Falling SubState + kept PlayAction infra for emotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual retail behavior for jump animations is a plain SubState swap — NOT an Action overlay as I'd initially guessed. MotionCommand.Falling (0x40000015) is a SubState cycle whose motion-table entries handle the whole jump lifecycle: - Links[(stance, RunForward)][Falling] = leap-into-air link - Cycles[(stance, Falling)] = airborne cycle (loops) - Links[(stance, Falling)][Ready/...] = landing link back to normal Empirical verification from the diagnostic dump: Links[0x003D0007] has 3 inner entries: inner key: 0x41000003 (Ready) inner key: 0x45000005 (WalkForward) inner key: 0x40000015 (Falling) ← jackpot SetCycle() already handles SubState + Links + Cycles resolution correctly, so the whole fix is three lines: if (!result.IsOnGround) animCommand = MotionCommand.Falling; What's in this commit: - Added MotionCommand.Falling (0x40000015) constant + comments explaining the retail jump-is-a-SubState flow - GameWindow.UpdatePlayerAnimation swaps to Falling when airborne (the cleanest possible implementation — motion table does all the work) - Kept AnimationSequencer.PlayAction infrastructure (ported via Links fallback + Modifiers fallback). Not needed for jump, but perfectly valid for emotes like /wave, /bow (found in the same Links dict as inner keys 0x13000080-0x13000083) and eventual combat attacks - Kept MotionCommand.Jump / Jumpup / FallDown constants (unused for now but useful reference if non-humanoid motion tables use them) - Removed all diagnostic logging What was learned (for future motion work): - Retail's MotionTable.Cycles dict holds SubState loops (Ready, Walk, Run, Falling, Crouch, etc.) by (style<<16) | (motion & 0xFFFFFF) - MotionTable.Links dict holds TRANSITIONS between motions: the OUTER key is the (style, fromMotion) combo; the INNER key is the TARGET motion. The stored MotionData IS the link animation played during the transition. This is what ACE's get_link traverses. - MotionTable.Modifiers dict holds overlay motions (mask 0x20) — rare for humanoids, only 8 TurnRight/SideStepRight stance variants - Actions (mask 0x10) in retail ALSO go through Links — they're transition animations FROM current substate, not overlays. Use PlayAction (now correctly routed to Links dict) for them. Jump animation now works retail-faithfully for running + jumping off. Standing-jump behavior depends on whether the player's motion table has a Ready→Falling link; SetCycle's fallback chain should handle it via the style-level catch-all if the direct link is absent. 470 tests pass. Build clean. --- src/AcDream.App/Rendering/GameWindow.cs | 54 +++++++------------ .../Physics/AnimationSequencer.cs | 40 +++++++++++--- src/AcDream.Core/Physics/MotionInterpreter.cs | 24 +++++++-- 3 files changed, 70 insertions(+), 48 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b9dd13f..a43a74d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3037,46 +3037,30 @@ public sealed class GameWindow : IDisposable { if (_dats is null) return; - // ── Action-motion events (jump / land) ───────────────────────────── + // ── Airborne SubState (Falling) ──────────────────────────────────── // - // Retail does NOT animate jumps — confirmed via ACE's HandleActionJump - // (Player.cs:914-915) which explicitly clears PendingMotions and - // sets IsAnimating=false during the jump. The character keeps - // whatever cycle it was on and the physics body arcs through the air. - // Humanoid Setup MotionTables have NO entry for Jump (0x2500003B) - // or FallDown (0x10000050) in the Modifiers dict — verified empirically - // (only 8 TurnRight stance-variants + SideStepRight). + // Retail models the jump-animation as a SubState swap to + // MotionCommand.Falling (0x40000015) while airborne, NOT as an + // Action overlay. Empirically verified: Links[(NonCombat,RunForward)] + // has 3 transitions including 0x40000015 Falling. The SubState cycle + // for Falling lives in Cycles[(style, Falling)] and loops while + // airborne. On land, we transition back to whatever SubState the + // motion input implies (Ready / WalkForward / RunForward). // - // We still call PlayAction here as a no-op safety hatch: if a future - // Setup / creature DOES carry a jump/fall modifier in its MotionTable - // (e.g. a leaping-monster) the sequencer will pick it up for free. - // For player humanoids, the lookup silently misses and nothing changes. - if (result.JumpExtent.HasValue || result.JustLanded) - { - if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var actionPe) - && _animatedEntities.TryGetValue(actionPe.Id, out var actionAe) - && actionAe.Sequencer is not null) - { - if (result.JumpExtent.HasValue) - actionAe.Sequencer.PlayAction(AcDream.Core.Physics.MotionCommand.Jump); - if (result.JustLanded) - actionAe.Sequencer.PlayAction(AcDream.Core.Physics.MotionCommand.FallDown); - } - } + // Implementation: force animCommand = Falling when airborne; the + // existing SetCycle pathway resolves the link + cycle correctly and + // the transition back happens naturally when airborne becomes false. - // Determine the animation command: forward takes priority, then sidestep, - // then turn, then idle (Ready 0x41000003). + // Determine the animation command: airborne takes priority (Falling + // SubState), then forward, sidestep, turn, then idle (Ready 0x41000003). // - // Note: AC's Jump (0x2500003b) is an Action motion (mask 0x25000000), - // NOT a SubState cycle. Feeding it to the MotionTable resolver via - // SetCycle produces a failed cycle lookup — which mis-renders the - // character. Proper action playback needs a separate sequencer path - // that honors the motion table's action queue; that's deferred. - // For now the player stays in whatever cycle was active when they - // jumped (usually walk/run or Ready) — animation wise it's wrong but - // at least the character doesn't implode. + // Airborne → Falling (retail behavior; see airborne note above). + // Otherwise: LocalAnimationCommand (RunForward when running) preferred, + // falling back to wire ForwardCommand (WalkForward / WalkBackward). uint animCommand; - if (result.LocalAnimationCommand is { } localCmd) + if (!result.IsOnGround) + animCommand = AcDream.Core.Physics.MotionCommand.Falling; + else if (result.LocalAnimationCommand is { } localCmd) animCommand = localCmd; else if (result.ForwardCommand is { } fwd) animCommand = fwd; diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 4c39583..5adc39b 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -440,15 +440,39 @@ public sealed class AnimationSequencer /// Speed multiplier for the action's framerate. public void PlayAction(uint motionCommand, float speedMod = 1f) { - // Resolve motion data. Modifiers use (style << 16) | (motion & 0xFFFFFF) - // as the styled key, or just (motion & 0xFFFFFF) for style-independent - // entries (matches ACE MotionTable.GetObjectSequence lookup order). - uint styleKey = CurrentStyle << 16; - int keyStyled = (int)(styleKey | (motionCommand & 0xFFFFFFu)); - int keyPlain = (int)(motionCommand & 0xFFFFFFu); + // Resolve motion data. The lookup depends on the command's mask class: + // + // - Action (mask 0x10): stored in the Links dict as the transition + // FROM currentSubstate TO the action motion. Matches ACE + // MotionTable.GetObjectSequence @ line 189-207 (CommandMask.Action). + // - Modifier (mask 0x20): stored in the Modifiers dict, keyed by + // (style<<16) | (motion&0xFFFFFF) (or unstyled key). Matches ACE + // @ line 234-242 (CommandMask.Modifier). + // + // Jump (0x2500003B) has BOTH bits set (0x20|0x04|0x01) but ACE treats + // it via the Modifier path. FallDown (0x10000050) / Jumpup (0x1000004B) + // are pure Actions (mask 0x10) and live in Links. + // + // We try Links first (via GetLink, which reproduces ACE's get_link + // fallback chain). If that fails and the motion is a Modifier, fall + // through to the Modifiers dict. + const uint ActionMask = 0x10000000u; + const uint ModifierMask = 0x20000000u; - if (!_mtable.Modifiers.TryGetValue(keyStyled, out var data)) - _mtable.Modifiers.TryGetValue(keyPlain, out data); + MotionData? data = null; + if ((motionCommand & ActionMask) != 0 && CurrentMotion != 0) + { + // Action: look up the transition link from current substate → action. + data = GetLink(CurrentStyle, CurrentMotion, motionCommand); + } + if (data is null && (motionCommand & ModifierMask) != 0) + { + uint styleKey = CurrentStyle << 16; + int keyStyled = (int)(styleKey | (motionCommand & 0xFFFFFFu)); + int keyPlain = (int)(motionCommand & 0xFFFFFFu); + if (!_mtable.Modifiers.TryGetValue(keyStyled, out data)) + _mtable.Modifiers.TryGetValue(keyPlain, out data); + } if (data is null || data.Anims.Count == 0) return; diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index d55c84b..5b29c65 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -48,14 +48,28 @@ public static class MotionCommand /// 0x40000008 — Fallen (lying on ground). public const uint Fallen = 0x40000008u; /// - /// 0x2500003B — Jump (Modifier action; played via - /// ). NOT a SubState — it - /// overlays the current cycle via the motion table's Modifiers dict. + /// 0x40000015 — Falling (SubState). The airborne cycle. Retail's + /// MotionTable has Links from RunForward/Ready/WalkForward → Falling, + /// and a Cycles entry for (style, Falling) that loops while the body + /// is in the air. Swap via + /// when airborne; swap back to Ready/WalkForward/RunForward on land. + /// + public const uint Falling = 0x40000015u; + /// + /// 0x2500003B — Jump (Modifier flag). NOT an animation trigger; retail + /// uses this as a state flag internally. Kept for future use. /// public const uint Jump = 0x2500003Bu; /// - /// 0x10000050 — FallDown (Action; the landing animation played after - /// a jump. Enqueued via ). + /// 0x1000004B — Jumpup (Action). Not present in the humanoid player + /// motion table's Links dict (empirically verified). Retail uses the + /// Falling SubState for airborne animation instead. + /// + public const uint Jumpup = 0x1000004Bu; + /// + /// 0x10000050 — FallDown (Action). Same story as Jumpup; not in the + /// humanoid motion table's Links. Landing returns to Ready via the + /// regular SetCycle transition. /// public const uint FallDown = 0x10000050u; /// 0x10000057 — Dead.