fix(anim): jump animation via Falling SubState + kept PlayAction infra for emotes

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.
This commit is contained in:
Erik 2026-04-18 15:32:52 +02:00
parent 08ea2c0af8
commit 6bce9b8019
3 changed files with 70 additions and 48 deletions

View file

@ -3037,46 +3037,30 @@ public sealed class GameWindow : IDisposable
{ {
if (_dats is null) return; if (_dats is null) return;
// ── Action-motion events (jump / land) ───────────────────────────── // ── Airborne SubState (Falling) ────────────────────────────────────
// //
// Retail does NOT animate jumps — confirmed via ACE's HandleActionJump // Retail models the jump-animation as a SubState swap to
// (Player.cs:914-915) which explicitly clears PendingMotions and // MotionCommand.Falling (0x40000015) while airborne, NOT as an
// sets IsAnimating=false during the jump. The character keeps // Action overlay. Empirically verified: Links[(NonCombat,RunForward)]
// whatever cycle it was on and the physics body arcs through the air. // has 3 transitions including 0x40000015 Falling. The SubState cycle
// Humanoid Setup MotionTables have NO entry for Jump (0x2500003B) // for Falling lives in Cycles[(style, Falling)] and loops while
// or FallDown (0x10000050) in the Modifiers dict — verified empirically // airborne. On land, we transition back to whatever SubState the
// (only 8 TurnRight stance-variants + SideStepRight). // motion input implies (Ready / WalkForward / RunForward).
// //
// We still call PlayAction here as a no-op safety hatch: if a future // Implementation: force animCommand = Falling when airborne; the
// Setup / creature DOES carry a jump/fall modifier in its MotionTable // existing SetCycle pathway resolves the link + cycle correctly and
// (e.g. a leaping-monster) the sequencer will pick it up for free. // the transition back happens naturally when airborne becomes false.
// 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);
}
}
// Determine the animation command: forward takes priority, then sidestep, // Determine the animation command: airborne takes priority (Falling
// then turn, then idle (Ready 0x41000003). // SubState), then forward, sidestep, turn, then idle (Ready 0x41000003).
// //
// Note: AC's Jump (0x2500003b) is an Action motion (mask 0x25000000), // Airborne → Falling (retail behavior; see airborne note above).
// NOT a SubState cycle. Feeding it to the MotionTable resolver via // Otherwise: LocalAnimationCommand (RunForward when running) preferred,
// SetCycle produces a failed cycle lookup — which mis-renders the // falling back to wire ForwardCommand (WalkForward / WalkBackward).
// 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.
uint animCommand; uint animCommand;
if (result.LocalAnimationCommand is { } localCmd) if (!result.IsOnGround)
animCommand = AcDream.Core.Physics.MotionCommand.Falling;
else if (result.LocalAnimationCommand is { } localCmd)
animCommand = localCmd; animCommand = localCmd;
else if (result.ForwardCommand is { } fwd) else if (result.ForwardCommand is { } fwd)
animCommand = fwd; animCommand = fwd;

View file

@ -440,15 +440,39 @@ public sealed class AnimationSequencer
/// <param name="speedMod">Speed multiplier for the action's framerate.</param> /// <param name="speedMod">Speed multiplier for the action's framerate.</param>
public void PlayAction(uint motionCommand, float speedMod = 1f) public void PlayAction(uint motionCommand, float speedMod = 1f)
{ {
// Resolve motion data. Modifiers use (style << 16) | (motion & 0xFFFFFF) // Resolve motion data. The lookup depends on the command's mask class:
// as the styled key, or just (motion & 0xFFFFFF) for style-independent //
// entries (matches ACE MotionTable.GetObjectSequence lookup order). // - Action (mask 0x10): stored in the Links dict as the transition
uint styleKey = CurrentStyle << 16; // FROM currentSubstate TO the action motion. Matches ACE
int keyStyled = (int)(styleKey | (motionCommand & 0xFFFFFFu)); // MotionTable.GetObjectSequence @ line 189-207 (CommandMask.Action).
int keyPlain = (int)(motionCommand & 0xFFFFFFu); // - 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)) MotionData? data = null;
_mtable.Modifiers.TryGetValue(keyPlain, out data); 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) if (data is null || data.Anims.Count == 0)
return; return;

View file

@ -48,14 +48,28 @@ public static class MotionCommand
/// <summary>0x40000008 — Fallen (lying on ground).</summary> /// <summary>0x40000008 — Fallen (lying on ground).</summary>
public const uint Fallen = 0x40000008u; public const uint Fallen = 0x40000008u;
/// <summary> /// <summary>
/// 0x2500003B — Jump (Modifier action; played via /// 0x40000015 — Falling (SubState). The airborne cycle. Retail's
/// <see cref="AnimationSequencer.PlayAction"/>). NOT a SubState — it /// MotionTable has Links from RunForward/Ready/WalkForward → Falling,
/// overlays the current cycle via the motion table's Modifiers dict. /// and a Cycles entry for (style, Falling) that loops while the body
/// is in the air. Swap via <see cref="AnimationSequencer.SetCycle"/>
/// when airborne; swap back to Ready/WalkForward/RunForward on land.
/// </summary>
public const uint Falling = 0x40000015u;
/// <summary>
/// 0x2500003B — Jump (Modifier flag). NOT an animation trigger; retail
/// uses this as a state flag internally. Kept for future use.
/// </summary> /// </summary>
public const uint Jump = 0x2500003Bu; public const uint Jump = 0x2500003Bu;
/// <summary> /// <summary>
/// 0x10000050 — FallDown (Action; the landing animation played after /// 0x1000004B — Jumpup (Action). Not present in the humanoid player
/// a jump. Enqueued via <see cref="AnimationSequencer.PlayAction"/>). /// motion table's Links dict (empirically verified). Retail uses the
/// Falling SubState for airborne animation instead.
/// </summary>
public const uint Jumpup = 0x1000004Bu;
/// <summary>
/// 0x10000050 — FallDown (Action). Same story as Jumpup; not in the
/// humanoid motion table's Links. Landing returns to Ready via the
/// regular SetCycle transition.
/// </summary> /// </summary>
public const uint FallDown = 0x10000050u; public const uint FallDown = 0x10000050u;
/// <summary>0x10000057 — Dead.</summary> /// <summary>0x10000057 — Dead.</summary>