feat(L.2g-S2a): CMotionInterp inbound funnel in Core + live-retail conformance harness (DEV-1 core)

Port the inbound funnel verbatim into MotionInterpreter:
- MoveToInterpretedState (0x005289c0): raw-state style adopt, FLAT
  copy_movement_from overwrite, apply, then action replay under the
  15-bit server_action_stamp wraparound gate (local player skips its
  own autonomous echoes).
- ApplyInterpretedMovement (0x00528600): my_run_rate cache from
  RunForward speed, then retail dispatch order style -> forward-or-
  Falling -> sidestep(-stop) -> turn(-stop), turn early-return.
- DispatchInterpretedMotion (0x00528360): contact-gated sink dispatch
  (IInterpretedMotionSink = the GetObjectSequence backend the App
  implements); blocked non-action motions take the apply-only path —
  retail's real mechanism behind K-fix17's 'airborne remotes keep
  their cycle' empirical guard.
- contact_allows_move REWRITTEN VERBATIM from the real 0x00528240
  (pseudo-C 305471): Falling/0x40000011 + turns always allowed,
  non-creature weenies + no-gravity bypass, else Contact+OnWalkable.
  The previous body conflated jump_charge_is_allowed (0x00527a50)
  posture checks — the 2026-06-04 deep-dive divergence, now retired.
  Six tests that pinned the misattributed behavior corrected
  (grounded posture does NOT block motion; airborne accepts
  Falling/turns only).
- IWeenieObject.IsCreature (default true) for the gate's non-creature
  bypass.

Conformance harness (the user-requested 'prove it equals retail'
apparatus, layer 1): RetailObserverTraceConformanceTests parses the
LIVE cdb trace of a retail observer (Fixtures/l2g-observer-trace.log,
captured via tools/cdb/l2g-observer.cdb) into 183 golden cases —
each [MTIS] input state replayed through our funnel must produce
retail's exact [DIM] dispatch sequence. 183/183 conformant, including
the airborne jump case (replayed contact-free; pre-gate vs post-gate
accounting + HitGround second-pass truncation documented in-test).
13 synthetic funnel tests cover the branches the trace missed
(actions, stamps, longjump, sidestep).

GameWindow integration (S2b) follows; funnel not yet wired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 18:50:09 +02:00
parent 97e098bf91
commit 7b0cbbda2c
6 changed files with 3693 additions and 55 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,272 @@
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 void ApplyMotion(uint motion, float speed)
=> Calls.Add($"DIM {motion:x8}@{speed:F2}");
public void StopMotion(uint motion)
=> Calls.Add($"STOP {motion:x8}");
}
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);
// The interpreted STATE still flat-copies (velocity uses it on landing).
Assert.Equal(0x44000007u, 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);
}
}

View file

@ -673,35 +673,24 @@ public sealed class MotionInterpreterTests
Assert.True(allowLeft);
}
[Fact]
public void ContactAllowsMove_FallenState_RejectsMove()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.InterpretedState.ForwardCommand = MotionCommand.Fallen;
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
}
[Fact]
public void ContactAllowsMove_DeadState_RejectsMove()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.InterpretedState.ForwardCommand = MotionCommand.Dead;
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
}
// L.2g S2 (2026-07-02): the posture-rejection tests that used to live
// here pinned a MISATTRIBUTED port — the Fallen/Dead/crouch-range checks
// belong to jump_charge_is_allowed (0x00527a50) / motion_allows_jump
// (0x005279e0), NOT to contact_allows_move. The real contact_allows_move
// (0x00528240, pseudo-C 305471) never reads the forward-command posture:
// a grounded creature in ANY posture may receive motion
// (GetObjectSequence handles the posture→locomotion transition via
// links). Verified against the live retail-observer trace
// (RetailObserverTraceConformanceTests, 183/183 dispatch conformant).
[Theory]
[InlineData(MotionCommand.Fallen)]
[InlineData(MotionCommand.Dead)]
[InlineData(MotionCommand.Crouch)]
[InlineData(MotionCommand.Sitting)]
[InlineData(MotionCommand.Sleeping)]
public void ContactAllowsMove_PostureState_RejectsMove(uint postureCommand)
[InlineData(0x41000012u)] // inside the crouch range (0x41000011, 0x41000015)
public void ContactAllowsMove_GroundedPosture_StillAllowsMove(uint postureCommand)
{
var body = MakeGrounded();
var interp = MakeInterp(body);
@ -709,20 +698,25 @@ public sealed class MotionInterpreterTests
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
Assert.True(allowed);
}
[Fact]
public void ContactAllowsMove_CrouchRange_RejectsMove()
public void ContactAllowsMove_AirborneCreature_AcceptsFallingAndTurns_BlocksWalk()
{
var body = MakeGrounded();
// Verbatim 0x00528240: Falling (0x40000015) / Dead-class (0x40000011)
// and TurnLeft/TurnRight are ALWAYS allowed; a gravity-bound creature
// without Contact+OnWalkable is blocked for everything else
// (including the style command, which falls through to the gate).
var body = new PhysicsBody { State = PhysicsStateFlags.Gravity };
var interp = MakeInterp(body);
// 0x41000012 is inside (0x41000011, 0x41000015) — crouch state
interp.InterpretedState.ForwardCommand = 0x41000012u;
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
Assert.True(interp.contact_allows_move(MotionCommand.Falling));
Assert.True(interp.contact_allows_move(0x40000011u));
Assert.True(interp.contact_allows_move(MotionCommand.TurnRight));
Assert.True(interp.contact_allows_move(MotionCommand.TurnLeft));
Assert.False(interp.contact_allows_move(MotionCommand.WalkForward));
Assert.False(interp.contact_allows_move(0x8000003Du));
}
[Fact]

