feat(R3-W6): LOCAL PLAYER UNIFICATION — edge-driven retail input; UpdatePlayerAnimation + the synthesis layer DELETED (closes J15)

The local player now drives the SAME pipeline remotes use. The
controller's per-frame RawMotionState rebuild (level-triggered D6.2) is
replaced by retail's CommandInterpreter altitude: key EDGES fire
DoMotion/StopMotion (0x00528d20/0x00528530) and the Shift edge fires
set_hold_run (0x00528b70, caller-0x006b33ca shape) — the interpreter's
own RawState mutates via ApplyMotion/RemoveMotion and every dispatch
routes the funnel + DefaultSink into the sequencer. GameWindow's
UpdatePlayerAnimation (169 lines) is DELETED with both
LocalAnimationCommand/LocalAnimationSpeed MovementResult fields and all
three synthesis sites (K-fix5 pacing, Walk→Run cycle selection, the
auto-walk overrides): run pacing now comes from apply_run_to_command's
my_run_rate promotion — the same source remotes use; airborne-Falling
falls out of contact_allows_move (the funnel's second dispatch).
Auto-walk's #69 turn-phase legs re-expressed as DoMotion(Turn*) edges;
teleport idle = StopCompletely (retail's full stop) + an edge-tracker
reset so held keys walk straight out of a portal.

Map discovery R1 fixed: ChargeJump() now fires at charge START (the
0x0056afac input-boundary site) — StandingLongJump had NEVER armed in
production despite the W3 port.

TWO root-cause fixes the cutover surfaced (each would have been
invisible under the old synthesis layer):
- CurrentHoldKey was a shadow FIELD synced only on the raw dispatch
  branch; set_hold_run writes RawState.CurrentHoldKey, so an edge-driven
  Shift toggle followed by DoMotion read a stale None and lost the run
  promotion. Now an alias of RawState.CurrentHoldKey (retail's
  adjust_motion reads raw_state.current_holdkey directly).
- The controller's grounded velocity + jump-launch writes used
  set_local_velocity's default autonomous:false, silently clearing
  LastMoveWasAutonomous and flipping the A3 dual dispatch to the
  interpreted branch for the local player. Both marked autonomous.

EXPECTED-DIFFS (map §6, for the R3 visual pass): #45's 1.248x sidestep
factor + ACDREAM_ANIM_SPEED_SCALE died with UpdatePlayerAnimation —
local sidestep pacing now matches how remotes have always played;
auto-walk-at-run plays walk-pace legs until R4; ApplyServerRunRate's
apply_current_movement goes live (Branch-2 fast re-speed absorbs the
echo — retail's own mid-run speed-change mechanism).

Wire: outbound values stay input-derived (the L.2b-verified byte
stream untouched; RawState-sourcing needs the map §2b cdb TODO and R7
owns outbound). MotionStateChanged = the old comparison minus the dead
localAnimCmd leg, OR any edge fired.

Full suite green: 3,731 passed. Live smoke: in-world, user-driven
input through the new path (walk/turn/strafe), 12k entities, zero
exceptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 09:19:56 +02:00
parent fc5a2cda28
commit fb7beb706b
4 changed files with 177 additions and 287 deletions

View file

@ -739,8 +739,6 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
private bool _playerMode;
private uint _playerServerGuid;
private uint? _playerCurrentAnimCommand;
private float _playerCurrentAnimSpeed = 1f;
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
private MovementTruthOutbound? _lastMovementTruthOutbound;
@ -8003,7 +8001,6 @@ public sealed class GameWindow : IDisposable
}
// Update the player entity's animation cycle to match current motion.
UpdatePlayerAnimation(result);
}
}
@ -10122,189 +10119,14 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// Phase B.2: switch the locally-controlled player entity's animation cycle
/// to match the current motion command. Only re-resolves when the command
/// actually changes (forward → run, idle → walk, etc.) to avoid re-building
/// the animation entry every frame.
///
/// <para>
/// Action motions (Jump, FallDown, emotes, attacks) are routed through
/// <see cref="AcDream.Core.Physics.AnimationSequencer.PlayAction"/> — they
/// live in the motion table's Modifiers dict, not the Cycles dict, and
/// are inserted into the queue on top of the current cycle instead of
/// replacing it.
/// </para>
/// </summary>
private void UpdatePlayerAnimation(AcDream.App.Input.MovementResult result)
{
if (_dats is null) return;
// ── Airborne SubState (Falling) ────────────────────────────────────
//
// 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).
//
// 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: airborne takes priority (Falling
// SubState), then forward, sidestep, turn, then idle (Ready 0x41000003).
//
// 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.IsOnGround)
animCommand = AcDream.Core.Physics.MotionCommand.Falling;
else if (result.LocalAnimationCommand is { } localCmd)
animCommand = localCmd;
else if (result.ForwardCommand is { } fwd)
animCommand = fwd;
else if (result.SidestepCommand is { } ss)
animCommand = ss;
else if (result.TurnCommand is { } tc)
animCommand = tc;
else
animCommand = 0x41000003u; // Ready (idle)
// Fast path: no command change AND speed delta is negligible. If
// command is unchanged but speed changed, we must still propagate
// so the sequencer can MultiplyCyclicFramerate — keeping the run
// loop smooth without restart.
// K-fix5 (2026-04-26): use LocalAnimationSpeed (cycle pace) NOT
// ForwardSpeed (wire field) — backward+run + strafe+run keep
// ForwardSpeed/SidestepSpeed at 1.0 for ACE compatibility but
// need the local cycle to play at runRate × so the animation
// matches the actual movement velocity.
float newSpeed = result.LocalAnimationSpeed;
bool sameCmd = animCommand == _playerCurrentAnimCommand;
bool sameSpeed = MathF.Abs(newSpeed - _playerCurrentAnimSpeed) < 1e-3f;
if (sameCmd && sameSpeed) return;
_playerCurrentAnimCommand = animCommand;
_playerCurrentAnimSpeed = newSpeed;
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return;
// The player entity may not be in _animatedEntities if a post-spawn
// UpdateMotion removed it (Phase 6.8 pattern). In that case, load
// the Setup and re-register. This is the player's own character so
// we always want it animated in player mode.
if (!_animatedEntities.TryGetValue(pe.Id, out var ae))
{
// A.5 T10: lock around _dats.Get — worker thread may be
// building a landblock mesh concurrently. DatBinReader's
// shared buffer position would corrupt without serialization.
DatReaderWriter.DBObjs.Setup? setup;
lock (_datLock) { setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(pe.SourceGfxObjOrSetupId); }
if (setup is null) return;
_physicsDataCache.CacheSetup(pe.SourceGfxObjOrSetupId, setup);
// Build a minimal part template from the entity's current MeshRefs.
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[pe.MeshRefs.Count];
for (int i = 0; i < pe.MeshRefs.Count; i++)
template[i] = (pe.MeshRefs[i].GfxObjId, pe.MeshRefs[i].SurfaceOverrides);
ae = new AnimatedEntity
{
Entity = pe,
Setup = setup,
Animation = null!, // filled below
LowFrame = 0,
HighFrame = 0,
Framerate = 30f,
Scale = 1f,
PartTemplate = template,
CurrFrame = 0f,
};
_animatedEntities[pe.Id] = ae;
}
// The motion table cycle key is (style << 16) | (command & 0xFFFFFF).
// Without a stance override, the resolver uses the table default
// (which always maps to the idle/Ready cycle regardless of command).
// Pass the NonCombat stance (0x003D) so the resolver builds the
// correct cycle key for walk/run/turn commands.
ushort cmdOverride = (ushort)(animCommand & 0xFFFFu);
const ushort NonCombatStance = 0x003D;
var cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats,
motionTableIdOverride: _playerMotionTableId,
stanceOverride: NonCombatStance,
commandOverride: cmdOverride);
// Sequencer path: SetCycle handles adjust_motion internally
// (TurnLeft→TurnRight with negative speed, etc.)
//
// Speed scaling: K-fix5 (2026-04-26) — use LocalAnimationSpeed
// (the PlayerMovementController-computed cycle pace) instead of
// the wire ForwardSpeed. Forward+Run = runRate; Backward+Run =
// runRate (where ForwardSpeed is the ACE-compatible 1.0);
// Strafe+Run = runRate (where SidestepSpeed is 1.0). Anything
// not in run = 1.0. The animation cycle now visually matches
// the movement velocity in every direction.
if (ae.Sequencer is not null)
{
uint fullStyle = 0x80000000u | (uint)NonCombatStance;
float animSpeed = result.LocalAnimationSpeed > 0f
? result.LocalAnimationSpeed
: 1f;
// ACDREAM_ANIM_SPEED_SCALE: optional visual-pacing knob. Retail's
// animation framerate scales linearly with speedMod (r03 §8.3),
// and our speedMod = runRate. If the visual feel doesn't match
// retail, override via env var (default 1.0 = no change).
float animScale = 1.0f;
if (float.TryParse(
Environment.GetEnvironmentVariable("ACDREAM_ANIM_SPEED_SCALE"),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var s) && s > 0f)
{
animScale = s;
}
// R3-W4: the K-fix18 skipLink flag is DELETED — the instant
// Falling engage comes from the controller's grounded→airborne
// edge firing MotionInterpreter.LeaveGround, whose
// RemoveLinkAnimations seam (bound at sequencer creation)
// strips pending links exactly where retail does.
// #45 (2026-05-06): scale sidestep speedMod to match ACE's
// wire formula. PlayerMovementController hands us a raw
// localAnimSpeed (1.0 slow / runRate fast), but ACE's
// BroadcastMovement converts SidestepSpeed via
// `speed × 3.12 / 1.25 × 0.5`
// (Network/Motion/MovementData.cs:124-131). Without the
// matching multiplier here, the local sidestep cycle plays
// at speedMod = 1.0 while the observer-side cycle plays at
// ~1.248 — local strafe visibly slower than retail (user
// report at #45 close-out of #39).
// Factor = WalkAnimSpeed / SidestepAnimSpeed × 0.5
// = 3.12 / 1.25 × 0.5 = 1.248.
uint cmdLow = animCommand & 0xFFu;
if (cmdLow == 0x0Fu /* SideStepRight */ || cmdLow == 0x10u /* SideStepLeft */)
{
animSpeed *= AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed
/ AcDream.Core.Physics.MotionInterpreter.SidestepAnimSpeed
* 0.5f;
}
ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * animScale);
}
// Legacy path: update the manual slerp fields (for entities without sequencer)
if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) return;
ae.Animation = cycle.Animation;
ae.LowFrame = Math.Max(0, cycle.LowFrame);
ae.HighFrame = Math.Min(cycle.HighFrame, cycle.Animation.PartFrames.Count - 1);
ae.Framerate = cycle.Framerate;
ae.CurrFrame = ae.LowFrame;
}
// R3-W6: UpdatePlayerAnimation DELETED — the player's sequencer is
// driven through the SAME MotionTableDispatchSink/DefaultSink funnel
// remotes use (edge-driven DoMotion/StopMotion/set_hold_run in
// PlayerMovementController; airborne-Falling falls out of
// contact_allows_move + apply_current_movement). The #45 sidestep
// 1.248x factor + ACDREAM_ANIM_SPEED_SCALE died with it —
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have
// always played (w6-cutover-map.md R3).
/// <summary>
/// Phase 3a — re-roll the active DayGroup whenever the current
@ -11845,7 +11667,6 @@ public sealed class GameWindow : IDisposable
_playerController = null;
_chaseCamera = null;
_retailChaseCamera = null;
_playerCurrentAnimCommand = null;
}
else
_window!.Close();
@ -12743,7 +12564,6 @@ public sealed class GameWindow : IDisposable
_playerController = null;
_chaseCamera = null;
_retailChaseCamera = null;
_playerCurrentAnimCommand = null;
_playerMouseDeltaX = 0f;
}
}
@ -12997,6 +12817,12 @@ public sealed class GameWindow : IDisposable
() => playerSeq.Manager.InitializeState();
_playerController.Motion.CheckForCompletedMotions =
playerSeq.Manager.CheckForCompletedMotions;
// R3-W6: the player's cycles are now driven through the SAME
// dispatch sink remotes use (TurnApplied/TurnStopped omitted —
// ObservedOmega is a remote-DR-only concept; local rotation is
// the controller's Yaw integration).
_playerController.Motion.DefaultSink =
new AcDream.Core.Physics.Motion.MotionTableDispatchSink(playerSeq);
}
var q = playerEntity.Rotation;