fix(anim): walk-backward leg-twitch + jump-too-low — port ACE negative-speed link path + faster charge

Two animation/movement issues from live verification:

1. Walk-backward leg twitches forward two times before the cycle
   reverses (X key glitch).

   Root cause: AnimationSequencer.GetLink only implemented the
   forward-direction lookup path. ACE's MotionTable.get_link
   (MotionTable.cs:395-426) takes BOTH the substate and the new
   motion's speeds, and switches lookup branches when EITHER speed
   is negative:
     * Forward path: Links[(style<<16) | substate][motion]
     * Reversed path (any negative speed): Links[(style<<16) |
                                            motion][substate]
   For Ready → WalkBackward we adjust_motion to WalkForward at
   speed -0.65 (negative). Our previous code looked up
   Links[Ready][WalkForward] — the "start walking forward"
   transition. Played in reverse, the cursor stranded at the
   wrong cycle frame and produced the user-visible "left leg
   twitches forward two times" before the cycle stabilized.
   With the reversed key Links[WalkForward][Ready] (the "stop
   walking → ready" anim) played at the cycle's negative speed,
   the link smoothly transitions Ready → start-of-cycle, then
   the cycle reverses cleanly.

   GetLink signature changed from (style, fromMotion, toMotion)
   to (style, substate, substateSpeed, motion, speed). Both
   call sites updated: SetCycle passes CurrentSpeedMod +
   adjustedSpeed; the Action-overlay path passes 1f, 1f
   (action overlays are always forward).

2. Jump too low.

   Two changes after deep investigation in named-retail decomp:

   a) Charge rate sped up from 1.0/s → 2.0/s. Retail's PowerBar
      charge constant is illegible in the named decomp (the
      divisor was clobbered in GetPowerBarLevel's FPU stack
      reordering at 0x0056ade0). 2.0/s (full charge in 0.5s)
      matches retail muscle memory better — a tap gives a
      noticeable hop, half-hold a meaningful jump, full-hold
      the maximum.

   b) Default jumpSkill bumped 200 → 300. Retail formula:
        height = (skill / (skill + 1300)) × 22.2 + 0.05
      At extent=1.0:
        skill=200 → 3.01m max (felt too low)
        skill=300 → 4.21m max (closer to retail mid-tier "I
                               can clear that fence" hop)
      Override via ACDREAM_JUMP_SKILL env var.

      Long-term fix is issue #7 — parsing PlayerDescription's
      skill block to apply the server's authoritative skill
      values. Until then, this default is the right baseline.

      (Velocity formula sqrt(height × 19.6) is unchanged and
      matches retail byte-for-byte; we only changed how much
      extent-feeding skill we default to.)

Tests stay 1222 green. The walk-backward fix has no new test
because GetLink is private; the cycle-transition behavior
will be exercised live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-26 15:46:10 +02:00
parent 0ecd4f34ae
commit 32583cdfe4
2 changed files with 88 additions and 23 deletions

View file

@ -130,7 +130,15 @@ public sealed class PlayerMovementController
// Jump charge state.
private bool _jumpCharging;
private float _jumpExtent;
private const float JumpChargeRate = 1.0f; // 0→1 over 1 second
// K-fix6 (2026-04-26): retail's PowerBar charge constant for jump is
// not legible in the named decomp (the divisor was clobbered in
// GetPowerBarLevel's FPU stack reordering at FUN_0056ade0). 2.0/s
// (full charge in 0.5s) feels matches retail muscle memory better
// than the previous 1.0/s — a tap gives a noticeable hop, half-hold
// a meaningful jump, full-hold the maximum extent. The vertical
// velocity formula itself (height × 19.6 → vz) is unchanged and
// matches retail byte-for-byte; only the time-to-fill is faster.
private const float JumpChargeRate = 2.0f;
// Airborne → grounded transition detection. Flipped on every frame where
// the body transitions from airborne to on-walkable; used by the GameWindow
@ -159,12 +167,19 @@ public sealed class PlayerMovementController
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
// Default skills — tuned toward mid-retail feel (jump ≈ 3m at full charge,
// run rate ≈ 2.4x). Real characters' skills come from PlayerDescription
// (0xF7B0/0x0013) which we don't parse yet; override via env vars:
// Default skills — tuned toward mid-retail feel. Real characters'
// skills come from PlayerDescription (0xF7B0/0x0013) which we don't
// parse yet; override via env vars:
// ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
// K-fix6 (2026-04-26): bumped default jump skill from 200 → 300.
// Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1):
// skill=200 → 3.01m max (felt too low — user complaint)
// skill=300 → 4.21m max (closer to a typical retail mid-tier
// character's "I can clear that fence" hop)
// Until #7 ships and PlayerDescription gives us the server's real
// skill, this default is the right "feels like retail" baseline.
int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200;
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 200;
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300;
_weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
_motion = new MotionInterpreter(_body, _weenie);
}

View file