View file

@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// L.2g S2 conformance harness, layer 1: golden fixtures parsed from a LIVE
/// cdb trace of a RETAIL observer client
/// (Fixtures/l2g-observer-trace.log, captured 2026-07-02 with
/// tools/cdb/l2g-observer.cdb while a retail actor ran the structured
/// motion protocol through ACE).
///
/// Each [MTIS] block is an exact INPUT (the InterpretedMotionState retail
/// received, dumped field-by-field) and the [DIM] lines that follow on the
/// same minterp are retail's exact OUTPUT (every DoInterpretedMotion
/// dispatch, in order). We replay every input through acdream's funnel and
/// assert an identical dispatch sequence — retail's actual runtime is the
/// oracle, not anyone's reading of the decomp.
///
/// Exclusions (documented, not silent):
/// - Action-class dispatches (0x10000000 bit) come from the UM's action
/// LIST, which the [MTIS] struct dump doesn't include (LList pointer
/// only) — filtered from both sides; covered by the synthetic action
/// tests in MotionInterpreterFunnelTests instead.
/// - motion==0x80000000 entries (the unpack-level DoMotion(command_ids[0])
/// style call for wire style index 0) — outside
/// move_to_interpreted_state; S3 wires the unpack-level style-on-change.
/// </summary>
public class RetailObserverTraceConformanceTests
{
private sealed record TraceCase(
int Line, string Minterp, InboundInterpretedState Ims, List<uint> Dims);
private sealed class RecordingSink : IInterpretedMotionSink
{
public readonly List<uint> Applied = new();
public void ApplyMotion(uint motion, float speed) => Applied.Add(motion);
public void StopMotion(uint motion) { }
}
private static string TracePath()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !File.Exists(Path.Combine(dir, "AcDream.slnx")))
dir = Directory.GetParent(dir)?.FullName;
Assert.NotNull(dir);
return Path.Combine(dir!, "tests", "AcDream.Core.Tests", "Fixtures", "l2g-observer-trace.log");
}
private static List<TraceCase> ParseTrace()
{
var lines = File.ReadAllLines(TracePath());
var cases = new List<TraceCase>();
var mtisRe = new Regex(@"\[MTIS\] minterp=(\w+)");
var fieldRe = new Regex(@"\+0x(\w+) (\w+)\s+: (\S+)");
var dimRe = new Regex(@"\[DIM\] minterp=(\w+) motion=(\w+)");
for (int i = 0; i < lines.Length; i++)
{
var m = mtisRe.Match(lines[i]);
if (!m.Success) continue;
string minterp = m.Groups[1].Value;
var ims = InboundInterpretedState.Default();
int j = i + 1;
for (; j < lines.Length; j++)
{
var f = fieldRe.Match(lines[j]);
if (!f.Success) break;
string name = f.Groups[2].Value;
string val = f.Groups[3].Value;
uint ParseHex() => Convert.ToUInt32(val, 16);
float ParseF() => float.Parse(val, CultureInfo.InvariantCulture);
switch (name)
{
case "current_style": ims.CurrentStyle = ParseHex(); break;
case "forward_command": ims.ForwardCommand = ParseHex(); break;
case "forward_speed": ims.ForwardSpeed = ParseF(); break;
case "sidestep_command": ims.SideStepCommand = ParseHex(); break;
case "sidestep_speed": ims.SideStepSpeed = ParseF(); break;
case "turn_command": ims.TurnCommand = ParseHex(); break;
case "turn_speed": ims.TurnSpeed = ParseF(); break;
}
}
// Collect this minterp's DIMs until the next UNPACK/MTIS event.
var dims = new List<uint>();
for (; j < lines.Length; j++)
{
if (lines[j].Contains("[UNPACK]") || lines[j].Contains("[MTIS]")) break;
var d = dimRe.Match(lines[j]);
if (d.Success && d.Groups[1].Value == minterp)
dims.Add(Convert.ToUInt32(d.Groups[2].Value, 16));
}
// A SECOND style dispatch marks a second apply_current_movement
// pass fired by the physics tick (HitGround / LeaveGround
// re-apply, CMotionInterp::HitGround 0x00528ac0) — not by this
// UM. Keep only the first pass as this UM's output. (2 cases in
// the capture — the actor's jump/land moments.)
int secondStyle = -1;
for (int k = 1; k < dims.Count; k++)
if (dims[k] == ims.CurrentStyle) { secondStyle = k; break; }
if (secondStyle > 0) dims = dims.Take(secondStyle).ToList();
cases.Add(new TraceCase(i + 1, minterp, ims, dims));
}
return cases;
}
private static bool IsExcluded(uint motion)
=> (motion & 0x10000000u) != 0 // action-list dispatches, not in the dump
|| motion == 0x80000000u; // unpack-level style-index-0 call
[Fact]
public void EveryTracedUm_ProducesRetailsExactDispatchSequence()
{
var cases = ParseTrace();
Assert.True(cases.Count > 100, $"expected >100 traced UMs, parsed {cases.Count}");
int verified = 0;
var failures = new List<string>();
foreach (var c in cases)
{
// The trace breakpoint sits at DoInterpretedMotion ENTRY
// (pre-gate); the sink records POST-gate (GetObjectSequence)
// calls. For grounded bodies they coincide. A Falling dispatch
// for a non-Falling forward command marks the traced body as
// AIRBORNE at that moment (the actor's jump) — replay those
// with contact cleared, and drop the traced style entry from
// the expectation (called pre-gate, blocked post-gate).
bool airborne = c.Ims.ForwardCommand != 0x40000015u
&& c.Dims.Contains(0x40000015u);
var body = new PhysicsBody();
body.State |= PhysicsStateFlags.Gravity;
body.TransientState |= TransientStateFlags.Active;
if (!airborne)
body.TransientState |= TransientStateFlags.Contact
| TransientStateFlags.OnWalkable;
var mi = new MotionInterpreter(body);
var sink = new RecordingSink();
mi.MoveToInterpretedState(c.Ims, sink);
var expected = c.Dims.Where(d => !IsExcluded(d)
&& (!airborne || d != c.Ims.CurrentStyle)).ToList();
var actual = sink.Applied.Where(d => !IsExcluded(d)).ToList();
if (!expected.SequenceEqual(actual))
{
failures.Add(
$"line {c.Line} minterp {c.Minterp}: " +
$"fwd=0x{c.Ims.ForwardCommand:X8}@{c.Ims.ForwardSpeed:F2} " +
$"side=0x{c.Ims.SideStepCommand:X8} turn=0x{c.Ims.TurnCommand:X8} — " +
$"retail [{string.Join(",", expected.Select(d => d.ToString("X8")))}] " +
$"vs acdream [{string.Join(",", actual.Select(d => d.ToString("X8")))}]");
}
else
{
verified++;
}
}
Assert.True(failures.Count == 0,
$"{failures.Count} of {cases.Count} traced UMs diverged "
+ $"({verified} conformant):\n" + string.Join("\n", failures.Take(12)));
}
}