acdream/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs
Erik f423884bd1 feat(physics): R5-V4 behavioral slice — head stance dispatch (all mt) + #164 autonomy bit + mt-0 wire flags
Three unpack_movement parity items (facade deferred per the handoff's
own optional clause — see r5-wiring-handoff §V4 status):

1. HEAD style-on-change (0x00524440 @00524502-0052452c): both GameWindow
   routing heads (remote + player) now dispatch DoMotion(style, ctor
   defaults) when the UM's stance differs from the interp's current
   style, BEFORE the movement-type routing — for EVERY type. Previously
   style applied only through the mt-0 funnel copy, so a chase/turn UM
   (mt 6-9) carrying a stance change started the move in the OLD stance
   (a monster charged in NonCombat posture until the next mt-0). The
   RetailObserverTraceConformanceTests exclusion note updated — the
   trace filter stays (head calls can't appear in a
   MoveToInterpretedState replay) but the production gap it pointed at
   is closed.

2. #164 (closes): the action-replay loop threads each action's autonomy
   into the dispatch params (Autonomous = the 0x1000 splice, raw
   305982) — replayed actions enter the interpreted actions list with
   their real autonomy instead of ctor-default false.

3. mt-0 wire flags consumed (UpdateMotion parsed them since R4-V3):
   0x1 StickToObject → CPhysicsObj::stick_to_object port (0x005127e0:
   resolve target, PartArray radii — 0 when shapeless, guid-as-is for
   acdream's flat entity table — → PositionManager.StickTo; unresolvable
   target → no stick), at BOTH case-0 tails in retail order
   (@00524583-0052458e: funnel apply → stick → longjump flag);
   0x2 StandingLongJump → Motion.StandingLongJump, UNCONDITIONAL write
   (absent flag clears — retail @0052458e). ServerMotionState gains the
   StandingLongJump field.

Conformance: ChaseArm_WithStanceChange_AppliesStanceBeforeTheChase
(harness mirrors the routing-head dispatch) +
Actions_ReplayCarriesAutonomyIntoTheInterpretedList. Suite 4041 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:18:26 +02:00

377 lines
15 KiB
C#

using System.Collections.Generic;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// 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).
/// </summary>
public class MotionInterpreterFunnelTests
{
private sealed class RecordingSink : IInterpretedMotionSink
{
public readonly List<string> 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<InboundMotionAction>? 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 Actions_ReplayCarriesAutonomyIntoTheInterpretedList()
{
// R5-V4 (#164): retail splices the ACTION's autonomy into the
// dispatch params (bit 0x1000 — raw 305982:
// `var_28 ^= ((action.autonomous << 0xc) ^ var_28) & 0x1000`), so a
// replayed action enters the interpreted actions list
// (InterpretedMotionState::AddAction consumes p.Autonomous) with its
// REAL autonomy. Pre-V4 the replay params were ctor-default →
// Autonomous always false.
var mi = GroundedInterp(); // remote posture: IsLocalPlayer = false
var sink = new RecordingSink();
mi.MoveToInterpretedState(
Ims(actions: new[] { new InboundMotionAction(0x10000062u, 5, Autonomous: true, 1f) }), sink);
var entry = Assert.Single(mi.InterpretedState.Actions, a => a.Command == 0x0062);
Assert.True(entry.Autonomous);
}
[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);
}
}