using System.Collections.Generic; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// L.2g S2 — the inbound CMotionInterp funnel (deviation DEV-1). /// /// Oracle: docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md /// (decomp: move_to_interpreted_state 0x005289c0 @305936, /// apply_interpreted_movement 0x00528600 @305713) — validated against the /// LIVE retail-observer cdb trace (l2g-observer-trace.log), which showed the /// per-UM dispatch order verbatim: style → forward → sidestep(-stop) → /// turn(-stop). /// public class MotionInterpreterFunnelTests { private sealed class RecordingSink : IInterpretedMotionSink { public readonly List Calls = new(); public bool ApplyMotion(uint motion, float speed) { Calls.Add($"DIM {motion:x8}@{speed:F2}"); // R3-W5: a style/stance id (>= 0x80000000, i.e. negative as // int32) has no locomotion MotionData entry in the dat — retail's // real CMotionTable::DoObjectMotion genuinely fails for it // (MotionTableManagerError.MotionFailed). The fake sink mirrors // that so InterpretedMotionState.ForwardCommand isn't clobbered // by ApplyMotion's negative-motion branch before the very next // dispatch reads it — matches the live retail-observer trace. return motion < 0x80000000u; } public bool StopMotion(uint motion) { Calls.Add($"STOP {motion:x8}"); return true; } } private static MotionInterpreter GroundedInterp() { var body = new PhysicsBody(); body.State |= PhysicsStateFlags.Gravity; body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active; return new MotionInterpreter(body); } private static InboundInterpretedState Ims( uint style = 0x8000003Du, uint fwd = 0x41000003u, float fwdSpd = 1.0f, uint side = 0u, float sideSpd = 1.0f, uint turn = 0u, float turnSpd = 1.0f, IReadOnlyList? actions = null) => new() { CurrentStyle = style, ForwardCommand = fwd, ForwardSpeed = fwdSpd, SideStepCommand = side, SideStepSpeed = sideSpd, TurnCommand = turn, TurnSpeed = turnSpd, Actions = actions, }; [Fact] public void EmptyUm_DispatchesStyleThenReadyThenStops_RetailOrder() { // The flags=0 "empty" UM: all defaults → a wholesale stop. Live-trace // golden (actor minterp 18e8b0f8): DIM style, DIM Ready, then the // sidestep + turn stop notifications. var mi = GroundedInterp(); var sink = new RecordingSink(); mi.MoveToInterpretedState(Ims(), sink); Assert.Equal(new[] { "DIM 8000003d@1.00", "DIM 41000003@1.00", "STOP 6500000f", "STOP 6500000d", }, sink.Calls); Assert.Equal(0x41000003u, mi.InterpretedState.ForwardCommand); Assert.Equal(1.0f, mi.InterpretedState.ForwardSpeed); } [Fact] public void RunUm_DispatchesRunAtWireSpeed_AndCachesMyRunRate() { // fwd=RunForward@2.85 — apply_interpreted_movement caches // my_run_rate from forward_speed BEFORE dispatching (305718). var mi = GroundedInterp(); var sink = new RecordingSink(); mi.MoveToInterpretedState(Ims(fwd: 0x44000007u, fwdSpd: 2.85f), sink); Assert.Equal(new[] { "DIM 8000003d@1.00", "DIM 44000007@2.85", "STOP 6500000f", "STOP 6500000d", }, sink.Calls); Assert.Equal(2.85f, mi.MyRunRate); Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); Assert.Equal(2.85f, mi.InterpretedState.ForwardSpeed); } [Fact] public void WalkUm_DoesNotTouchMyRunRate() { var mi = GroundedInterp(); mi.MyRunRate = 2.5f; mi.MoveToInterpretedState(Ims(fwd: 0x45000005u), new RecordingSink()); Assert.Equal(2.5f, mi.MyRunRate); // only RunForward caches } [Fact] public void RunPlusTurnUm_TurnDispatched_NoTurnStop_NoIdleEnqueue() { // turn_command != 0 → DIM(turn) then EARLY RETURN — no turn-stop, // no idle bookkeeping (305711-305713 early return). var mi = GroundedInterp(); var sink = new RecordingSink(); mi.MoveToInterpretedState( Ims(fwd: 0x44000007u, fwdSpd: 2.85f, turn: 0x6500000Du, turnSpd: 1.5f), sink); Assert.Equal(new[] { "DIM 8000003d@1.00", "DIM 44000007@2.85", "STOP 6500000f", "DIM 6500000d@1.50", }, sink.Calls); } [Fact] public void SidestepUm_DispatchedInsteadOfSidestepStop() { var mi = GroundedInterp(); var sink = new RecordingSink(); mi.MoveToInterpretedState(Ims(side: 0x6500000Fu, sideSpd: -1.2f), sink); Assert.Equal(new[] { "DIM 8000003d@1.00", "DIM 41000003@1.00", "DIM 6500000f@-1.20", "STOP 6500000d", }, sink.Calls); } [Fact] public void AirborneBody_NoCycleDispatches_OnlyTurnStop() { // Airborne (gravity on, no Contact): apply_interpreted_movement // substitutes DIM(Falling 0x40000015) for the forward block // (305723-305727), but DoInterpretedMotion's OWN contact gate // (0x00528360) then takes the apply-only path for style + Falling — // GetObjectSequence never fires. This is retail's real mechanism // behind the K-fix17 "preserve the Falling cycle while airborne" // empirical guard: airborne remotes simply don't re-cycle from UMs. // Only the unconditional turn-stop notification comes through. var body = new PhysicsBody(); // no Contact flag body.State |= PhysicsStateFlags.Gravity; var mi = new MotionInterpreter(body); var sink = new RecordingSink(); mi.MoveToInterpretedState(Ims(fwd: 0x44000007u, fwdSpd: 2.85f), sink); // Falling (0x40000015) is ALWAYS allowed by contact_allows_move // (0x00528240 early-accept), so it reaches the sink; the style and // forward dispatches are gate-blocked (apply-only path). Assert.Equal(new[] { "DIM 40000015@1.00", "STOP 6500000d" }, sink.Calls); // #161 correction (2026-07-03): the apply pass runs its dispatches // with ModifyInterpretedState = FALSE — retail constructs var_2c // then CLEARS bits 11/14/15 (SetHoldKey / ModifyInterpretedState / // CancelMoveTo) and re-sets 15/17 from the args; the BN pseudo-C // smears that bitfield store into the mush expression at raw // 305778 (`(word & 0x37ff) | (arg2&1)<<15 | (arg3&1)<<17`). ACE // MotionInterp.cs:444-449 confirms independently. So NEITHER the // blocked style dispatch NOR the Falling substitution writes // InterpretedState — the wire's forward command survives the // airborne pass. This is the retail landing-exit mechanism: // HitGround's re-apply dispatches the PRESERVED command, and the // motion table plays the Falling→X landing link. (The previous // revision of this assertion pinned 0x40000015 — the #161 bug // itself: the ctor-default params let the Falling dispatch clobber // forward_command, so a stand-still landing re-dispatched Falling // forever.) Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); Assert.Equal(2.85f, mi.InterpretedState.ForwardSpeed); } [Fact] public void HitGround_AfterFall_RedispatchesPreservedForward_ExitsFalling() { // #161 (remote jump landing stuck in the falling pose): the full // remote lifecycle. Wire says RunForward@2.85 while grounded; the // body leaves the ground (LeaveGround engages Falling through the // sink WITHOUT clobbering the interpreted forward command — the // apply pass's ModifyInterpretedState=false, raw 305778 / ACE // MotionInterp.cs:447); on touchdown, HitGround (0x00528ac0) — // called with GRAVITY STILL SET, its verbatim state&0x400 gate — // re-applies the PRESERVED command, which is what makes // GetObjectSequence play the Falling→RunForward landing link. No // wire input is needed to exit the falling pose. var body = new PhysicsBody(); body.State |= PhysicsStateFlags.Gravity; body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active; var mi = new MotionInterpreter(body, new RemoteWeenie()); var sink = new RecordingSink(); mi.DefaultSink = sink; // HitGround/LeaveGround re-apply through DefaultSink mi.MoveToInterpretedState(Ims(fwd: 0x44000007u, fwdSpd: 2.85f), sink); Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); // Jump start: ground contact drops FIRST (GameWindow's VectorUpdate // handler order), then LeaveGround re-applies → Falling engages. body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); sink.Calls.Clear(); mi.LeaveGround(); Assert.Contains(sink.Calls, c => c.StartsWith("DIM 40000015")); Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); // NOT clobbered Assert.Equal(2.85f, mi.InterpretedState.ForwardSpeed); // Touchdown: contact restored, Gravity still set (the retail // contract — CMotionInterp::HitGround no-ops without it). body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; sink.Calls.Clear(); mi.HitGround(); // Retail re-apply order: style (from the copy_movement_from-adopted // interpreted current_style, raw 0051e757) → preserved forward → // sidestep-stop → turn-stop. The forward dispatch at the wire // command IS the falling-pose exit. Assert.Equal(new[] { "DIM 8000003d@1.00", "DIM 44000007@2.85", "STOP 6500000f", "STOP 6500000d", }, sink.Calls); } [Fact] public void FlatCopy_OverwritesEveryAxis() { // copy_movement_from (0x0051e750) is a FLAT overwrite — a fresh UM // with defaults clears a previously-set sidestep/turn. var mi = GroundedInterp(); mi.MoveToInterpretedState( Ims(fwd: 0x44000007u, fwdSpd: 2.85f, side: 0x6500000Fu, turn: 0x6500000Du), new RecordingSink()); mi.MoveToInterpretedState(Ims(), new RecordingSink()); Assert.Equal(0x41000003u, mi.InterpretedState.ForwardCommand); Assert.Equal(0u, mi.InterpretedState.SideStepCommand); Assert.Equal(0u, mi.InterpretedState.TurnCommand); } [Fact] public void RawStateStyle_AdoptedFromIms() { // move_to_interpreted_state head: raw_state.current_style = // ims.current_style (305944). var mi = GroundedInterp(); mi.MoveToInterpretedState(Ims(style: 0x8000003Cu), new RecordingSink()); Assert.Equal(0x8000003Cu, mi.RawState.CurrentStyle); } // ── action list: 15-bit server_action_stamp gate (305953-305989) ────── [Fact] public void Actions_FreshStamp_DispatchedAfterMovement_AndStampAdopted() { var mi = GroundedInterp(); var sink = new RecordingSink(); var actions = new[] { new InboundMotionAction(0x10000062u, Stamp: 5, Autonomous: false, Speed: 1.25f) }; mi.MoveToInterpretedState(Ims(actions: actions), sink); Assert.Equal("DIM 10000062@1.25", sink.Calls[^1]); // after the movement dispatches Assert.Equal(5, mi.ServerActionStamp); } [Fact] public void Actions_StaleStamp_Skipped() { var mi = GroundedInterp(); mi.ServerActionStamp = 10; var sink = new RecordingSink(); mi.MoveToInterpretedState( Ims(actions: new[] { new InboundMotionAction(0x10000062u, 9, false, 1f) }), sink); Assert.DoesNotContain(sink.Calls, c => c.StartsWith("DIM 10000062")); Assert.Equal(10, mi.ServerActionStamp); } [Fact] public void Actions_StampWrapsAt15Bits() { // The compare is 15-bit wraparound (mask 0x7fff, threshold 0x3fff). var mi = GroundedInterp(); mi.ServerActionStamp = 0x7FFE; var sink = new RecordingSink(); mi.MoveToInterpretedState( Ims(actions: new[] { new InboundMotionAction(0x10000062u, 2, false, 1f) }), sink); Assert.Contains(sink.Calls, c => c.StartsWith("DIM 10000062")); // 2 is newer than 0x7ffe Assert.Equal(2, mi.ServerActionStamp); } [Fact] public void Actions_AutonomousOnLocalPlayer_Skipped() { // Retail skips autonomous action replay on the LOCAL player (its own // echo); remotes always apply (305977-305987). var body = new PhysicsBody(); body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; var mi = new MotionInterpreter(body) { IsLocalPlayer = true }; var sink = new RecordingSink(); mi.MoveToInterpretedState( Ims(actions: new[] { new InboundMotionAction(0x10000062u, 5, Autonomous: true, 1f) }), sink); Assert.DoesNotContain(sink.Calls, c => c.StartsWith("DIM 10000062")); } [Fact] public void InboundState_Defaults_MatchRetailUnPack() { // InterpretedMotionState::UnPack absent-field defaults (0x0051f400): // style NonCombat, fwd Ready, speeds 1.0, side/turn 0. var d = InboundInterpretedState.Default(); Assert.Equal(0x8000003Du, d.CurrentStyle); Assert.Equal(0x41000003u, d.ForwardCommand); Assert.Equal(1.0f, d.ForwardSpeed); Assert.Equal(0u, d.SideStepCommand); Assert.Equal(1.0f, d.SideStepSpeed); Assert.Equal(0u, d.TurnCommand); Assert.Equal(1.0f, d.TurnSpeed); } }