Live bug (2026-07-03, user door test): a server MoveTo armed (movingTo=True) but the local body never moved; the retail observer saw ACE walk the server-side player to the door; the next local input reasserted the stale position (rubber-band). Log evidence: the player's pending_motions queue was non-empty on 92/94 MotionDone pops (remotes: 0/40) - MoveToManager's retail wait-for-anims gate (BeginTurnToHeading's MotionsPending check) never opened. ROOT CAUSE (found via a production-faithful repro test - the original suite force-drained the queue, masking it): the R3 port MISIDENTIFIED retail's StopCompletely_Internal body. It is NOT a physics velocity zero - CPhysicsObj::StopCompletely_Internal (0x0050ead0) tailcalls CPartArray::StopCompletelyInternal (0x00518890) which is MotionTableManager::PerformMovement(type 5): an ANIMATION-side full stop whose UNCONDITIONALLY-queued pending_animations entry is the completable partner of the A9 pending_motions node CMotionInterp::StopCompletely enqueues (raw @00527e90 between the state writes and add_to_queue). Without the dispatch, every StopCompletely (login/teleport SetPosition + every MoveToManager PerformMovement head) left an orphan node with no completion - the queue never drained at idle. (The R3 code comment claimed a register row for the stand-in; none was ever added - porting the real mechanism closes that violation without a row.) Fix set (all retail-anchored): - IInterpretedMotionSink gains StopCompletely() (default true = the null-sink posture); MotionTableDispatchSink routes it to the R2 manager's already-ported type-5 op (StopObjectCompletely + Ready-sentinel entry) + TurnStopped for the ObservedOmega seam. - MotionInterpreter.StopCompletely calls DefaultSink?.StopCompletely() at the exact retail slot (after the state writes, before add_to_queue); the immediate set_velocity(Zero) is kept as the documented physics-effect stand-in. - PlayerMovementController ticks CheckForCompletedMotions after UseTime - retail's per-tick CPartArray::HandleMovement slot (0x00517d60 = MotionTableManager::UseTime = CheckForCompletedMotions tailcall, called every tick from UpdateObjectInternal @005159a4). NOTE: a first-cut per-tick apply_current_movement pump was tried and REVERTED - retail's apply callers are all event-driven (hold-key, HitGround/LeaveGround, ReportExhaustion), not per-tick; the remote block's per-tick apply is a pre-existing adaptation outside this fix's scope. - The P1 autonomous-store family, completing the pin's port shape (the gate landed in V5; the STORE side was missing): (a) the unpack store (00509730: last_move_was_autonomous = wire byte) in the GameWindow player branch - local player only for now (remote interps have no WeenieObj, which A3 reads as IsThePlayer; storing a remote's autonomous byte would mis-route their per-tick apply onto the raw branch); (b) input edges store =1 at the controller edge block - retail's CPhysicsObj::DoMotion @00510030 / StopMotion @005100e0 / TakeControlFromServer @006b32f4 input-boundary stores; (c) section 2's per-frame velocity write now PRESERVES the flag (V5's first cut stamped it per frame, the wrong altitude - retail stores it at event boundaries only); (d) InstallSpeculativeTurnToTarget stores false (models the wire mt-6's unpack store; part of the AP-23 adaptation) so the event-driven applies (e.g. HitGround mid-moveto) route interpreted and cannot clobber the manager's dispatched motions with idle raw state. Tests: new ServerMoveToPosition_ProductionCompletionFeed_WalksToArrival drives the controller under the PRODUCTION completion feed (sequencer.Advance -> MotionDoneTarget) instead of force-draining - this is the test that reproduced the live wedge (queue stuck at the two A9 Ready orphans) and now proves the drain. Existing cutover tests mirror the wire-path autonomous store before PerformMovement. Full suite green: 3,957. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
115 lines
5.4 KiB
C#
115 lines
5.4 KiB
C#
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). R2-Q5: the
|
|
/// production implementation is <c>Motion.MotionTableDispatchSink</c>,
|
|
/// which dispatches straight into the entity's motion-table stack
|
|
/// (<c>PerformMovement</c> → GetObjectSequence + is_allowed decide) —
|
|
/// no sink-side axis pick or fallback chain.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// R3-W5: retail's own <c>CPhysicsObj::DoInterpretedMotion</c> RETURNS a
|
|
/// result (<c>result == 0</c> = the motion table found a cycle for this
|
|
/// id; nonzero = <c>MotionTableManagerError.MotionFailed</c>/no-table) —
|
|
/// <c>CMotionInterp::DoInterpretedMotion</c>'s caller (raw 305591-305610)
|
|
/// gates BOTH the <c>add_to_queue</c> call AND the
|
|
/// <c>InterpretedMotionState::ApplyMotion</c> state-write on that result.
|
|
/// This matters concretely for the very first dispatch of every
|
|
/// <c>apply_interpreted_movement</c> call — the STYLE/stance id
|
|
/// (<c>current_style</c>, always <c>>= 0x80000000</c>) has no
|
|
/// locomotion <c>MotionData</c> entry in the dat, so
|
|
/// <c>CMotionTable::DoObjectMotion</c> genuinely fails for it; if that
|
|
/// failure isn't propagated, the state-write's negative-motion branch
|
|
/// (<see cref="InterpretedMotionState.ApplyMotion"/>: <c>arg2 < 0 →
|
|
/// forward_command = 0x41000003</c>) clobbers <c>ForwardCommand</c>
|
|
/// BEFORE the very next line reads it for the real forward dispatch —
|
|
/// confirmed regression against the 183-case live retail-observer trace
|
|
/// (<c>RetailObserverTraceConformanceTests</c>) when this was <c>void</c>.
|
|
/// Return <c>true</c> when the underlying <c>PerformMovement</c> call
|
|
/// succeeded (<c>MotionTableManagerError.Success</c>).
|
|
/// </returns>
|
|
bool 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>
|
|
/// <returns>Same result-propagation rationale as <see cref="ApplyMotion"/>
|
|
/// — retail's <c>CPhysicsObj::StopInterpretedMotion</c> also returns a
|
|
/// result that gates the post-stop <c>add_to_queue</c> + state-removal.</returns>
|
|
bool StopMotion(uint motion);
|
|
|
|
/// <summary>
|
|
/// R4-V5 wedge fix — retail <c>CPhysicsObj::StopCompletely_Internal</c>
|
|
/// (0x0050ead0) → <c>CPartArray::StopCompletelyInternal</c> (0x00518890)
|
|
/// → <c>MotionTableManager::PerformMovement(MovementStruct{type=5})</c>:
|
|
/// the ANIMATION-side full stop dispatched from the middle of
|
|
/// <c>CMotionInterp::StopCompletely</c> (raw @00527e90). The manager
|
|
/// queues its pending_animations entry UNCONDITIONALLY for type 5, and
|
|
/// that entry's AnimationDone → MotionDone is the matched pop for the A9
|
|
/// pending_motions node <c>StopCompletely</c> enqueues right after this
|
|
/// call — without it the A9 node is an orphan and <c>MotionsPending</c>
|
|
/// never drains at idle (the 2026-07-03 moveto wedge). Default
|
|
/// implementation returns <c>true</c> (the null-sink "no animation
|
|
/// backend" posture — bare tests and sink-less interps are unaffected).
|
|
/// </summary>
|
|
bool StopCompletely() => true;
|
|
}
|
|
|
|
/// <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,
|
|
};
|
|
}
|