@ -367,9 +367,13 @@ public sealed class AnimationSequencer
return;
}
// Resolve transition link (currentSubstate → adjustedMotion).
// Resolve transition link (currentSubstate → adjustedMotion). Pass
// both speeds — GetLink switches lookup branches based on sign.
// CurrentSpeedMod defaults to 1.0 (positive) on a fresh sequencer,
// so a Ready → WalkBackward transition correctly enters GetLink's
// negative-speed (reversed-key) branch.
MotionData? linkData = CurrentMotion != 0
? GetLink(style, CurrentMotion, adjustedMotion)
? GetLink(style, CurrentMotion, CurrentSpeedMod, adjustedMotion, adjustedSpeed)
: null;
// Resolve target cycle using the ADJUSTED motion (TurnRight not TurnLeft).
@ -766,7 +770,10 @@ public sealed class AnimationSequencer
if ((motionCommand & ActionMask) != 0 && CurrentMotion != 0)
{
// Action: look up the transition link from current substate → action.
data = GetLink(CurrentStyle, CurrentMotion, motionCommand);
// Action overlays always play forward (positive speeds) — the
// action speed mod is the caller-supplied modifier, not part of
// the substate cycle's direction.
data = GetLink(CurrentStyle, CurrentMotion, /*substateSpeed:*/ 1f, motionCommand, /*speed:*/ 1f);
}
if (data is null && (motionCommand & ModifierMask) != 0)
{
@ -855,31 +862,74 @@ public sealed class AnimationSequencer
new() { Direction = AnimationHookDir.Both };
/// <summary>
/// Look up the transition MotionData for going from <paramref name="fromMotion"/>
/// to <paramref name="toMotion"/> within <paramref name="style"/>.
/// Look up the transition MotionData for going from <paramref name="substate"/>
/// (current state, played at <paramref name="substateSpeed"/>) to
/// <paramref name="motion"/> (new state, played at <paramref name="speed"/>).
///
/// Port of ACE's MotionTable.get_link:
/// 1. Try Links[(style&lt;&lt;16)|(fromMotion&amp;0xFFFFFF)][toMotion]
/// 2. Fallback: try Links[style&lt;&lt;16][toMotion]
/// <para>
/// Port of ACE's MotionTable.get_link (MotionTable.cs:395-426). The lookup
/// path differs by sign of the speeds — the retail/ACE mechanism is two
/// distinct branches:
/// <list type="bullet">
/// <item><b>Both speeds positive</b> (forward → forward, normal case):
/// Look up Links[(style&lt;&lt;16) | substate][motion] — the link FROM
/// substate TO motion. Played forward.</item>
/// <item><b>Either speed negative</b> (any direction reversal —
/// WalkBackward, SideStepLeft, TurnLeft): Look up the REVERSED key
/// Links[(style&lt;&lt;16) | motion][substate] — the link FROM motion TO
/// substate. Played in reverse, this anim visually transitions
/// substate → motion's pose, then the cycle continues from where it
/// left off. Without this branch, Ready→WalkBackward would queue the
/// "start walking forward" link played in reverse, which strands the
/// cursor at the wrong cycle frame and causes the user-visible
/// "left leg twitches forward two times" glitch on the X key.</item>
/// </list>
/// </para>
///
/// DatReaderWriter encodes Links as Dictionary&lt;int, MotionCommandData&gt;
/// where MotionCommandData.MotionData is Dictionary&lt;int, MotionData&gt;.
/// </summary>
private MotionData? GetLink(uint style, uint fromMotion, uint toMotion)
private MotionData? GetLink(uint style, uint substate, float substateSpeed, uint motion, float speed)
{
int outerKey1 = (int)((style << 16) | (fromMotion & 0xFFFFFFu));
if (_mtable.Links.TryGetValue(outerKey1, out var cmd1))
if (speed < 0f || substateSpeed < 0f)
{
if (cmd1.MotionData.TryGetValue((int)toMotion, out var result1))
return result1;
// Reversed-direction path: link FROM motion TO substate.
int reversedKey = (int)((style << 16) | (motion & 0xFFFFFFu));
if (_mtable.Links.TryGetValue(reversedKey, out var revLink)
&& revLink.MotionData.TryGetValue((int)substate, out var revResult))
{
return revResult;
}
// Style-defaults fallback per ACE MotionTable.cs:405-409.
if (_mtable.StyleDefaults.TryGetValue(
(DatReaderWriter.Enums.MotionCommand)style, out var defaultMotion))
{
int subKey = (int)((style << 16) | (substate & 0xFFFFFFu));
if (_mtable.Links.TryGetValue(subKey, out var subLink)
&& subLink.MotionData.TryGetValue((int)defaultMotion, out var subResult))
{
return subResult;
}
}
return null;
}
// Fallback: style-level catch-all.
int outerKey2 = (int)(style << 16);
if (_mtable.Links.TryGetValue(outerKey2, out var cmd2))
// Forward-direction path: link FROM substate TO motion (the original
// implementation pre-K-fix6).
int outerKey1 = (int)((style << 16) | (substate & 0xFFFFFFu));
if (_mtable.Links.TryGetValue(outerKey1, out var cmd1)
&& cmd1.MotionData.TryGetValue((int)motion, out var result1))
{
if (cmd2.MotionData.TryGetValue((int)toMotion, out var result2))
return result2;
return result1;
}
// Fallback: style-level catch-all (ACE line 419-422).
int outerKey2 = (int)(style << 16);
if (_mtable.Links.TryGetValue(outerKey2, out var cmd2)
&& cmd2.MotionData.TryGetValue((int)motion, out var result2))
{
return result2;
}
return null;