feat(R2-Q5): RemoteMotionSink DELETED — funnel dispatches straight into PerformMovement; AP-73 retired
The funnel's retail-ordered dispatches now go directly into the entity's motion-table stack via Motion/MotionTableDispatchSink (Core): ApplyMotion → PerformMovement(InterpretedCommand), StopMotion → PerformMovement(StopInterpretedCommand). No axis collection, no single-cycle priority pick, no Commit pass, no HasCycle probe, no Run→Walk→Ready fallback chain — GetObjectSequence 0x00522860 + is_allowed decide (closes H4, H5-callers, H11-callers, H15, H17-carry). Run-while-turning now blends for real: the turn is a Branch-4 modifier combined over the untouched run substate (AP-73 DELETED from the register). AnimationSequencer gains the public PerformMovement passthrough (lazy initialize_state + locomotion velocity synthesis on success — AP-75; remote body translation via PositionManager.ComputeOffset depends on CurrentVelocity) and InitializeState(); HasCycle DELETED (the miss hazard is structurally gone — GetObjectSequence checks the cycle before any surgery; new conformance test pins sequence+state untouched on a miss). GameWindow: sink swap with the TurnApplied/TurnStopped ObservedOmega callbacks (H17 carried verbatim — register AP-76, retire R6); spawn + door sites run retail's enter-world order (initialize_state installs the table default, the wire's initial motion dispatches unguarded — the L.1c fallback chains deleted); despawn drains the pending queue via HandleExitWorld (MotionDone success:false per entry, 0x0051bda0). 5 new MotionTableDispatchSink conformance tests (lazy init, Branch-4 turn blend + callback, Case-B stop unwind, Case-A stop re-drive, miss no-op). Full suite green: 3,462 passed. Live smoke vs ACE: in-world, NPC emote/Ready UMs dispatching through the new path, [MOTIONDONE] completions firing across entities — first live proof of the Q3→Q4→Q5 chain. Zero exceptions. Registers: AP-73 deleted; AP-76 added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c072b73686
commit
d82f07d4e5
8 changed files with 399 additions and 395 deletions
|
|
@ -179,9 +179,9 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
| AP-73 | **Remote motion sink maps retail's substate+modifier animation model onto ONE cycle** — the L.2g S2 funnel dispatches retail-verbatim (style/forward/sidestep/turn per `apply_interpreted_movement` order + action-stamp gate), but `RemoteMotionSink.Commit` resolves the axes into a single `SetCycle` (forward locomotion > sidestep > turn priority + Run→Walk→Ready missing-cycle fallback); retail BLENDS modifiers over the substate cycle via `CMotionTable::re_modify` (DEV-9) | `src/AcDream.App/Rendering/RemoteMotionSink.cs` (`Commit`) | The pick + fallback are the pre-S2 behavior moved verbatim (user-verified across L.1/L.3); the STATE side is now fully retail through the funnel, so only the visible-animation composition approximates. Retire with the S3/S6 modifier port (`re_modify` + `subtract_motion` omega/velocity combine) | Run-while-turning shows pure run legs (no lean/turn blend); sidestep-while-walking shows one cycle only — animation-composition mismatch vs retail observers, position unaffected | `CMotionTable::re_modify` (GetObjectSequence tail, pc:298636); `StopSequenceMotion` modifier branch pc:298954 |
|
||||
| AP-74 | **K-fix18 `skipTransitionLink`** post-Q4 expression: `SetCycle(..., skipTransitionLink:true)` strips the just-dispatched transition links via `CSequence.RemoveAllLinkAnimations` so Falling engages instantly on jump start; retail gets the same strip from `LeaveGround` → `CPhysicsObj::RemoveLinkAnimations` (0x0050fe20) fired by the physics layer detecting ground departure (R2-Q4 rehost of the 2026-04-26 K-fix18) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` skip branch); GameWindow :4830/:10201 callers | Same primitive retail uses, fired from the caller instead of the ground-transition event; pending-queue tick counts intentionally not zeroed (retail's RemoveLinkAnimations doesn't touch the manager queue — the countdown chain absorbs the stall) | If a code path sets the flag outside a genuine ground-departure, links retail would play get eaten; queue completion for the stripped entry reports late (next sentinel) | `LeaveGround` 0x00528b00 → `RemoveLinkAnimations` 0x0050fe20; retire in R3-W4 (flag + call sites deleted, LeaveGround fires it) |
|
||||
| AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 |
|
||||
| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) |
|
||||
|
||||
## 4. Temporary stopgap (TS) — 33 rows
|
||||
|
||||
|
|
|
|||
|
|
@ -408,7 +408,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// remote gets the same treatment as the local player.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class RemoteMotion // internal: RemoteMotionSink (L.2g S2b) consumes it
|
||||
internal sealed class RemoteMotion // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
|
||||
{
|
||||
public AcDream.Core.Physics.PhysicsBody Body;
|
||||
public AcDream.Core.Physics.MotionInterpreter Motion;
|
||||
|
|
@ -3708,47 +3708,17 @@ public sealed class GameWindow : IDisposable
|
|||
seqMotion = AcDream.Core.Physics.MotionCommand.Ready;
|
||||
}
|
||||
|
||||
// Phase L.1c followup (2026-04-28): apply the same
|
||||
// missing-cycle fallback the OnLiveMotionUpdated path
|
||||
// uses. Without this, a monster spawned in combat
|
||||
// stance with the wire's seqMotion absent from its
|
||||
// MotionTable hits ClearCyclicTail() with no
|
||||
// replacement enqueue, every body part snaps to its
|
||||
// setup-default offset, and the visual collapses to
|
||||
// "torso on the ground" — visible to acdream
|
||||
// observers when another client is in combat with a
|
||||
// monster, until the first OnLiveMotionUpdated UM
|
||||
// applies the same fallback there.
|
||||
uint spawnCycle = seqMotion;
|
||||
if (!sequencer.HasCycle(seqStyle, spawnCycle))
|
||||
{
|
||||
uint origCycle = spawnCycle;
|
||||
// RunForward → WalkForward → Ready
|
||||
if ((spawnCycle & 0xFFu) == 0x07
|
||||
&& sequencer.HasCycle(seqStyle, 0x45000005u))
|
||||
{
|
||||
spawnCycle = 0x45000005u;
|
||||
}
|
||||
else if (sequencer.HasCycle(seqStyle, 0x41000003u))
|
||||
{
|
||||
spawnCycle = 0x41000003u;
|
||||
}
|
||||
else
|
||||
{
|
||||
spawnCycle = 0;
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"spawn cycle missing for guid=0x{spawn.Guid:X8} mtable=0x{mtableId:X8} " +
|
||||
$"style=0x{seqStyle:X8} requested=0x{origCycle:X8} " +
|
||||
$"→ fallback=0x{spawnCycle:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
if (spawnCycle != 0)
|
||||
sequencer.SetCycle(seqStyle, spawnCycle);
|
||||
// R2-Q5: retail's enter-world sequence — initialize_
|
||||
// state installs the table default (so the entity is
|
||||
// NEVER without a cycle: the L.1c "torso on the
|
||||
// ground" hazard is structurally gone), then the
|
||||
// wire's initial motion dispatches through the
|
||||
// verbatim GetObjectSequence (a missing cycle leaves
|
||||
// the default playing — retail's own miss behavior;
|
||||
// the Run→Walk→Ready fallback chain is deleted with
|
||||
// RemoteMotionSink).
|
||||
sequencer.InitializeState();
|
||||
sequencer.SetCycle(seqStyle, seqMotion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3821,8 +3791,11 @@ public sealed class GameWindow : IDisposable
|
|||
: NonCombatStyle;
|
||||
uint spawnState = spawn.PhysicsState ?? 0u;
|
||||
uint initialCycle = (spawnState & EtherealPs) != 0 ? MotionOn : MotionOff;
|
||||
if (sequencer.HasCycle(initialStyle, initialCycle))
|
||||
sequencer.SetCycle(initialStyle, initialCycle);
|
||||
// R2-Q5: initialize_state guarantees a playing default;
|
||||
// the On/Off dispatch no-ops harmlessly if the door's
|
||||
// table lacks the cycle (HasCycle guard deleted).
|
||||
sequencer.InitializeState();
|
||||
sequencer.SetCycle(initialStyle, initialCycle);
|
||||
|
||||
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
|
||||
for (int i = 0; i < meshRefs.Count; i++)
|
||||
|
|
@ -4210,6 +4183,12 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
_worldState.RemoveEntityByServerGuid(serverGuid);
|
||||
_worldGameState.RemoveById(existingEntity.Id);
|
||||
// R2-Q5: retail's exit-world drain — every pending queue entry fires
|
||||
// MotionDone(success:false) before the object goes away
|
||||
// (MotionTableManager::HandleExitWorld 0x0051bda0; R3 adds the
|
||||
// CMotionInterp-side drain alongside per r3-port-plan §4).
|
||||
if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone))
|
||||
aeGone.Sequencer?.Manager.HandleExitWorld();
|
||||
_animatedEntities.Remove(existingEntity.Id);
|
||||
_classificationCache.InvalidateEntity(existingEntity.Id);
|
||||
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
|
||||
|
|
@ -4528,12 +4507,13 @@ public sealed class GameWindow : IDisposable
|
|||
// MotionInterpreter.MoveToInterpretedState applies it with
|
||||
// retail's exact dispatch order + the 15-bit action-stamp
|
||||
// gate (conformance: RetailObserverTraceConformanceTests,
|
||||
// 183/183 vs the live retail-observer cdb trace), and
|
||||
// RemoteMotionSink maps the gate-passed dispatches onto the
|
||||
// sequencer (axis-priority pick + missing-cycle fallback
|
||||
// moved verbatim from the pre-S2 block; airborne handling is
|
||||
// the funnel's contact_allows_move gate — the retail
|
||||
// mechanism behind the old K-fix17 guard).
|
||||
// 183/183 vs the live retail-observer cdb trace), and the
|
||||
// gate-passed dispatches go straight into the motion-table
|
||||
// stack via MotionTableDispatchSink -> PerformMovement
|
||||
// (R2-Q5; GetObjectSequence + is_allowed decide — no sink-
|
||||
// side pick; airborne handling is the funnel's
|
||||
// contact_allows_move gate, the retail mechanism behind the
|
||||
// old K-fix17 guard).
|
||||
//
|
||||
// MoveTo packets (mt 6/7) reuse the PlanMoveToStart seed as
|
||||
// the funnel's forward command (fullMotion/speedMod computed
|
||||
|
|
@ -4640,11 +4620,32 @@ public sealed class GameWindow : IDisposable
|
|||
ims.Actions = actionList;
|
||||
}
|
||||
|
||||
RemoteMotionSink? sink = ae.Sequencer is not null
|
||||
? new RemoteMotionSink(ae.Sequencer, remoteMot, update.Guid)
|
||||
: null;
|
||||
// R2-Q5: funnel dispatches go STRAIGHT into the motion-
|
||||
// table stack (GetObjectSequence + is_allowed decide) —
|
||||
// RemoteMotionSink's single-cycle pick + fallback chains
|
||||
// are deleted (AP-73 retired). The callbacks are the
|
||||
// ObservedOmega seam: seed remote rotation from the wire
|
||||
// turn this tick (retail rotates from sequence omega in
|
||||
// the per-tick apply_physics chain — R6; register row).
|
||||
AcDream.Core.Physics.Motion.MotionTableDispatchSink? sink = null;
|
||||
if (ae.Sequencer is not null)
|
||||
{
|
||||
var rmForSink = remoteMot;
|
||||
sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(ae.Sequencer)
|
||||
{
|
||||
TurnApplied = (turnMotion, turnSpeed) =>
|
||||
{
|
||||
float signed = (turnMotion & 0xFFu) == 0x0E
|
||||
? -System.MathF.Abs(turnSpeed)
|
||||
: turnSpeed;
|
||||
rmForSink.ObservedOmega = new System.Numerics.Vector3(
|
||||
0f, 0f, -(System.MathF.PI / 2f) * signed);
|
||||
},
|
||||
TurnStopped = () =>
|
||||
rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
|
||||
};
|
||||
}
|
||||
remoteMot.Motion.MoveToInterpretedState(ims, sink);
|
||||
sink?.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,217 +0,0 @@
|
|||
using System;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// L.2g S2b — the App-side <see cref="IInterpretedMotionSink"/> for remote
|
||||
/// entities: receives the funnel's retail-ordered dispatches
|
||||
/// (<c>CMotionInterp::apply_interpreted_movement</c> order: style →
|
||||
/// forward-or-Falling → sidestep → turn → actions) and maps them onto the
|
||||
/// single-cycle <see cref="AnimationSequencer"/>.
|
||||
///
|
||||
/// <para>The axis-priority pick (forward locomotion beats sidestep beats
|
||||
/// turn), the missing-cycle fallback chain (Run→Walk→Ready), and the
|
||||
/// overlay routing for action-class commands are MOVED VERBATIM from the
|
||||
/// pre-S2 <c>GameWindow.OnLiveMotionUpdated</c> block — they are acdream's
|
||||
/// documented approximation of retail's substate+modifier model
|
||||
/// (GetObjectSequence + re_modify, deviation DEV-9) pending the S3/S6
|
||||
/// modifier port. What changed is upstream: the STATE now flows through
|
||||
/// the verbatim CMotionInterp funnel instead of ad-hoc bulk copies.</para>
|
||||
///
|
||||
/// <para>Airborne behavior needs no special-casing here: the funnel's
|
||||
/// verbatim <c>contact_allows_move</c> gate (0x00528240) blocks
|
||||
/// style/locomotion/action dispatches for a gravity-bound creature without
|
||||
/// ground contact — retail's real mechanism behind the old K-fix17
|
||||
/// "preserve the Falling cycle while airborne" guard.</para>
|
||||
///
|
||||
/// Lifecycle: construct per-UM, pass to
|
||||
/// <see cref="MotionInterpreter.MoveToInterpretedState"/>, then call
|
||||
/// <see cref="Commit"/> once to resolve the collected axes into at most one
|
||||
/// SetCycle.
|
||||
/// </summary>
|
||||
internal sealed class RemoteMotionSink : IInterpretedMotionSink
|
||||
{
|
||||
private readonly AnimationSequencer _sequencer;
|
||||
private readonly GameWindow.RemoteMotion _remote;
|
||||
private readonly uint _guid;
|
||||
|
||||
private uint _style; // last style-class dispatch (0x8xxxxxxx)
|
||||
private uint _substate; // first substate-class dispatch (0x4xxxxxxx) — fwd wins by dispatch order
|
||||
private float _substateSpeed = 1f;
|
||||
private uint _sidestep; // modifier-class sidestep (0x6500000F/0x10)
|
||||
private float _sidestepSpeed = 1f;
|
||||
private uint _turn; // modifier-class turn (0x6500000D/0x0E)
|
||||
private float _turnSpeed = 1f;
|
||||
|
||||
public RemoteMotionSink(AnimationSequencer sequencer, GameWindow.RemoteMotion remote, uint guid)
|
||||
{
|
||||
_sequencer = sequencer;
|
||||
_remote = remote;
|
||||
_guid = guid;
|
||||
_style = sequencer.CurrentStyle != 0 ? sequencer.CurrentStyle : 0x8000003Du;
|
||||
}
|
||||
|
||||
public void ApplyMotion(uint motion, float speed)
|
||||
{
|
||||
// Style class (stance): retail dispatches it first each apply pass.
|
||||
if ((motion & 0x80000000u) != 0 && (motion & 0x10000000u) == 0)
|
||||
{
|
||||
_style = motion;
|
||||
return;
|
||||
}
|
||||
|
||||
// Turn/sidestep FIRST: they are Modifier-class (0x65xxxxxx), so the
|
||||
// generic overlay classification below would swallow them — that
|
||||
// exact ordering bug shipped in the first S2b cut and killed
|
||||
// turn-in-place animation + the ObservedOmega seed (S2 smoke,
|
||||
// 2026-07-02: 30 [TURN_WIRE] events, zero turn SetCycles). They are
|
||||
// cycle-axis candidates for the single-cycle pick, not overlays.
|
||||
uint low = motion & 0xFFu;
|
||||
bool isTurn = low is 0x0D or 0x0E && (motion & 0xFF000000u) == 0x65000000u;
|
||||
bool isSidestep = low is 0x0F or 0x10 && (motion & 0xFF000000u) == 0x65000000u;
|
||||
|
||||
if (!isTurn && !isSidestep)
|
||||
{
|
||||
var route = AnimationCommandRouter.Classify(motion);
|
||||
if (route is AnimationCommandRouteKind.Action
|
||||
or AnimationCommandRouteKind.Modifier
|
||||
or AnimationCommandRouteKind.ChatEmote)
|
||||
{
|
||||
// Overlay (attack swings, emotes): append over the current
|
||||
// cyclic tail immediately — the substate cycle is unaffected
|
||||
// (retail Action branch of GetObjectSequence). The funnel's
|
||||
// contact gate already filtered airborne cases.
|
||||
AnimationCommandRouter.RouteFullCommand(
|
||||
_sequencer, _style, motion, speed <= 0f ? 1f : speed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isTurn)
|
||||
{
|
||||
_turn = motion;
|
||||
_turnSpeed = speed;
|
||||
// Seed ObservedOmega from the wire turn so rotation starts THIS
|
||||
// tick; UpdatePosition orientation snaps correct any drift.
|
||||
// TurnLeft arrives as TurnRight + negative speed (adjust_motion
|
||||
// convention) or as the explicit 0x0E command.
|
||||
float signed = low == 0x0E ? -MathF.Abs(speed) : speed;
|
||||
_remote.ObservedOmega = new System.Numerics.Vector3(
|
||||
0, 0, -(MathF.PI / 2f) * signed);
|
||||
return;
|
||||
}
|
||||
if (isSidestep)
|
||||
{
|
||||
_sidestep = motion;
|
||||
_sidestepSpeed = speed;
|
||||
return;
|
||||
}
|
||||
|
||||
// Substate class (Walk/Run/Ready/Falling/door On-Off/...): the
|
||||
// FIRST one wins — the funnel dispatches forward before modifiers,
|
||||
// so forward locomotion naturally outranks the rest.
|
||||
if (_substate == 0)
|
||||
{
|
||||
_substate = motion;
|
||||
_substateSpeed = speed;
|
||||
}
|
||||
}
|
||||
|
||||
public void StopMotion(uint motion)
|
||||
{
|
||||
uint low = motion & 0xFFu;
|
||||
if (low is 0x0D or 0x0E)
|
||||
{
|
||||
// Turn cleared — stop rotating immediately, don't coast.
|
||||
_remote.ObservedOmega = System.Numerics.Vector3.Zero;
|
||||
}
|
||||
// Sidestep/turn STATE was already cleared by the funnel's flat
|
||||
// copy_movement_from; nothing else to do.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the collected axes into the sequencer cycle. Priority
|
||||
/// (pre-S2 picker, moved verbatim): forward locomotion → sidestep →
|
||||
/// turn → whatever substate arrived (Ready included). Missing-cycle
|
||||
/// fallback Run→Walk→Ready avoids SetCycle's unconditional
|
||||
/// ClearCyclicTail on a cycle the MotionTable lacks ("torso on the
|
||||
/// ground").
|
||||
/// </summary>
|
||||
public void Commit()
|
||||
{
|
||||
uint animCycle;
|
||||
float animSpeed;
|
||||
|
||||
uint subLow = _substate & 0xFFu;
|
||||
bool fwdIsRunWalk = subLow is 0x05 or 0x06 or 0x07;
|
||||
|
||||
if (fwdIsRunWalk || (_sidestep == 0 && _turn == 0))
|
||||
{
|
||||
if (_substate == 0) return; // nothing dispatched (airborne UM)
|
||||
animCycle = _substate;
|
||||
animSpeed = _substateSpeed;
|
||||
}
|
||||
else if (_sidestep != 0)
|
||||
{
|
||||
animCycle = _sidestep;
|
||||
animSpeed = _sidestepSpeed;
|
||||
}
|
||||
else
|
||||
{
|
||||
animCycle = _turn;
|
||||
animSpeed = _turnSpeed;
|
||||
}
|
||||
|
||||
// Missing-cycle fallback chain (moved verbatim; see the pre-S2
|
||||
// comment block: regression 186a584, creatures lacking a
|
||||
// (stance, RunForward) cycle).
|
||||
uint cycleToPlay = animCycle;
|
||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
||||
&& (animCycle & 0xFFu) is 0x05u or 0x07u)
|
||||
{
|
||||
bool hc = _sequencer.HasCycle(_style, cycleToPlay);
|
||||
System.Console.WriteLine(
|
||||
$"[HASCYCLE] guid={_guid:X8} style=0x{_style:X8} "
|
||||
+ $"requestedCycle=0x{cycleToPlay:X8} HasCycle={hc}");
|
||||
}
|
||||
if (!_sequencer.HasCycle(_style, cycleToPlay))
|
||||
{
|
||||
uint requested = cycleToPlay;
|
||||
if ((cycleToPlay & 0xFFu) == 0x07
|
||||
&& _sequencer.HasCycle(_style, 0x45000005u))
|
||||
{
|
||||
cycleToPlay = 0x45000005u; // RunForward → WalkForward
|
||||
}
|
||||
else if (_sequencer.HasCycle(_style, 0x41000003u))
|
||||
{
|
||||
cycleToPlay = 0x41000003u; // → Ready
|
||||
}
|
||||
else
|
||||
{
|
||||
cycleToPlay = 0; // leave the existing cycle alone
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"UM cycle missing for guid=0x{_guid:X8} " +
|
||||
$"style=0x{_style:X8} requested=0x{requested:X8} " +
|
||||
$"→ fallback=0x{cycleToPlay:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
if (cycleToPlay == 0) return;
|
||||
|
||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
||||
&& (_sequencer.CurrentMotion != cycleToPlay
|
||||
|| MathF.Abs(_sequencer.CurrentSpeedMod - animSpeed) > 1e-3f))
|
||||
{
|
||||
System.Console.WriteLine(
|
||||
$"[SETCYCLE] guid={_guid:X8} "
|
||||
+ $"old=(motion=0x{_sequencer.CurrentMotion:X8} speed={_sequencer.CurrentSpeedMod:F3}) "
|
||||
+ $"new=(motion=0x{cycleToPlay:X8} speed={animSpeed:F3})");
|
||||
}
|
||||
_sequencer.SetCycle(_style, cycleToPlay, animSpeed);
|
||||
}
|
||||
}
|
||||
|
|
@ -316,31 +316,12 @@ public sealed class AnimationSequencer
|
|||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Check whether the underlying MotionTable contains a cycle for the
|
||||
/// given (style, motion) pair. Useful for callers that want to fall
|
||||
/// back to a known-good motion (e.g. <c>WalkForward</c> →
|
||||
/// <c>Ready</c>) instead of triggering <see cref="SetCycle"/>'s
|
||||
/// unconditional cyclic-tail rebuild on a missing cycle — which leaves
|
||||
/// the body without any animation tail and snaps every part to the
|
||||
/// setup-default offset (visible as "torso on the ground" since most
|
||||
/// creatures' setup-default has limbs at the torso origin).
|
||||
/// </summary>
|
||||
public bool HasCycle(uint style, uint motion)
|
||||
{
|
||||
// adjust_motion remapping (mirrors the head of SetCycle):
|
||||
// TurnLeft, SideStepLeft, WalkBackward map to their right/forward
|
||||
// mirror cycles.
|
||||
uint adjustedMotion = motion;
|
||||
switch (motion & 0xFFFFu)
|
||||
{
|
||||
case 0x000E: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du; break;
|
||||
case 0x0010: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu; break;
|
||||
case 0x0006: adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u; break;
|
||||
}
|
||||
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
|
||||
return _mtable.Cycles.ContainsKey(cycleKey);
|
||||
}
|
||||
// R2-Q5: HasCycle DELETED (caller-free). The missing-cycle hazard it
|
||||
// guarded ("torso on the ground": legacy SetCycle's unconditional
|
||||
// cyclic-tail drop) is structurally gone — the verbatim
|
||||
// GetObjectSequence checks the cycle BEFORE any list surgery and leaves
|
||||
// the sequence untouched on a miss, so the spawn/UM fallback chains
|
||||
// (Run→Walk→Ready) that probed it are retired with RemoteMotionSink.
|
||||
|
||||
/// <summary>
|
||||
/// Switch to a new cyclic motion, prepending any transition link frames
|
||||
|
|
@ -406,7 +387,11 @@ public sealed class AnimationSequencer
|
|||
if (style != 0 && style != _state.Style)
|
||||
_manager.PerformMovement(MotionTableMovement.Interpreted(style, 1f));
|
||||
|
||||
uint dispatchResult = _manager.PerformMovement(
|
||||
// Motion via the PerformMovement passthrough (velocity synthesis on
|
||||
// success — same helper the R2-Q5 funnel sink path uses; the
|
||||
// adjusted motion + adjusted speed produce the identical synthesis
|
||||
// result as the original pair).
|
||||
uint dispatchResult = PerformMovement(
|
||||
MotionTableMovement.Interpreted(adjustedMotion, adjustedSpeed));
|
||||
|
||||
// K-fix18 (2026-04-26, register row survives → R3): instant-engage
|
||||
|
|
@ -423,74 +408,10 @@ public sealed class AnimationSequencer
|
|||
|
||||
// Failed dispatch (missing cycle for this style, is_allowed reject):
|
||||
// retail leaves sequence AND state untouched — no synthesis either.
|
||||
// (Velocity synthesis already ran inside PerformMovement on success.)
|
||||
if (dispatchResult != MotionTableManagerError.Success)
|
||||
return;
|
||||
|
||||
// ── Synthesize CurrentVelocity for locomotion cycles ──────────────
|
||||
// The Humanoid motion table ships every locomotion MotionData with
|
||||
// Flags=0x00 (no HasVelocity), so EnqueueMotionData leaves
|
||||
// CurrentVelocity at Vector3.Zero. That matches the literal retail
|
||||
// dat, but retail's body physics uses CMotionInterp::get_state_velocity
|
||||
// (FUN_00528960) which returns RunAnimSpeed × ForwardSpeed for
|
||||
// RunForward, independent of the dat's HasVelocity flag. The dat
|
||||
// velocity is a separate additive source (kick-off velocity, flying
|
||||
// creatures, etc) not the primary locomotion drive.
|
||||
//
|
||||
// For our sequencer's <see cref="CurrentVelocity"/> to be usable by
|
||||
// consumers (local-player get_state_velocity via Option B, remote
|
||||
// dead-reckoning in GameWindow) it must carry the retail-constant
|
||||
// locomotion value when the dat is silent. Synthesize it here,
|
||||
// post-EnqueueMotionData, only when the cycle is a locomotion cycle
|
||||
// AND the dat didn't populate it.
|
||||
//
|
||||
// Constants match <see cref="MotionInterpreter.RunAnimSpeed"/> etc —
|
||||
// decompiled from _DAT_007c96e0/e4/e8. The velocity is body-local
|
||||
// (+Y = forward, +X = right); consumers rotate into world space via
|
||||
// the owning entity's orientation.
|
||||
// For known locomotion cycles, ALWAYS overwrite CurrentVelocity with
|
||||
// the synthesized value — even if the transition link set
|
||||
// CurrentVelocity from its own HasVelocity flag. The link's velocity
|
||||
// is for the brief transition (e.g. small stride into run-pose); the
|
||||
// cycle's intended steady-state velocity is what consumers (remote
|
||||
// body translation in GameWindow.TickAnimations env-var path) need.
|
||||
// Without this, walking-to-running transitions left CurrentVelocity
|
||||
// at the link's slow pace, and the user reported "it just blips
|
||||
// forward walking" until another motion command (turn, etc) forced
|
||||
// a re-synth.
|
||||
{
|
||||
float yvel = 0f;
|
||||
float xvel = 0f;
|
||||
uint low = motion & 0xFFu;
|
||||
bool isLocomotion = false;
|
||||
switch (low)
|
||||
{
|
||||
case 0x05: // WalkForward
|
||||
yvel = WalkAnimSpeed * adjustedSpeed;
|
||||
isLocomotion = true;
|
||||
break;
|
||||
case 0x06: // WalkBackward — adjust_motion remapped to WalkForward
|
||||
// with speedMod *= -0.65f.
|
||||
yvel = WalkAnimSpeed * adjustedSpeed;
|
||||
isLocomotion = true;
|
||||
break;
|
||||
case 0x07: // RunForward
|
||||
yvel = RunAnimSpeed * adjustedSpeed;
|
||||
isLocomotion = true;
|
||||
break;
|
||||
case 0x0F: // SideStepRight
|
||||
xvel = SidestepAnimSpeed * adjustedSpeed;
|
||||
isLocomotion = true;
|
||||
break;
|
||||
case 0x10: // SideStepLeft — remapped to SideStepRight with
|
||||
// negated speed; same handling as backward walk.
|
||||
xvel = SidestepAnimSpeed * adjustedSpeed;
|
||||
isLocomotion = true;
|
||||
break;
|
||||
}
|
||||
if (isLocomotion)
|
||||
_core.SetVelocity(new Vector3(xvel, yvel, 0f));
|
||||
}
|
||||
|
||||
// ── Synthesize CurrentOmega for turn cycles ───────────────────────
|
||||
// Same story as velocity synthesis above: Humanoid turn MotionData
|
||||
// often ships without HasOmega. Retail clients turn the body via
|
||||
|
|
@ -525,6 +446,75 @@ public sealed class AnimationSequencer
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-Q5: run retail's <c>enter_default_state</c> analog now if it
|
||||
/// hasn't run yet (idempotent). Spawn paths call this so an entity with
|
||||
/// no initial wire motion still plays the table default (retail: every
|
||||
/// CPhysicsObj entering the world runs initialize_state).
|
||||
/// </summary>
|
||||
public void InitializeState() => EnsureInitialized();
|
||||
|
||||
/// <summary>
|
||||
/// R2-Q5: the single dispatch entry — lazy initialize_state, then
|
||||
/// <see cref="MotionTableManager.PerformMovement"/>, then (on a
|
||||
/// successful InterpretedCommand) the locomotion velocity synthesis
|
||||
/// (register AP-75; the consumers are remote body translation via
|
||||
/// PositionManager.ComputeOffset and the local Option-B
|
||||
/// get_state_velocity — retire in R6 when root motion drives the body).
|
||||
/// Omega is deliberately NOT synthesized here: remote rotation is the
|
||||
/// ObservedOmega seam (MotionTableDispatchSink callbacks, retire R6);
|
||||
/// only the SetCycle path keeps the turn-omega fallback.
|
||||
/// </summary>
|
||||
public uint PerformMovement(MotionTableMovement movement)
|
||||
{
|
||||
EnsureInitialized();
|
||||
uint result = _manager.PerformMovement(movement);
|
||||
if (result == MotionTableManagerError.Success
|
||||
&& movement.Type == MovementType.InterpretedCommand)
|
||||
{
|
||||
SynthesizeLocomotionVelocity(movement.Motion, movement.Speed);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overwrite sequence velocity with the retail locomotion constant when
|
||||
/// the dispatched motion is a locomotion cycle. The Humanoid motion
|
||||
/// table ships every locomotion MotionData with a zero Velocity, so
|
||||
/// <c>add_motion</c>'s unconditional set leaves the sequence at zero —
|
||||
/// but retail's body physics uses <c>CMotionInterp::get_state_velocity</c>
|
||||
/// (0x00528960: RunAnimSpeed × ForwardSpeed etc., independent of the
|
||||
/// dat), so consumers of <see cref="CurrentVelocity"/> need the
|
||||
/// constant here. Velocity is body-local (+Y forward, +X right);
|
||||
/// consumers rotate into world space via the entity orientation.
|
||||
/// Constants decompiled from _DAT_007c96e0/e4/e8.
|
||||
/// </summary>
|
||||
private void SynthesizeLocomotionVelocity(uint motion, float speed)
|
||||
{
|
||||
float yvel = 0f;
|
||||
float xvel = 0f;
|
||||
bool isLocomotion = false;
|
||||
switch (motion & 0xFFu)
|
||||
{
|
||||
case 0x05: // WalkForward
|
||||
case 0x06: // WalkBackward (pre-adjust callers; adjusted = 0x05)
|
||||
yvel = WalkAnimSpeed * speed;
|
||||
isLocomotion = true;
|
||||
break;
|
||||
case 0x07: // RunForward
|
||||
yvel = RunAnimSpeed * speed;
|
||||
isLocomotion = true;
|
||||
break;
|
||||
case 0x0F: // SideStepRight
|
||||
case 0x10: // SideStepLeft (pre-adjust callers; adjusted = 0x0F)
|
||||
xvel = SidestepAnimSpeed * speed;
|
||||
isLocomotion = true;
|
||||
break;
|
||||
}
|
||||
if (isLocomotion)
|
||||
_core.SetVelocity(new Vector3(xvel, yvel, 0f));
|
||||
}
|
||||
|
||||
// Retail locomotion constants — mirror of MotionInterpreter.RunAnimSpeed
|
||||
// etc. Kept here to keep AnimationSequencer self-contained for the
|
||||
// synthesize-velocity path above. Values decompiled from _DAT_007c96e0/e4/e8.
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@ public interface IInterpretedMotionSink
|
|||
/// The <c>CPhysicsObj::DoInterpretedMotion → … →
|
||||
/// CMotionTable::GetObjectSequence</c> backend — called for every motion
|
||||
/// that passes <c>contact_allows_move</c>, in retail dispatch order
|
||||
/// (style → forward-or-Falling → sidestep → turn → actions). The App
|
||||
/// implementation decides how the axes map onto the visible
|
||||
/// <c>AnimationSequencer</c> cycle (retaining the HasCycle fallback,
|
||||
/// overlay routing, etc.).
|
||||
/// (style → forward-or-Falling → sidestep → turn → actions). R2-Q5: the
|
||||
/// production implementation is <c>Motion.MotionTableDispatchSink</c>,
|
||||
/// which dispatches straight into the entity's motion-table stack
|
||||
/// (<c>PerformMovement</c> → GetObjectSequence + is_allowed decide) —
|
||||
/// no sink-side axis pick or fallback chain.
|
||||
/// </summary>
|
||||
void ApplyMotion(uint motion, float speed);
|
||||
|
||||
|
|
|
|||
70
src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs
Normal file
70
src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R2-Q5 — the <see cref="IInterpretedMotionSink"/> that dispatches the
|
||||
/// CMotionInterp funnel's retail-ordered motion events STRAIGHT into the
|
||||
/// entity's motion-table stack via
|
||||
/// <see cref="AnimationSequencer.PerformMovement"/> (lazy initialize_state +
|
||||
/// <c>MotionTableManager::PerformMovement</c> 0x0051c0b0).
|
||||
///
|
||||
/// <para>
|
||||
/// This replaces the App-side <c>RemoteMotionSink</c> (deleted): no axis
|
||||
/// collection, no single-cycle priority pick, no Commit pass, no HasCycle
|
||||
/// probe, no Run→Walk→Ready missing-cycle chain — retail's
|
||||
/// <c>GetObjectSequence</c> (0x00522860) + <c>is_allowed</c> decide what
|
||||
/// plays. Forward locomotion installs the substate cycle (Branch 2);
|
||||
/// sidesteps resolve by the dat (cycle when authored, else modifier);
|
||||
/// turns are Branch-4 physics-only modifiers blended over the substate via
|
||||
/// <c>combine_motion</c>/<c>re_modify</c> — the composition the retired
|
||||
/// AP-73 row approximated with one cycle.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The <see cref="TurnApplied"/>/<see cref="TurnStopped"/> callbacks are the
|
||||
/// interim ObservedOmega seam: the App seeds the remote body's angular
|
||||
/// velocity from the wire turn so rotation starts the same tick (retail
|
||||
/// rotates the body from the sequence omega inside the per-tick
|
||||
/// <c>apply_physics</c> chain — R6 scope; register row). They fire BEFORE
|
||||
/// the dispatch so a consumer sees the seed even if the dat lacks the
|
||||
/// modifier entry.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MotionTableDispatchSink : IInterpretedMotionSink
|
||||
{
|
||||
private readonly AnimationSequencer _sequencer;
|
||||
|
||||
/// <summary>Turn-class dispatch observed: (motion, signedSpeed) —
|
||||
/// TurnLeft arrives either as the explicit 0x0E command or as
|
||||
/// TurnRight + negative speed (adjust_motion wire convention).</summary>
|
||||
public Action<uint, float>? TurnApplied { get; set; }
|
||||
|
||||
/// <summary>Turn-class stop observed.</summary>
|
||||
public Action? TurnStopped { get; set; }
|
||||
|
||||
public MotionTableDispatchSink(AnimationSequencer sequencer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sequencer);
|
||||
_sequencer = sequencer;
|
||||
}
|
||||
|
||||
private static bool IsTurn(uint motion)
|
||||
=> (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E;
|
||||
|
||||
public void ApplyMotion(uint motion, float speed)
|
||||
{
|
||||
if (IsTurn(motion))
|
||||
TurnApplied?.Invoke(motion, speed);
|
||||
|
||||
_sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed));
|
||||
}
|
||||
|
||||
public void StopMotion(uint motion)
|
||||
{
|
||||
if (IsTurn(motion))
|
||||
TurnStopped?.Invoke();
|
||||
|
||||
_sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f));
|
||||
}
|
||||
}
|
||||
|
|
@ -224,32 +224,16 @@ public sealed class AnimationSequencerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void HasCycle_PresentInTable_ReturnsTrue()
|
||||
public void SetCycle_MissingCycle_LeavesSequenceAndStateUntouched()
|
||||
{
|
||||
// Phase L.1c followup (2026-04-28): regression guard for
|
||||
// "torso on the ground" — caller (GameWindow MoveTo path) needs
|
||||
// to query the table before SetCycle to avoid the
|
||||
// ClearCyclicTail wipe on a missing cycle.
|
||||
const uint Style = 0x003Cu; // HandCombat
|
||||
const uint Motion = 0x0003u; // Ready
|
||||
const uint AnimId = 0x03000001u;
|
||||
|
||||
var setup = Fixtures.MakeSetup(2);
|
||||
var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
|
||||
// Caller passes the SAME shape SetCycle expects: full style with
|
||||
// class byte (0x80000000) and full motion (0x40000000 / 0x10000000).
|
||||
Assert.True(seq.HasCycle(0x8000003Cu, 0x41000003u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasCycle_MissingFromTable_ReturnsFalse()
|
||||
{
|
||||
const uint Style = 0x003Cu;
|
||||
const uint ReadyMotion = 0x0003u;
|
||||
// R2-Q5: HasCycle + the caller-side fallback chains are DELETED.
|
||||
// The retail mechanism replacing the L.1c "torso on the ground"
|
||||
// guard: GetObjectSequence (0x00522860) checks the cycle BEFORE any
|
||||
// list surgery — a missing cycle leaves the sequence AND MotionState
|
||||
// untouched, so whatever was playing (here the initialize_state
|
||||
// default) keeps playing.
|
||||
const uint Style = 0x8000003Cu; // HandCombat (full command)
|
||||
const uint ReadyMotion = 0x41000003u;
|
||||
const uint AnimId = 0x03000001u;
|
||||
|
||||
var setup = Fixtures.MakeSetup(2);
|
||||
|
|
@ -258,9 +242,17 @@ public sealed class AnimationSequencerTests
|
|||
loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
|
||||
// RunForward (0x44000007) is NOT in the table — caller should
|
||||
// see false and fall back to a known motion (WalkForward / Ready).
|
||||
Assert.False(seq.HasCycle(0x8000003Cu, 0x44000007u));
|
||||
seq.InitializeState();
|
||||
Assert.Equal(ReadyMotion, seq.CurrentMotion);
|
||||
int nodesBefore = seq.QueueCount;
|
||||
|
||||
// RunForward (0x44000007) is NOT in the table — the dispatch fails
|
||||
// and nothing changes (no cyclic-tail wipe, no state overwrite).
|
||||
seq.SetCycle(Style, 0x44000007u);
|
||||
|
||||
Assert.Equal(ReadyMotion, seq.CurrentMotion);
|
||||
Assert.Equal(nodesBefore, seq.QueueCount);
|
||||
Assert.True(seq.HasCurrentNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R2-Q5 — <see cref="MotionTableDispatchSink"/>: the funnel's dispatches go
|
||||
/// straight into <see cref="AnimationSequencer.PerformMovement"/> (no axis
|
||||
/// collection, no priority pick, no fallback chain — GetObjectSequence
|
||||
/// 0x00522860 + is_allowed decide) with the TurnApplied/TurnStopped
|
||||
/// ObservedOmega seam.
|
||||
/// </summary>
|
||||
public class MotionTableDispatchSinkTests
|
||||
{
|
||||
private const uint NC = 0x8000003Du;
|
||||
private const uint Ready = 0x41000003u;
|
||||
private const uint Walk = 0x45000005u;
|
||||
private const uint TurnRight = 0x6500000Du;
|
||||
|
||||
private const uint ReadyAnim = 0x200u;
|
||||
private const uint WalkAnim = 0x201u;
|
||||
|
||||
private sealed class Loader : IAnimationLoader
|
||||
{
|
||||
private readonly System.Collections.Generic.Dictionary<uint, Animation> _anims = new();
|
||||
public void Register(uint id, Animation anim) => _anims[id] = anim;
|
||||
public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null;
|
||||
}
|
||||
|
||||
private static Animation MakeAnim(int frames)
|
||||
{
|
||||
var anim = new Animation();
|
||||
for (int f = 0; f < frames; f++)
|
||||
{
|
||||
var pf = new AnimationFrame(1);
|
||||
pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity });
|
||||
anim.PartFrames.Add(pf);
|
||||
}
|
||||
return anim;
|
||||
}
|
||||
|
||||
private static MotionData MakeMd(uint animId, Vector3? omega = null)
|
||||
{
|
||||
var md = new MotionData();
|
||||
QualifiedDataId<Animation> qid = animId;
|
||||
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 30f });
|
||||
if (omega is { } o)
|
||||
{
|
||||
md.Omega = o;
|
||||
md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasOmega;
|
||||
}
|
||||
return md;
|
||||
}
|
||||
|
||||
private static AnimationSequencer MakeSequencer(bool withTurnModifier = true)
|
||||
{
|
||||
var setup = new Setup();
|
||||
setup.Parts.Add(0x01000000u);
|
||||
setup.DefaultScale.Add(Vector3.One);
|
||||
|
||||
var loader = new Loader();
|
||||
loader.Register(ReadyAnim, MakeAnim(4));
|
||||
loader.Register(WalkAnim, MakeAnim(6));
|
||||
|
||||
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC };
|
||||
mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready;
|
||||
mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(ReadyAnim);
|
||||
mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(WalkAnim);
|
||||
if (withTurnModifier)
|
||||
{
|
||||
mt.Modifiers[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] =
|
||||
MakeMd(ReadyAnim, omega: new Vector3(0f, 0f, 1.2f));
|
||||
}
|
||||
|
||||
return new AnimationSequencer(setup, mt, loader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyMotion_CycleClass_InstallsSubstate_LazyInitialized()
|
||||
{
|
||||
var seq = MakeSequencer();
|
||||
var sink = new MotionTableDispatchSink(seq);
|
||||
|
||||
// Fresh sequencer: the first dispatch lazily runs initialize_state
|
||||
// (retail lazy-create, r3-motioninterp-decomp §6g) then installs
|
||||
// the requested substate.
|
||||
sink.ApplyMotion(Walk, 2.0f);
|
||||
|
||||
Assert.Equal(Walk, seq.CurrentMotion);
|
||||
Assert.Equal(2.0f, seq.CurrentSpeedMod);
|
||||
// Locomotion velocity synthesis ran in the passthrough (AP-75).
|
||||
Assert.Equal(MotionInterpreter.WalkAnimSpeed * 2.0f, seq.CurrentVelocity.Y, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyMotion_Turn_Branch4Modifier_FiresTurnApplied_NoCycleChange()
|
||||
{
|
||||
var seq = MakeSequencer();
|
||||
uint? turnMotion = null;
|
||||
float turnSpeed = 0f;
|
||||
var sink = new MotionTableDispatchSink(seq)
|
||||
{
|
||||
TurnApplied = (m, s) => { turnMotion = m; turnSpeed = s; },
|
||||
};
|
||||
|
||||
sink.ApplyMotion(Walk, 1.0f);
|
||||
sink.ApplyMotion(TurnRight, 1.5f);
|
||||
|
||||
// The AP-73 mechanism for real: substate cycle untouched, the turn
|
||||
// is a MotionState modifier with its dat omega combined.
|
||||
Assert.Equal(Walk, seq.CurrentMotion);
|
||||
Assert.Equal((TurnRight, 1.5f), (turnMotion!.Value, turnSpeed));
|
||||
Assert.Equal(1.2f * 1.5f, seq.CurrentOmega.Z, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopMotion_Turn_FiresTurnStopped_UnwindsModifierPhysics()
|
||||
{
|
||||
var seq = MakeSequencer();
|
||||
bool stopped = false;
|
||||
var sink = new MotionTableDispatchSink(seq) { TurnStopped = () => stopped = true };
|
||||
|
||||
sink.ApplyMotion(Walk, 1.0f);
|
||||
sink.ApplyMotion(TurnRight, 1.5f);
|
||||
sink.StopMotion(TurnRight);
|
||||
|
||||
Assert.True(stopped);
|
||||
// StopSequenceMotion Case B (0x00522fc0): subtract_motion of the dat
|
||||
// omega + modifier unlinked.
|
||||
Assert.Equal(0f, seq.CurrentOmega.Z, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopMotion_CurrentSubstate_ReDrivesToStyleDefault()
|
||||
{
|
||||
var seq = MakeSequencer();
|
||||
var sink = new MotionTableDispatchSink(seq);
|
||||
|
||||
sink.ApplyMotion(Walk, 1.0f);
|
||||
sink.StopMotion(Walk);
|
||||
|
||||
// StopSequenceMotion Case A: stopping the active cycle re-drives
|
||||
// GetObjectSequence toward the style default (Ready).
|
||||
Assert.Equal(Ready, seq.CurrentMotion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyMotion_MissingEverywhere_NoOp_DefaultKeepsPlaying()
|
||||
{
|
||||
var seq = MakeSequencer(withTurnModifier: false);
|
||||
var sink = new MotionTableDispatchSink(seq);
|
||||
|
||||
sink.ApplyMotion(Walk, 1.0f);
|
||||
// 0x44000007 RunForward: no cycle, no modifier -> dispatch fails,
|
||||
// sequence + state untouched (no fallback chain — H4/H5).
|
||||
sink.ApplyMotion(0x44000007u, 2.0f);
|
||||
|
||||
Assert.Equal(Walk, seq.CurrentMotion);
|
||||
Assert.Equal(1.0f, seq.CurrentSpeedMod);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue