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.