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:
parent
97e098bf91
commit
7b0cbbda2c
6 changed files with 3693 additions and 55 deletions
74
src/AcDream.Core/Physics/InterpretedMotionFunnel.cs
Normal file
74
src/AcDream.Core/Physics/InterpretedMotionFunnel.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L.2g S2 — supporting types for the inbound CMotionInterp funnel
|
||||||
|
/// (deviation DEV-1). Spec:
|
||||||
|
/// docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md; decomp:
|
||||||
|
/// MovementManager::unpack_movement 0x00524440,
|
||||||
|
/// CMotionInterp::move_to_interpreted_state 0x005289c0,
|
||||||
|
/// apply_interpreted_movement 0x00528600, DoInterpretedMotion 0x00528360 —
|
||||||
|
/// dispatch order validated against a LIVE retail-observer cdb trace
|
||||||
|
/// (tools/cdb/l2g-observer.cdb).
|
||||||
|
/// </summary>
|
||||||
|
public interface IInterpretedMotionSink
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.).
|
||||||
|
/// </summary>
|
||||||
|
void ApplyMotion(uint motion, float speed);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>StopInterpretedMotion</c> notification for an axis the incoming
|
||||||
|
/// state cleared (sidestep 0x6500000F / turn 0x6500000D). Unconditional
|
||||||
|
/// (no contact gate) per apply_interpreted_movement.
|
||||||
|
/// </summary>
|
||||||
|
void StopMotion(uint motion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One entry of the inbound action list (retail <c>MotionItem</c> /
|
||||||
|
/// <c>InterpretedMotionState.actions</c>): a one-shot command with the
|
||||||
|
/// 15-bit server action stamp + autonomy bit
|
||||||
|
/// (<c>u16 ((stamp & 0x7FFF) | (autonomous ? 0x8000 : 0))</c>).
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct InboundMotionAction(
|
||||||
|
uint Command, int Stamp, bool Autonomous, float Speed);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A fully-decoded inbound <c>InterpretedMotionState</c>: wire fields where
|
||||||
|
/// present, retail UnPack defaults where absent
|
||||||
|
/// (<c>InterpretedMotionState::UnPack</c> 0x0051f400 /
|
||||||
|
/// ctor 0x0051e8d0). A flags=0 "empty" UM decodes to exactly
|
||||||
|
/// <see cref="Default"/> — a retail-verbatim full stop.
|
||||||
|
/// </summary>
|
||||||
|
public struct InboundInterpretedState
|
||||||
|
{
|
||||||
|
public uint CurrentStyle;
|
||||||
|
public uint ForwardCommand;
|
||||||
|
public float ForwardSpeed;
|
||||||
|
public uint SideStepCommand;
|
||||||
|
public float SideStepSpeed;
|
||||||
|
public uint TurnCommand;
|
||||||
|
public float TurnSpeed;
|
||||||
|
public IReadOnlyList<InboundMotionAction>? Actions;
|
||||||
|
|
||||||
|
public static InboundInterpretedState Default() => new()
|
||||||
|
{
|
||||||
|
CurrentStyle = 0x8000003Du,
|
||||||
|
ForwardCommand = 0x41000003u,
|
||||||
|
ForwardSpeed = 1.0f,
|
||||||
|
SideStepCommand = 0u,
|
||||||
|
SideStepSpeed = 1.0f,
|
||||||
|
TurnCommand = 0u,
|
||||||
|
TurnSpeed = 1.0f,
|
||||||
|
Actions = null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -147,6 +147,11 @@ public enum WeenieError : uint
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public struct LegacyRawMotionState
|
public struct LegacyRawMotionState
|
||||||
{
|
{
|
||||||
|
/// <summary>Current style / stance (retail raw_state.current_style,
|
||||||
|
/// offset +0x18). Adopted from the inbound InterpretedMotionState by
|
||||||
|
/// move_to_interpreted_state (0x005289c0 line 305944). Default
|
||||||
|
/// NonCombat 0x8000003D.</summary>
|
||||||
|
public uint CurrentStyle;
|
||||||
/// <summary>Forward/backward motion command (offset +0x20).</summary>
|
/// <summary>Forward/backward motion command (offset +0x20).</summary>
|
||||||
public uint ForwardCommand;
|
public uint ForwardCommand;
|
||||||
/// <summary>Speed scalar for forward motion (offset +0x28).</summary>
|
/// <summary>Speed scalar for forward motion (offset +0x28).</summary>
|
||||||
|
|
@ -163,6 +168,7 @@ public struct LegacyRawMotionState
|
||||||
/// <summary>Initialize to the idle/ready state (1.0 speed, Ready command).</summary>
|
/// <summary>Initialize to the idle/ready state (1.0 speed, Ready command).</summary>
|
||||||
public static LegacyRawMotionState Default() => new()
|
public static LegacyRawMotionState Default() => new()
|
||||||
{
|
{
|
||||||
|
CurrentStyle = 0x8000003Du,
|
||||||
ForwardCommand = MotionCommand.Ready,
|
ForwardCommand = MotionCommand.Ready,
|
||||||
ForwardSpeed = 1.0f,
|
ForwardSpeed = 1.0f,
|
||||||
SideStepCommand = 0,
|
SideStepCommand = 0,
|
||||||
|
|
@ -238,6 +244,15 @@ public interface IWeenieObject
|
||||||
bool InqRunRate(out float rate);
|
bool InqRunRate(out float rate);
|
||||||
/// <summary>vtable +0x3C — CanJump. Returns true if the weenie can jump at this extent.</summary>
|
/// <summary>vtable +0x3C — CanJump. Returns true if the weenie can jump at this extent.</summary>
|
||||||
bool CanJump(float extent);
|
bool CanJump(float extent);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>CWeenieObject::IsCreature</c>. Non-creature weenies bypass
|
||||||
|
/// the ground-contact gate in <c>contact_allows_move</c> (0x00528240)
|
||||||
|
/// and the run remap in <c>adjust_motion</c>. Players, NPCs, and
|
||||||
|
/// monsters are creatures — default true keeps existing implementers
|
||||||
|
/// retail-correct (register TS-34 tracks the adjust_motion side).
|
||||||
|
/// </summary>
|
||||||
|
bool IsCreature() => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MotionInterpreter ─────────────────────────────────────────────────────────
|
// ── MotionInterpreter ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -1080,38 +1095,51 @@ public sealed class MotionInterpreter
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool contact_allows_move(uint motion)
|
public bool contact_allows_move(uint motion)
|
||||||
{
|
{
|
||||||
|
// L.2g S2 (2026-07-02): rewritten VERBATIM from the real
|
||||||
|
// CMotionInterp::contact_allows_move (0x00528240, pseudo-C 305471).
|
||||||
|
// The previous body conflated jump_charge_is_allowed (0x00527a50:
|
||||||
|
// CanJump + Fallen/crouch-range checks) with this gate — the
|
||||||
|
// 2026-06-04 deep-dive flagged the divergence; the retail-observer
|
||||||
|
// trace conformance run exposed it (airborne bodies must still
|
||||||
|
// ACCEPT Falling/Dead + turns).
|
||||||
|
//
|
||||||
|
// Retail: allowed (1) when —
|
||||||
|
// motion is TurnRight/TurnLeft (0x6500000D/0E), OR
|
||||||
|
// motion is Falling (0x40000015) or Dead-class (0x40000011), OR
|
||||||
|
// the weenie exists and is NOT a creature, OR
|
||||||
|
// gravity is off, OR
|
||||||
|
// the body has Contact + OnWalkable.
|
||||||
|
// Everything else (a gravity-bound creature without ground contact)
|
||||||
|
// is blocked — this is the real mechanism behind "airborne remotes
|
||||||
|
// keep their cycle" (K-fix17's empirical guard).
|
||||||
if (PhysicsObj is null)
|
if (PhysicsObj is null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Turn commands are always allowed regardless of ground contact.
|
if (motion > 0x40000015u)
|
||||||
// (Decompile doesn't explicitly early-return for turns here, but
|
{
|
||||||
// ACE and the general shape of the code confirm they bypass the block.)
|
if (motion is MotionCommand.TurnRight or MotionCommand.TurnLeft)
|
||||||
if (motion == MotionCommand.TurnRight || motion == MotionCommand.TurnLeft)
|
return true;
|
||||||
|
}
|
||||||
|
else if (motion == MotionCommand.Falling || motion == 0x40000011u)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WeenieObj is not null && !WeenieObj.IsCreature())
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
// Dead or Fallen forward-command blocks movement.
|
|
||||||
uint fwd = InterpretedState.ForwardCommand;
|
|
||||||
if (fwd == MotionCommand.Fallen || fwd == MotionCommand.Dead)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Crouch / sit / sleep range (0x41000011 < fwd < 0x41000015).
|
|
||||||
if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperExclusive)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Need Gravity flag + Contact + OnWalkable for ground-based motion.
|
|
||||||
if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
|
if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
|
||||||
return true; // no gravity → object can always move (swimming, flying)
|
return true;
|
||||||
|
|
||||||
bool contact = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact);
|
bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
|
||||||
bool onWalkable = PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
|
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
|
||||||
|
if (!grounded)
|
||||||
if (!contact)
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (!onWalkable)
|
// PRE-EXISTING acdream side effect (not part of 0x00528240; kept for
|
||||||
return false;
|
// the local-player standing-long-jump path pending its own verbatim
|
||||||
|
// pass): grounded and idle flags the long-jump candidate.
|
||||||
// Grounded and idle — flag as standing-long-jump candidate.
|
uint fwd = InterpretedState.ForwardCommand;
|
||||||
if (fwd == MotionCommand.Ready
|
if (fwd == MotionCommand.Ready
|
||||||
&& InterpretedState.SideStepCommand == 0
|
&& InterpretedState.SideStepCommand == 0
|
||||||
&& InterpretedState.TurnCommand == 0)
|
&& InterpretedState.TurnCommand == 0)
|
||||||
|
|
@ -1252,4 +1280,147 @@ public sealed class MotionInterpreter
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══ L.2g S2 — the inbound CMotionInterp funnel (DEV-1) ════════════════
|
||||||
|
// Spec: docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md.
|
||||||
|
// Dispatch order validated against the LIVE retail-observer cdb trace
|
||||||
|
// (tools/cdb/l2g-observer.cdb): style → forward → sidestep(-stop) →
|
||||||
|
// turn(-stop) → actions, wholesale per UM.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when this interpreter belongs to the LOCAL player. Retail skips
|
||||||
|
/// replaying AUTONOMOUS actions on the local player (they are its own
|
||||||
|
/// echo — move_to_interpreted_state 305977) but applies them on remotes.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsLocalPlayer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>CMotionInterp::server_action_stamp</c> — 15-bit wraparound stamp
|
||||||
|
/// of the last applied inbound action (move_to_interpreted_state
|
||||||
|
/// 305953-305989).
|
||||||
|
/// </summary>
|
||||||
|
public int ServerActionStamp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>CMotionInterp::move_to_interpreted_state</c> (0x005289c0,
|
||||||
|
/// pseudo-C 305936): adopt the style into the raw state, FLAT-copy the
|
||||||
|
/// incoming interpreted state (<c>copy_movement_from</c> 0x0051e750 —
|
||||||
|
/// every axis overwritten, defaults included), re-apply the whole
|
||||||
|
/// movement through the sink, then replay fresh actions under the
|
||||||
|
/// 15-bit stamp gate.
|
||||||
|
/// </summary>
|
||||||
|
public int MoveToInterpretedState(in InboundInterpretedState ims, IInterpretedMotionSink? sink = null)
|
||||||
|
{
|
||||||
|
if (PhysicsObj is null) return 0;
|
||||||
|
|
||||||
|
RawState.CurrentStyle = ims.CurrentStyle;
|
||||||
|
|
||||||
|
// copy_movement_from — flat overwrite, no per-field presence checks.
|
||||||
|
InterpretedState.ForwardCommand = ims.ForwardCommand;
|
||||||
|
InterpretedState.ForwardSpeed = ims.ForwardSpeed;
|
||||||
|
InterpretedState.SideStepCommand = ims.SideStepCommand;
|
||||||
|
InterpretedState.SideStepSpeed = ims.SideStepSpeed;
|
||||||
|
InterpretedState.TurnCommand = ims.TurnCommand;
|
||||||
|
InterpretedState.TurnSpeed = ims.TurnSpeed;
|
||||||
|
|
||||||
|
ApplyInterpretedMovement(ims.CurrentStyle, sink);
|
||||||
|
|
||||||
|
if (ims.Actions is { } actions)
|
||||||
|
{
|
||||||
|
foreach (var a in actions)
|
||||||
|
{
|
||||||
|
// 15-bit wraparound "newer" gate vs server_action_stamp:
|
||||||
|
// abs diff > 0x3fff flips the comparison (305955-305969).
|
||||||
|
int incoming = a.Stamp & 0x7FFF;
|
||||||
|
int stored = ServerActionStamp & 0x7FFF;
|
||||||
|
int diff = incoming >= stored ? incoming - stored : stored - incoming;
|
||||||
|
bool newer = diff <= 0x3FFF ? stored < incoming : incoming < stored;
|
||||||
|
if (!newer) continue;
|
||||||
|
|
||||||
|
// Local player skips its own autonomous echoes (305977).
|
||||||
|
if (IsLocalPlayer && a.Autonomous) continue;
|
||||||
|
|
||||||
|
ServerActionStamp = incoming;
|
||||||
|
DispatchInterpretedMotion(a.Command, a.Speed, sink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>CMotionInterp::apply_interpreted_movement</c> (0x00528600,
|
||||||
|
/// pseudo-C 305713): cache <c>my_run_rate</c> from a RunForward speed,
|
||||||
|
/// then dispatch style / forward-or-Falling / sidestep-or-stop /
|
||||||
|
/// turn-or-stop in retail order. A non-zero turn EARLY-RETURNS (no
|
||||||
|
/// turn-stop, no idle bookkeeping).
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyInterpretedMovement(uint currentStyle, IInterpretedMotionSink? sink)
|
||||||
|
{
|
||||||
|
if (PhysicsObj is null) return;
|
||||||
|
|
||||||
|
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
|
||||||
|
MyRunRate = InterpretedState.ForwardSpeed;
|
||||||
|
|
||||||
|
DispatchInterpretedMotion(currentStyle, 1.0f, sink);
|
||||||
|
|
||||||
|
if (!contact_allows_move(InterpretedState.ForwardCommand))
|
||||||
|
{
|
||||||
|
DispatchInterpretedMotion(MotionCommand.Falling, 1.0f, sink);
|
||||||
|
}
|
||||||
|
else if (StandingLongJump)
|
||||||
|
{
|
||||||
|
DispatchInterpretedMotion(MotionCommand.Ready, 1.0f, sink);
|
||||||
|
sink?.StopMotion(MotionCommand.SideStepRight);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DispatchInterpretedMotion(
|
||||||
|
InterpretedState.ForwardCommand, InterpretedState.ForwardSpeed, sink);
|
||||||
|
if (InterpretedState.SideStepCommand == 0)
|
||||||
|
sink?.StopMotion(MotionCommand.SideStepRight);
|
||||||
|
else
|
||||||
|
DispatchInterpretedMotion(
|
||||||
|
InterpretedState.SideStepCommand, InterpretedState.SideStepSpeed, sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (InterpretedState.TurnCommand != 0)
|
||||||
|
{
|
||||||
|
DispatchInterpretedMotion(
|
||||||
|
InterpretedState.TurnCommand, InterpretedState.TurnSpeed, sink);
|
||||||
|
return; // retail early return — no idle-stop this call
|
||||||
|
}
|
||||||
|
sink?.StopMotion(MotionCommand.TurnRight);
|
||||||
|
// Idle add_to_queue(Ready) bookkeeping lands with S3
|
||||||
|
// (pending_motions / MotionDone).
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>CMotionInterp::DoInterpretedMotion</c> (0x00528360, pseudo-C
|
||||||
|
/// 305575), sink-dispatch form: motions that pass
|
||||||
|
/// <c>contact_allows_move</c> reach the GetObjectSequence backend
|
||||||
|
/// (<see cref="IInterpretedMotionSink.ApplyMotion"/>); blocked
|
||||||
|
/// non-action motions take the apply-only path (state already copied,
|
||||||
|
/// NO cycle change — this is retail's real mechanism behind "airborne
|
||||||
|
/// remotes keep their Falling cycle"). Blocked action-class motions
|
||||||
|
/// (0x10000000 bit) are rejected outright (0x24).
|
||||||
|
/// </summary>
|
||||||
|
public WeenieError DispatchInterpretedMotion(uint motion, float speed, IInterpretedMotionSink? sink)
|
||||||
|
{
|
||||||
|
if (PhysicsObj is null) return WeenieError.NoPhysicsObject;
|
||||||
|
|
||||||
|
if (contact_allows_move(motion))
|
||||||
|
{
|
||||||
|
// 0x40000011 (Dead) flushes queued link animations in retail
|
||||||
|
// (RemoveLinkAnimations) — S4 wires the sequencer hook.
|
||||||
|
sink?.ApplyMotion(motion, speed);
|
||||||
|
// add_to_queue(context, motion, jumpAllowed) — S3 bookkeeping.
|
||||||
|
return WeenieError.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((motion & 0x10000000u) == 0)
|
||||||
|
return WeenieError.None; // apply-only: state kept, no cycle change
|
||||||
|
|
||||||
|
return WeenieError.GeneralMovementFailure; // retail 0x24
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2953
tests/AcDream.Core.Tests/Fixtures/l2g-observer-trace.log
Normal file
2953
tests/AcDream.Core.Tests/Fixtures/l2g-observer-trace.log
Normal file
File diff suppressed because it is too large
Load diff
272
tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs
Normal file
272
tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -673,35 +673,24 @@ public sealed class MotionInterpreterTests
|
||||||
Assert.True(allowLeft);
|
Assert.True(allowLeft);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
// L.2g S2 (2026-07-02): the posture-rejection tests that used to live
|
||||||
public void ContactAllowsMove_FallenState_RejectsMove()
|
// here pinned a MISATTRIBUTED port — the Fallen/Dead/crouch-range checks
|
||||||
{
|
// belong to jump_charge_is_allowed (0x00527a50) / motion_allows_jump
|
||||||
var body = MakeGrounded();
|
// (0x005279e0), NOT to contact_allows_move. The real contact_allows_move
|
||||||
var interp = MakeInterp(body);
|
// (0x00528240, pseudo-C 305471) never reads the forward-command posture:
|
||||||
interp.InterpretedState.ForwardCommand = MotionCommand.Fallen;
|
// a grounded creature in ANY posture may receive motion
|
||||||
|
// (GetObjectSequence handles the posture→locomotion transition via
|
||||||
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
|
// links). Verified against the live retail-observer trace
|
||||||
|
// (RetailObserverTraceConformanceTests, 183/183 dispatch conformant).
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
[InlineData(MotionCommand.Fallen)]
|
||||||
|
[InlineData(MotionCommand.Dead)]
|
||||||
[InlineData(MotionCommand.Crouch)]
|
[InlineData(MotionCommand.Crouch)]
|
||||||
[InlineData(MotionCommand.Sitting)]
|
[InlineData(MotionCommand.Sitting)]
|
||||||
[InlineData(MotionCommand.Sleeping)]
|
[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 body = MakeGrounded();
|
||||||
var interp = MakeInterp(body);
|
var interp = MakeInterp(body);
|
||||||
|
|
@ -709,20 +698,25 @@ public sealed class MotionInterpreterTests
|
||||||
|
|
||||||
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
|
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
|
||||||
|
|
||||||
Assert.False(allowed);
|
Assert.True(allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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);
|
var interp = MakeInterp(body);
|
||||||
// 0x41000012 is inside (0x41000011, 0x41000015) — crouch state
|
|
||||||
interp.InterpretedState.ForwardCommand = 0x41000012u;
|
|
||||||
|
|
||||||
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
|
Assert.True(interp.contact_allows_move(MotionCommand.Falling));
|
||||||
|
Assert.True(interp.contact_allows_move(0x40000011u));
|
||||||
Assert.False(allowed);
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue