The two parallel DoInterpretedMotion implementations MERGE into one verbatim pair (0x00528360/@305639): contact gate, StandingLongJump state-only branch, Dead → RemoveLinkAnimations, the jump_error_code double-check WITH the DisableJumpDuringLink 0x20000 leg (W2's TODO(W5) resolved — MovementParameters now flows), ModifyInterpretedState gating, CurCell-null tail, AddToQueue with the real ContextId; StopInterpretedMotion's post-stop Ready node + raw-mirror RemoveMotion. Legacy overloads + ApplyMotionToInterpretedState DELETED. The funnel's public surface unchanged (183-case suite compiles + passes as-is). DoMotion 0x00528d20 verbatim: cancel_moveto interrupt; SetHoldKey(key, cancelMoveToBit) BEFORE adjust_motion; params re-default for the interpreted call; combat-stance gates on the ORIGINAL id (Crouch/Sit/ Sleep → 0x3f/0x40/0x41; & 0x2000000 → 0x42 outside NonCombat); action depth cap ≥6 → 0x45; raw mirror ORIGINAL id. StopMotion 0x00528530 mirror shape. StopCompletely 0x00527e40 with the A9 snapshot quirk (motion_allows_jump on the OLD forward command, stashed in the queued node) + the J9 fix (sidestep/turn SPEEDS untouched — the 1.0 resets were a divergence). PerformMovement flushes zero-tick completions after every dispatched op via the new CheckForCompletedMotions seam (0x0050fe30) — bound App-side per entity (remotes via EnsureRemoteMotionBindings, player at the sequencer bind site). PORT DISCOVERY (not in the W0 pins): retail's DoInterpretedMotion RESULT gates both add_to_queue AND the state write — a void sink let the style dispatch's apply-only failure clobber ForwardCommand before the forward axis read it, regressing 74/183 live-trace cases. IInterpretedMotionSink.ApplyMotion/StopMotion now return bool (documented on the interface with the raw anchors); one funnel assertion corrected with raw-line citations (airborne ForwardCommand ends at Falling under the fully-wired verbatim algorithm). 62 new gate-table tests. Full suite: 3,729 passed. Implemented by a dedicated agent against the W0-pinned spec; seam bound + suite independently re-verified by the orchestrator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
303 lines
12 KiB
C#
303 lines
12 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);
|
|
// R3-W5 correction: DoInterpretedMotion's verbatim body (raw
|
|
// 305575-305631) writes InterpretedState via
|
|
// InterpretedMotionState::ApplyMotion on BOTH the success path
|
|
// (raw 305609-305610, gated on the sink's own result) AND the
|
|
// blocked/apply-only `label_528440` path (raw 305616-305618,
|
|
// UNCONDITIONAL on the ModifyInterpretedState bit — no sink
|
|
// involved at all). This W5 slice is the first port to actually
|
|
// WIRE that state-write (the pre-W5 funnel never called
|
|
// InterpretedMotionState.ApplyMotion from dispatch at all — state
|
|
// was set only by MoveToInterpretedState's flat copy at the top of
|
|
// the call), so the blocked style dispatch DOES flip
|
|
// ForwardCommand to Ready (0x41000003) at step 1, which then makes
|
|
// the Falling substitution dispatch (step 2) ITself write
|
|
// ForwardCommand = Falling (0x40000015) via the SAME mechanism —
|
|
// this replaces the assertion this comment used to make ("state
|
|
// still flat-copies") with the verbatim end state. Only
|
|
// sink.Calls (dispatch ORDER) was ever cdb-verified by this file's
|
|
// module doc; this resulting-state assertion was an untested
|
|
// assumption from the pre-W5 architecture.
|
|
Assert.Equal(0x40000015u, mi.InterpretedState.ForwardCommand);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|