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

View file

@ -147,6 +147,11 @@ public enum WeenieError : uint
/// </summary>
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>
public uint ForwardCommand;
/// <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>
public static LegacyRawMotionState Default() => new()
{
CurrentStyle = 0x8000003Du,
ForwardCommand = MotionCommand.Ready,
ForwardSpeed = 1.0f,
SideStepCommand = 0,
@ -238,6 +244,15 @@ public interface IWeenieObject
bool InqRunRate(out float rate);
/// <summary>vtable +0x3C — CanJump. Returns true if the weenie can jump at this extent.</summary>
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 ─────────────────────────────────────────────────────────
@ -1080,38 +1095,51 @@ public sealed class MotionInterpreter
/// </summary>
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)
return false;
// Turn commands are always allowed regardless of ground contact.
// (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 == MotionCommand.TurnRight || motion == MotionCommand.TurnLeft)
if (motion > 0x40000015u)
{
if (motion is MotionCommand.TurnRight or MotionCommand.TurnLeft)
return true;
}
else if (motion == MotionCommand.Falling || motion == 0x40000011u)
{
return true;
}
if (WeenieObj is not null && !WeenieObj.IsCreature())
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))
return true; // no gravity → object can always move (swimming, flying)
return true;
bool contact = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact);
bool onWalkable = PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
if (!contact)
bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)
&& PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable);
if (!grounded)
return false;
if (!onWalkable)
return false;
// Grounded and idle — flag as standing-long-jump candidate.
// PRE-EXISTING acdream side effect (not part of 0x00528240; kept for
// the local-player standing-long-jump path pending its own verbatim
// pass): grounded and idle flags the long-jump candidate.
uint fwd = InterpretedState.ForwardCommand;
if (fwd == MotionCommand.Ready
&& InterpretedState.SideStepCommand == 0
&& InterpretedState.TurnCommand == 0)
@ -1252,4 +1280,147 @@ public sealed class MotionInterpreter
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
}
}