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:
parent
fc5a2cda28
commit
fb7beb706b
4 changed files with 177 additions and 287 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue