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>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue