feat(R3-W5): DoMotion family verbatim + the ONE DoInterpretedMotion + per-op zero-tick flush (closes J3, J4, J9, J14)
The two parallel DoInterpretedMotion implementations MERGE into one verbatim pair (0x00528360/@305639): contact gate, StandingLongJump state-only branch, Dead → RemoveLinkAnimations, the jump_error_code double-check WITH the DisableJumpDuringLink 0x20000 leg (W2's TODO(W5) resolved — MovementParameters now flows), ModifyInterpretedState gating, CurCell-null tail, AddToQueue with the real ContextId; StopInterpretedMotion's post-stop Ready node + raw-mirror RemoveMotion. Legacy overloads + ApplyMotionToInterpretedState DELETED. The funnel's public surface unchanged (183-case suite compiles + passes as-is). DoMotion 0x00528d20 verbatim: cancel_moveto interrupt; SetHoldKey(key, cancelMoveToBit) BEFORE adjust_motion; params re-default for the interpreted call; combat-stance gates on the ORIGINAL id (Crouch/Sit/ Sleep → 0x3f/0x40/0x41; & 0x2000000 → 0x42 outside NonCombat); action depth cap ≥6 → 0x45; raw mirror ORIGINAL id. StopMotion 0x00528530 mirror shape. StopCompletely 0x00527e40 with the A9 snapshot quirk (motion_allows_jump on the OLD forward command, stashed in the queued node) + the J9 fix (sidestep/turn SPEEDS untouched — the 1.0 resets were a divergence). PerformMovement flushes zero-tick completions after every dispatched op via the new CheckForCompletedMotions seam (0x0050fe30) — bound App-side per entity (remotes via EnsureRemoteMotionBindings, player at the sequencer bind site). PORT DISCOVERY (not in the W0 pins): retail's DoInterpretedMotion RESULT gates both add_to_queue AND the state write — a void sink let the style dispatch's apply-only failure clobber ForwardCommand before the forward axis read it, regressing 74/183 live-trace cases. IInterpretedMotionSink.ApplyMotion/StopMotion now return bool (documented on the interface with the raw anchors); one funnel assertion corrected with raw-line citations (airborne ForwardCommand ends at Falling under the fully-wired verbatim algorithm). 62 new gate-table tests. Full suite: 3,729 passed. Implemented by a dedicated agent against the W0-pinned spec; seam bound + suite independently re-verified by the orchestrator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e214acdf23
commit
df7b096d6f
7 changed files with 1522 additions and 241 deletions
|
|
@ -4217,6 +4217,9 @@ public sealed class GameWindow : IDisposable
|
||||||
rm.Motion.DefaultSink = rm.Sink;
|
rm.Motion.DefaultSink = rm.Sink;
|
||||||
rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations;
|
rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations;
|
||||||
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
|
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
|
||||||
|
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
|
||||||
|
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
|
||||||
|
rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions;
|
||||||
return rm.Sink;
|
return rm.Sink;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -12992,6 +12995,8 @@ public sealed class GameWindow : IDisposable
|
||||||
_playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations;
|
_playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations;
|
||||||
_playerController.Motion.InitializeMotionTables =
|
_playerController.Motion.InitializeMotionTables =
|
||||||
() => playerSeq.Manager.InitializeState();
|
() => playerSeq.Manager.InitializeState();
|
||||||
|
_playerController.Motion.CheckForCompletedMotions =
|
||||||
|
playerSeq.Manager.CheckForCompletedMotions;
|
||||||
}
|
}
|
||||||
|
|
||||||
var q = playerEntity.Rotation;
|
var q = playerEntity.Rotation;
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,38 @@ public interface IInterpretedMotionSink
|
||||||
/// (<c>PerformMovement</c> → GetObjectSequence + is_allowed decide) —
|
/// (<c>PerformMovement</c> → GetObjectSequence + is_allowed decide) —
|
||||||
/// no sink-side axis pick or fallback chain.
|
/// no sink-side axis pick or fallback chain.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
void ApplyMotion(uint motion, float speed);
|
/// <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>
|
/// <summary>
|
||||||
/// <c>StopInterpretedMotion</c> notification for an axis the incoming
|
/// <c>StopInterpretedMotion</c> notification for an axis the incoming
|
||||||
/// state cleared (sidestep 0x6500000F / turn 0x6500000D). Unconditional
|
/// state cleared (sidestep 0x6500000F / turn 0x6500000D). Unconditional
|
||||||
/// (no contact gate) per apply_interpreted_movement.
|
/// (no contact gate) per apply_interpreted_movement.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
void StopMotion(uint motion);
|
/// <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>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -52,19 +52,21 @@ public sealed class MotionTableDispatchSink : IInterpretedMotionSink
|
||||||
private static bool IsTurn(uint motion)
|
private static bool IsTurn(uint motion)
|
||||||
=> (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E;
|
=> (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E;
|
||||||
|
|
||||||
public void ApplyMotion(uint motion, float speed)
|
public bool ApplyMotion(uint motion, float speed)
|
||||||
{
|
{
|
||||||
if (IsTurn(motion))
|
if (IsTurn(motion))
|
||||||
TurnApplied?.Invoke(motion, speed);
|
TurnApplied?.Invoke(motion, speed);
|
||||||
|
|
||||||
_sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed));
|
uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed));
|
||||||
|
return result == MotionTableManagerError.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StopMotion(uint motion)
|
public bool StopMotion(uint motion)
|
||||||
{
|
{
|
||||||
if (IsTurn(motion))
|
if (IsTurn(motion))
|
||||||
TurnStopped?.Invoke();
|
TurnStopped?.Invoke();
|
||||||
|
|
||||||
_sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f));
|
uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f));
|
||||||
|
return result == MotionTableManagerError.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -676,6 +676,24 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Action? InitializeMotionTables { get; set; }
|
public Action? InitializeMotionTables { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R3-W5 seam standing in for retail <c>CPhysicsObj::CheckForCompletedMotions</c>
|
||||||
|
/// (0x0050fe30, decomp §7d @277925) → <c>CPartArray::CheckForCompletedMotions</c>
|
||||||
|
/// → <c>MotionTableManager::CheckForCompletedMotions</c> (the SECOND call
|
||||||
|
/// site of the shared animation-completion driver, §7b) — called by
|
||||||
|
/// <see cref="PerformMovement"/> after EVERY dispatched op (raw
|
||||||
|
/// 306234/306241/306248/306255/306262, §2/§7d), synchronously draining
|
||||||
|
/// any already-elapsed (0-duration) animation-table node right after the
|
||||||
|
/// motion is applied, in case the newly-applied motion table entry
|
||||||
|
/// completes immediately. The App layer binds this to the entity's
|
||||||
|
/// <c>MotionTableManager.CheckForCompletedMotions</c> (R2-Q3 object),
|
||||||
|
/// matching the existing <see cref="RemoveLinkAnimations"/>/
|
||||||
|
/// <see cref="UnstickFromObject"/> <c>Action?</c> seam convention. A null
|
||||||
|
/// binding is a silent no-op (safe default for tests and for any physics
|
||||||
|
/// body with no MotionTableManager attached yet).
|
||||||
|
/// </summary>
|
||||||
|
public Action? CheckForCompletedMotions { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// R3-W4 — retail <c>CMotionInterp::initted</c> (set by
|
/// R3-W4 — retail <c>CMotionInterp::initted</c> (set by
|
||||||
/// <see cref="EnterDefaultState"/>, raw @306152: <c>this->initted =
|
/// <see cref="EnterDefaultState"/>, raw @306152: <c>this->initted =
|
||||||
|
|
@ -780,196 +798,288 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
/// case 5: StopCompletely()
|
/// case 5: StopCompletely()
|
||||||
/// default: return 0x47
|
/// default: return 0x47
|
||||||
/// }
|
/// }
|
||||||
/// FUN_00510900() — CheckForCompletedMotions (animation flush, not simulated here)
|
/// FUN_0050fe30 — CPhysicsObj::CheckForCompletedMotions, called after EVERY
|
||||||
|
/// dispatched op (raw 306234/306241/306248/306255/306262, R3-W5 closes J14).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WeenieError PerformMovement(MovementStruct mvs)
|
public WeenieError PerformMovement(MovementStruct mvs)
|
||||||
{
|
{
|
||||||
|
var p = new MovementParameters
|
||||||
|
{
|
||||||
|
Speed = mvs.Speed,
|
||||||
|
ModifyInterpretedState = mvs.ModifyInterpretedState,
|
||||||
|
ModifyRawState = mvs.ModifyRawState,
|
||||||
|
};
|
||||||
|
|
||||||
|
bool dispatched = true;
|
||||||
WeenieError result = mvs.Type switch
|
WeenieError result = mvs.Type switch
|
||||||
{
|
{
|
||||||
MovementType.RawCommand => DoMotion(mvs.Motion, mvs.Speed),
|
MovementType.RawCommand => DoMotion(mvs.Motion, p),
|
||||||
MovementType.InterpretedCommand => DoInterpretedMotion(mvs.Motion, mvs.Speed, mvs.ModifyInterpretedState),
|
MovementType.InterpretedCommand => DoInterpretedMotion(mvs.Motion, p),
|
||||||
MovementType.StopRawCommand => StopMotion(mvs.Motion),
|
MovementType.StopRawCommand => StopMotion(mvs.Motion, p),
|
||||||
MovementType.StopInterpretedCommand => StopInterpretedMotion(mvs.Motion, mvs.ModifyInterpretedState),
|
MovementType.StopInterpretedCommand => StopInterpretedMotion(mvs.Motion, p),
|
||||||
MovementType.StopCompletely => StopCompletely(),
|
MovementType.StopCompletely => StopCompletely(),
|
||||||
_ => WeenieError.GeneralMovementFailure,
|
_ => Invalid(out dispatched),
|
||||||
};
|
};
|
||||||
// FUN_00510900 — CheckForCompletedMotions is an animation system flush;
|
|
||||||
// no simulation state to update here.
|
// R3-W5 (closes J14): CPhysicsObj::CheckForCompletedMotions fires
|
||||||
|
// after EVERY dispatched op — a synchronous zero-tick flush so a
|
||||||
|
// newly-applied motion-table entry that completes IMMEDIATELY
|
||||||
|
// (0-duration node) gets its MotionDone callback in the same call,
|
||||||
|
// not on the next tick. The type-dispatch failure path (bad
|
||||||
|
// MovementStruct.type, 0x47) never reaches a dispatch, so it must
|
||||||
|
// NOT flush — matches retail's raw 306227 early-return before the
|
||||||
|
// switch even begins.
|
||||||
|
if (dispatched)
|
||||||
|
CheckForCompletedMotions?.Invoke();
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
|
static WeenieError Invalid(out bool dispatched)
|
||||||
|
{
|
||||||
|
dispatched = false;
|
||||||
|
return WeenieError.GeneralMovementFailure;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FUN_00529930 — DoMotion ───────────────────────────────────────────────
|
// ── FUN_00529930 — DoMotion ───────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Process one raw motion command from a network packet.
|
/// <c>CMotionInterp::DoMotion</c> (0x00528d20, decomp §2 @306159, FULL
|
||||||
///
|
/// BODY, R3-W5, closes J3). App-facing 2-arg compat overload —
|
||||||
/// Decompiled logic (FUN_00529930):
|
/// PlayerMovementController's call sites pass a raw
|
||||||
/// Copy packet fields into local variables (at local_24..local_4).
|
/// <c>(uint motion, float speed)</c> pair; this builds a
|
||||||
/// If the speed byte in flags is negative → call FUN_00510cc0 (cancel moveto).
|
/// default-constructed <see cref="MovementParameters"/> (retail's own
|
||||||
/// If 0x800 flag → FUN_005297c0 (set hold key from packet).
|
/// UnPack/CommandInterpreter callers build one the same way for a bare
|
||||||
/// FUN_00528c20 — adjust_motion (raw→interpreted adjustments).
|
/// raw command) with <see cref="MovementParameters.Speed"/> overridden,
|
||||||
/// Guard against special mid-animation states (returns 0x3F/0x40/0x41/0x42).
|
/// then delegates to the verbatim overload below.
|
||||||
/// If Action bit (0x10000000) set and num_actions ≥ 6 → return 0x45.
|
|
||||||
/// Call DoInterpretedMotion(motion, movementParams).
|
|
||||||
///
|
|
||||||
/// Our simplified port focuses on the state fields and physics side-effects.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WeenieError DoMotion(uint motion, float speed = 1.0f)
|
public WeenieError DoMotion(uint motion, float speed = 1.0f)
|
||||||
{
|
=> DoMotion(motion, new MovementParameters { Speed = speed });
|
||||||
if (PhysicsObj is null)
|
|
||||||
return WeenieError.NoPhysicsObject;
|
|
||||||
|
|
||||||
// Record the new raw forward command and speed.
|
|
||||||
// In the decompile, local_24 = *(param_3+8) = ForwardCommand,
|
|
||||||
// local_18 = ForwardSpeed, etc.
|
|
||||||
RawState.ForwardCommand = motion;
|
|
||||||
RawState.ForwardSpeed = speed;
|
|
||||||
|
|
||||||
// Delegate to the interpreted path. DoMotion ultimately calls
|
|
||||||
// DoInterpretedMotion after adjust_motion in the retail client.
|
|
||||||
return DoInterpretedMotion(motion, speed, modifyInterpretedState: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DoInterpretedMotion ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Core animation-state-machine entry point (FUN_00528f70).
|
/// <c>CMotionInterp::DoMotion</c> (0x00528d20, decomp §2 @306159, FULL
|
||||||
|
/// BODY, R3-W5, closes J3).
|
||||||
///
|
///
|
||||||
/// In the full retail engine this runs the animation sequencer. In this
|
/// <para>
|
||||||
/// physics-only port we update the InterpretedState and call
|
/// Verbatim (raw 306159-306221):
|
||||||
/// apply_current_movement so that the velocity is immediately reflected.
|
/// <code>
|
||||||
|
/// if (physics_obj == 0) return 8;
|
||||||
|
/// ebp = arg2; // ORIGINAL motion id, unmutated
|
||||||
|
/// snapshot every MovementParameters field onto the stack;
|
||||||
|
/// var_2c = fresh local MovementParameters(); // re-default
|
||||||
|
/// if (bitfield sign bit) interrupt_current_movement(physics_obj);
|
||||||
|
/// if (bitfield & 0x800 /*SetHoldKey*/)
|
||||||
|
/// SetHoldKey(hold_key_to_apply, (bitfield>>0xf)&1 /*cancel_moveto bit, A4*/);
|
||||||
|
/// adjust_motion(&arg2, &speed, hold_key_to_apply); // mutates arg2/speed in place
|
||||||
|
/// if (interpreted_state.current_style != 0x8000003d) { // combat/special stance
|
||||||
|
/// if (ebp == 0x41000012) return 0x3f;
|
||||||
|
/// if (ebp == 0x41000013) return 0x40;
|
||||||
|
/// if (ebp == 0x41000014) return 0x41;
|
||||||
|
/// if (ebp & 0x2000000) return 0x42;
|
||||||
|
/// }
|
||||||
|
/// if ((ebp & 0x10000000) && GetNumActions() >= 6) return 0x45;
|
||||||
|
/// result = DoInterpretedMotion(arg2 /*ADJUSTED*/, &var_2c /*fresh local params*/);
|
||||||
|
/// if (result == 0 && bitfield & 0x2000 /*ModifyRawState*/)
|
||||||
|
/// RawMotionState::ApplyMotion(ebp /*ORIGINAL*/, arg3 /*CALLER's params, not var_2c*/);
|
||||||
|
/// return result;
|
||||||
|
/// </code>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Two mirror-discipline details, both verbatim: (1) the
|
||||||
|
/// <see cref="DoInterpretedMotion(uint,MovementParameters)"/> call
|
||||||
|
/// receives a FRESH local <see cref="MovementParameters"/> (re-defaulted,
|
||||||
|
/// only <c>Speed</c>/<c>HoldKeyToApply</c> carried through
|
||||||
|
/// <c>adjust_motion</c>'s mutation) — the caller's own
|
||||||
|
/// <paramref name="p"/> distance/heading/fail-distance fields are
|
||||||
|
/// discarded for the interpreted call, matching retail's explicit
|
||||||
|
/// re-construction of <c>var_2c</c>. (2) the raw-state mirror at the
|
||||||
|
/// bottom reads the ORIGINAL <c>arg3</c> (<paramref name="p"/>) — NOT
|
||||||
|
/// <c>var_2c</c> — for <see cref="MovementParameters.SetHoldKey"/>/
|
||||||
|
/// <see cref="MovementParameters.HoldKeyToApply"/>/<see cref="MovementParameters.Speed"/>,
|
||||||
|
/// per <c>RawMotionState::ApplyMotion</c>'s own signature
|
||||||
|
/// (<c>const MovementParameters*</c>) receiving the same pointer
|
||||||
|
/// <c>DoMotion</c> was called with.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WeenieError DoInterpretedMotion(uint motion, float speed = 1.0f, bool modifyInterpretedState = false)
|
/// <param name="motion">Retail <c>arg2</c> (the ORIGINAL, pre-adjustment
|
||||||
|
/// motion id — <c>ebp</c>).</param>
|
||||||
|
/// <param name="p">Retail <c>arg3</c>.</param>
|
||||||
|
public WeenieError DoMotion(uint motion, MovementParameters p)
|
||||||
{
|
{
|
||||||
if (PhysicsObj is null)
|
if (PhysicsObj is null)
|
||||||
return WeenieError.NoPhysicsObject;
|
return WeenieError.NoPhysicsObject;
|
||||||
|
|
||||||
if (!contact_allows_move(motion))
|
uint originalMotion = motion;
|
||||||
|
float speed = p.Speed;
|
||||||
|
var local = new MovementParameters(); // var_2c — fresh re-default
|
||||||
|
|
||||||
|
if (p.CancelMoveTo) // bitfield high-byte sign bit
|
||||||
|
InterruptCurrentMovement?.Invoke();
|
||||||
|
|
||||||
|
if (p.SetHoldKey) // bitfield & 0x800
|
||||||
|
SetHoldKey(p.HoldKeyToApply, p.CancelMoveTo); // A4: 2nd arg IS the cancel_moveto bit
|
||||||
|
|
||||||
|
adjust_motion(ref motion, ref speed, p.HoldKeyToApply); // mutates motion/speed in place
|
||||||
|
|
||||||
|
if (InterpretedState.CurrentStyle != 0x8000003du) // not MotionStance_NonCombat
|
||||||
{
|
{
|
||||||
// Action commands (bit 0x10000000) are blocked mid-air. A10:
|
if (originalMotion == MotionCommand.Crouch)
|
||||||
// DoInterpretedMotion @305622-305623 returns 0x24 (NotGrounded)
|
return WeenieError.CrouchInCombatStance; // 0x3f
|
||||||
// here, not 0x48 (that code is reserved for the
|
if (originalMotion == MotionCommand.Sitting)
|
||||||
// motion_allows_jump literal-range blocklist).
|
return WeenieError.SitInCombatStance; // 0x40
|
||||||
if ((motion & 0x10000000u) != 0)
|
if (originalMotion == MotionCommand.Sleeping)
|
||||||
return WeenieError.NotGrounded;
|
return WeenieError.SleepInCombatStance; // 0x41
|
||||||
// Non-action motions are queued silently; state still updates.
|
if ((originalMotion & 0x2000000u) != 0)
|
||||||
|
return WeenieError.ChatEmoteOutsideNonCombat; // 0x42
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modifyInterpretedState)
|
if ((originalMotion & 0x10000000u) != 0 && InterpretedState.GetNumActions() >= 6)
|
||||||
ApplyMotionToInterpretedState(motion, speed);
|
return WeenieError.ActionDepthExceeded; // 0x45
|
||||||
|
|
||||||
apply_current_movement(cancelMoveTo: false, allowJump: true);
|
local.Speed = speed;
|
||||||
return WeenieError.None;
|
local.HoldKeyToApply = p.HoldKeyToApply;
|
||||||
|
|
||||||
|
WeenieError result = DoInterpretedMotion(motion, local); // ADJUSTED id, fresh local params
|
||||||
|
|
||||||
|
if (result == WeenieError.None && p.ModifyRawState) // bitfield & 0x2000
|
||||||
|
RawState.ApplyMotion(originalMotion, p); // ORIGINAL id, CALLER's params (arg3, not var_2c)
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── StopMotion ────────────────────────────────────────────────────────────
|
// ── StopMotion ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stop a specific raw motion (FUN_00529140 → StopInterpretedMotion).
|
/// App-facing compat overload for <c>StopMotion</c> (0x00528530, decomp
|
||||||
|
/// §5b @305674). Builds a default-constructed <see cref="MovementParameters"/>
|
||||||
|
/// and delegates to the verbatim overload below.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WeenieError StopMotion(uint motion)
|
public WeenieError StopMotion(uint motion)
|
||||||
{
|
=> StopMotion(motion, new MovementParameters());
|
||||||
if (PhysicsObj is null)
|
|
||||||
return WeenieError.NoPhysicsObject;
|
|
||||||
|
|
||||||
if (RawState.ForwardCommand == motion)
|
|
||||||
{
|
|
||||||
RawState.ForwardCommand = MotionCommand.Ready;
|
|
||||||
RawState.ForwardSpeed = 1.0f;
|
|
||||||
}
|
|
||||||
if (RawState.SidestepCommand == motion)
|
|
||||||
{
|
|
||||||
RawState.SidestepCommand = 0;
|
|
||||||
RawState.SidestepSpeed = 1.0f;
|
|
||||||
}
|
|
||||||
if (RawState.TurnCommand == motion)
|
|
||||||
{
|
|
||||||
RawState.TurnCommand = 0;
|
|
||||||
RawState.TurnSpeed = 1.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
return StopInterpretedMotion(motion, modifyInterpretedState: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── StopInterpretedMotion ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stop a specific interpreted motion (FUN_00529080).
|
/// <c>CMotionInterp::StopMotion</c> (0x00528530, decomp §5b @305674, FULL
|
||||||
/// </summary>
|
/// BODY, R3-W5, closes J3's StopMotion leg). The <c>DoMotion</c> mirror:
|
||||||
public WeenieError StopInterpretedMotion(uint motion, bool modifyInterpretedState = false)
|
/// same defaulting/reconstruction pattern, same optional
|
||||||
{
|
/// <c>interrupt_current_movement</c>, same <c>adjust_motion</c>
|
||||||
if (PhysicsObj is null)
|
/// reinterpretation — but NO combat-stance or action-depth gating (those
|
||||||
return WeenieError.NoPhysicsObject;
|
/// are <c>DoMotion</c>-only).
|
||||||
|
|
||||||
if (modifyInterpretedState)
|
|
||||||
{
|
|
||||||
if (InterpretedState.ForwardCommand == motion)
|
|
||||||
{
|
|
||||||
InterpretedState.ForwardCommand = MotionCommand.Ready;
|
|
||||||
InterpretedState.ForwardSpeed = 1.0f;
|
|
||||||
}
|
|
||||||
if (InterpretedState.SideStepCommand == motion)
|
|
||||||
{
|
|
||||||
InterpretedState.SideStepCommand = 0;
|
|
||||||
InterpretedState.SideStepSpeed = 1.0f;
|
|
||||||
}
|
|
||||||
if (InterpretedState.TurnCommand == motion)
|
|
||||||
{
|
|
||||||
InterpretedState.TurnCommand = 0;
|
|
||||||
InterpretedState.TurnSpeed = 1.0f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apply_current_movement(cancelMoveTo: false, allowJump: false);
|
|
||||||
return WeenieError.None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── FUN_00528a50 — StopCompletely ─────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reset both raw and interpreted states to Ready/idle, then push zero velocity.
|
|
||||||
///
|
///
|
||||||
/// Decompiled logic (FUN_00528a50):
|
/// <para>
|
||||||
/// if (PhysicsObj == null) return 8
|
/// Verbatim (raw 305674-305706): snapshot <paramref name="p"/> fields,
|
||||||
/// FUN_00510cc0() — cancel moveto
|
/// build a fresh local <see cref="MovementParameters"/>, optional
|
||||||
/// uVar1 = FUN_005285e0(InterpretedState.ForwardCommand) — motion_allows_jump
|
/// interrupt on the sign bit, <c>adjust_motion(&arg3, &speed,
|
||||||
/// *(+0x20) = 0x41000003 (RawState.ForwardCommand = Ready)
|
/// hold_key_to_apply)</c> (mutates the motion id in place — note retail
|
||||||
/// *(+0x28) = 0x3f800000 (RawState.ForwardSpeed = 1.0f)
|
/// reuses the <c>arg3</c> register slot for the motion id here, not a
|
||||||
/// *(+0x2c) = 0 (RawState.SidestepCommand = 0)
|
/// separate local), delegate to
|
||||||
/// *(+0x38) = 0 (RawState.TurnCommand = 0)
|
/// <c>StopInterpretedMotion(adjusted, &local)</c>; on success AND
|
||||||
/// *(+0x4c) = 0x41000003 (InterpretedState.ForwardCommand = Ready)
|
/// bit <c>0x2000</c> (ModifyRawState), mirror via
|
||||||
/// *(+0x50) = 0x3f800000 (InterpretedState.ForwardSpeed = 1.0f)
|
/// <c>RawMotionState::RemoveMotion(ORIGINAL motion)</c> — using the
|
||||||
/// *(+0x54) = 0 (InterpretedState.SideStepCommand = 0)
|
/// ORIGINAL unmutated id, matching <c>DoMotion</c>'s equivalent
|
||||||
/// *(+0x5c) = 0 (InterpretedState.TurnCommand = 0)
|
/// <c>ApplyMotion(ebp, arg3)</c> mirror discipline.
|
||||||
/// FUN_0050f5a0() — StopCompletely_Internal (zero velocity on PhysicsObj)
|
/// </para>
|
||||||
/// FUN_00528790(…) — add_to_queue
|
/// </summary>
|
||||||
/// if (PhysicsObj != null && CurCell == null) → FUN_005108f0 (RemoveLinkAnimations)
|
/// <param name="motion">Retail <c>arg2</c> (the ORIGINAL motion id).</param>
|
||||||
/// return 0
|
/// <param name="p">Retail <c>arg3</c>.</param>
|
||||||
|
public WeenieError StopMotion(uint motion, MovementParameters p)
|
||||||
|
{
|
||||||
|
if (PhysicsObj is null)
|
||||||
|
return WeenieError.NoPhysicsObject;
|
||||||
|
|
||||||
|
uint originalMotion = motion;
|
||||||
|
float speed = p.Speed;
|
||||||
|
var local = new MovementParameters(); // fresh re-default
|
||||||
|
|
||||||
|
if (p.CancelMoveTo)
|
||||||
|
InterruptCurrentMovement?.Invoke();
|
||||||
|
|
||||||
|
adjust_motion(ref motion, ref speed, p.HoldKeyToApply);
|
||||||
|
|
||||||
|
local.Speed = speed;
|
||||||
|
local.HoldKeyToApply = p.HoldKeyToApply;
|
||||||
|
|
||||||
|
WeenieError result = StopInterpretedMotion(motion, local); // ADJUSTED id
|
||||||
|
|
||||||
|
if (result == WeenieError.None && p.ModifyRawState)
|
||||||
|
RawState.RemoveMotion(originalMotion); // ORIGINAL id
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FUN_00527e40 — StopCompletely ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>CMotionInterp::StopCompletely</c> (0x00527e40, decomp §5a @305208,
|
||||||
|
/// FULL BODY, R3-W5, closes J9). W0-pins.md A9 — ported INCLUDING the
|
||||||
|
/// jump-snapshot quirk.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Verbatim (raw 305208-305236):
|
||||||
|
/// <code>
|
||||||
|
/// if (physics_obj == 0) return 8;
|
||||||
|
/// interrupt_current_movement(physics_obj);
|
||||||
|
/// eax_2 = motion_allows_jump(interpreted_state.forward_command); // OLD fwd, BEFORE overwrite
|
||||||
|
/// raw_state.forward_command = 0x41000003; raw_state.forward_speed = 1f;
|
||||||
|
/// raw_state.sidestep_command = 0; raw_state.turn_command = 0;
|
||||||
|
/// interpreted_state.forward_command = 0x41000003; interpreted_state.forward_speed = 1f;
|
||||||
|
/// interpreted_state.sidestep_command = 0; interpreted_state.turn_command = 0;
|
||||||
|
/// StopCompletely_Internal(physics_obj);
|
||||||
|
/// add_to_queue(0, 0x41000003, eax_2); // eax_2 = the PRE-overwrite snapshot
|
||||||
|
/// if (physics_obj != 0 && physics_obj->cell == 0) RemoveLinkAnimations(physics_obj);
|
||||||
|
/// return 0;
|
||||||
|
/// </code>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>A9 quirk</b>: <c>motion_allows_jump</c> is evaluated on the OLD
|
||||||
|
/// <c>interpreted_state.forward_command</c> BEFORE it gets overwritten
|
||||||
|
/// two lines later — the queued node's <c>JumpErrorCode</c> reflects the
|
||||||
|
/// PRE-stop command, not the post-stop Ready command (which would always
|
||||||
|
/// pass). <b>J9</b>: retail touches ONLY the forward command/speed plus
|
||||||
|
/// the sidestep/turn COMMANDS — it does NOT write <c>sidestep_speed</c>
|
||||||
|
/// or <c>turn_speed</c> on either state. The pre-R3 acdream divergence
|
||||||
|
/// (unconditional 1.0 speed resets on all four speed fields) is REMOVED
|
||||||
|
/// here.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <c>StopCompletely_Internal</c> (0x0050f5a0, CPhysicsObj-side) stands
|
||||||
|
/// in as <see cref="PhysicsBody.set_velocity"/>(Zero) — register row,
|
||||||
|
/// kept (full port is physics-layer scope, r3-port-plan.md §2 KEEP
|
||||||
|
/// LIST). CurCell proxy: <see cref="PhysicsBody.CellPosition"/>.<c>ObjCellId
|
||||||
|
/// == 0</c> (unseeded/detached body — matches <c>cell == 0</c>).
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WeenieError StopCompletely()
|
public WeenieError StopCompletely()
|
||||||
{
|
{
|
||||||
if (PhysicsObj is null)
|
if (PhysicsObj is null)
|
||||||
return WeenieError.NoPhysicsObject;
|
return WeenieError.NoPhysicsObject;
|
||||||
|
|
||||||
// Reset raw state
|
InterruptCurrentMovement?.Invoke();
|
||||||
|
|
||||||
|
// A9: snapshot BEFORE the overwrite below.
|
||||||
|
WeenieError jumpSnapshot = MotionAllowsJump(InterpretedState.ForwardCommand);
|
||||||
|
|
||||||
|
// J9: forward cmd/speed + sidestep/turn COMMANDS only — speeds untouched.
|
||||||
RawState.ForwardCommand = MotionCommand.Ready;
|
RawState.ForwardCommand = MotionCommand.Ready;
|
||||||
RawState.ForwardSpeed = 1.0f;
|
RawState.ForwardSpeed = 1.0f;
|
||||||
RawState.SidestepCommand = 0;
|
RawState.SidestepCommand = 0;
|
||||||
RawState.SidestepSpeed = 1.0f;
|
|
||||||
RawState.TurnCommand = 0;
|
RawState.TurnCommand = 0;
|
||||||
RawState.TurnSpeed = 1.0f;
|
|
||||||
|
|
||||||
// Reset interpreted state
|
|
||||||
InterpretedState.ForwardCommand = MotionCommand.Ready;
|
InterpretedState.ForwardCommand = MotionCommand.Ready;
|
||||||
InterpretedState.ForwardSpeed = 1.0f;
|
InterpretedState.ForwardSpeed = 1.0f;
|
||||||
InterpretedState.SideStepCommand = 0;
|
InterpretedState.SideStepCommand = 0;
|
||||||
InterpretedState.SideStepSpeed = 1.0f;
|
|
||||||
InterpretedState.TurnCommand = 0;
|
InterpretedState.TurnCommand = 0;
|
||||||
InterpretedState.TurnSpeed = 1.0f;
|
|
||||||
|
|
||||||
// Zero the body velocity (FUN_0050f5a0 = StopCompletely_Internal)
|
// StopCompletely_Internal (0x0050f5a0) stand-in.
|
||||||
PhysicsObj.set_velocity(Vector3.Zero);
|
PhysicsObj.set_velocity(Vector3.Zero);
|
||||||
|
|
||||||
|
AddToQueue(contextId: 0, MotionCommand.Ready, (uint)jumpSnapshot);
|
||||||
|
|
||||||
|
if (PhysicsObj.CellPosition.ObjCellId == 0)
|
||||||
|
RemoveLinkAnimations?.Invoke();
|
||||||
|
|
||||||
return WeenieError.None;
|
return WeenieError.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2461,42 +2571,11 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
return RunAnimSpeed * rate;
|
return RunAnimSpeed * rate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── private helper ────────────────────────────────────────────────────────
|
// R3-W5: the former `ApplyMotionToInterpretedState` private helper
|
||||||
|
// (a hand-rolled switch approximating InterpretedMotionState.ApplyMotion)
|
||||||
/// <summary>
|
// is DELETED per the plan (closes J3/J4) — the merged DoInterpretedMotion
|
||||||
/// Apply a motion command to the interpreted state fields.
|
// below calls the verbatim InterpretedMotionState.ApplyMotion(motion, p)
|
||||||
/// Mirrors the InterpretedState.ApplyMotion logic in ACE.
|
// directly (already ported, W1).
|
||||||
/// </summary>
|
|
||||||
private void ApplyMotionToInterpretedState(uint motion, float speed)
|
|
||||||
{
|
|
||||||
switch (motion)
|
|
||||||
{
|
|
||||||
case MotionCommand.WalkForward:
|
|
||||||
case MotionCommand.RunForward:
|
|
||||||
case MotionCommand.WalkBackward:
|
|
||||||
InterpretedState.ForwardCommand = motion;
|
|
||||||
InterpretedState.ForwardSpeed = speed;
|
|
||||||
break;
|
|
||||||
case MotionCommand.SideStepRight:
|
|
||||||
case MotionCommand.SideStepLeft:
|
|
||||||
InterpretedState.SideStepCommand = motion;
|
|
||||||
InterpretedState.SideStepSpeed = speed;
|
|
||||||
break;
|
|
||||||
case MotionCommand.TurnRight:
|
|
||||||
case MotionCommand.TurnLeft:
|
|
||||||
InterpretedState.TurnCommand = motion;
|
|
||||||
InterpretedState.TurnSpeed = speed;
|
|
||||||
break;
|
|
||||||
case MotionCommand.Ready:
|
|
||||||
InterpretedState.ForwardCommand = MotionCommand.Ready;
|
|
||||||
InterpretedState.ForwardSpeed = 1.0f;
|
|
||||||
InterpretedState.SideStepCommand = 0;
|
|
||||||
InterpretedState.SideStepSpeed = 1.0f;
|
|
||||||
InterpretedState.TurnCommand = 0;
|
|
||||||
InterpretedState.TurnSpeed = 1.0f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══ L.2g S2 — the inbound CMotionInterp funnel (DEV-1) ════════════════
|
// ══ L.2g S2 — the inbound CMotionInterp funnel (DEV-1) ════════════════
|
||||||
// Spec: docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md.
|
// Spec: docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md.
|
||||||
|
|
@ -2656,14 +2735,14 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
else if (StandingLongJump)
|
else if (StandingLongJump)
|
||||||
{
|
{
|
||||||
DispatchInterpretedMotion(MotionCommand.Ready, 1.0f, sink);
|
DispatchInterpretedMotion(MotionCommand.Ready, 1.0f, sink);
|
||||||
sink?.StopMotion(MotionCommand.SideStepRight);
|
DispatchStopInterpretedMotion(MotionCommand.SideStepRight, sink);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
DispatchInterpretedMotion(
|
DispatchInterpretedMotion(
|
||||||
InterpretedState.ForwardCommand, InterpretedState.ForwardSpeed, sink);
|
InterpretedState.ForwardCommand, InterpretedState.ForwardSpeed, sink);
|
||||||
if (InterpretedState.SideStepCommand == 0)
|
if (InterpretedState.SideStepCommand == 0)
|
||||||
sink?.StopMotion(MotionCommand.SideStepRight);
|
DispatchStopInterpretedMotion(MotionCommand.SideStepRight, sink);
|
||||||
else
|
else
|
||||||
DispatchInterpretedMotion(
|
DispatchInterpretedMotion(
|
||||||
InterpretedState.SideStepCommand, InterpretedState.SideStepSpeed, sink);
|
InterpretedState.SideStepCommand, InterpretedState.SideStepSpeed, sink);
|
||||||
|
|
@ -2676,77 +2755,329 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
||||||
return; // retail early return — no idle-stop this call
|
return; // retail early return — no idle-stop this call
|
||||||
}
|
}
|
||||||
|
|
||||||
sink?.StopMotion(MotionCommand.TurnRight);
|
// Tail (raw 305766-305786): unconditional StopInterpretedMotion(TurnRight,
|
||||||
// add_to_queue(context, Ready, 0) — R3-W2 (closes J1). TODO(W5):
|
// params) — the merged StopInterpretedMotion ITSELF performs the
|
||||||
// real ContextId once MovementParameters flows through this funnel
|
// add_to_queue(context, Ready, 0) + RemoveMotion(TurnRight) on
|
||||||
// (retail's own local here is READ UNINITIALIZED in the decompile —
|
// success, so no separate AddToQueue call is needed here anymore
|
||||||
// see the final-report contextId note).
|
// (R3-W5 moves this bookkeeping into the merged function body).
|
||||||
AddToQueue(contextId: 0, MotionCommand.Ready, jumpErrorCode: 0);
|
DispatchStopInterpretedMotion(MotionCommand.TurnRight, sink);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>CMotionInterp::DoInterpretedMotion</c> (0x00528360, pseudo-C
|
/// <c>CMotionInterp::DoInterpretedMotion</c> (0x00528360, raw
|
||||||
/// 305575), sink-dispatch form: motions that pass
|
/// 305575-305631, FULL BODY, R3-W5, closes J4). THE ONE
|
||||||
/// <c>contact_allows_move</c> reach the GetObjectSequence backend
|
/// <c>DoInterpretedMotion</c> — merges the former legacy
|
||||||
/// (<see cref="IInterpretedMotionSink.ApplyMotion"/>); blocked
|
/// <c>(uint, float, bool)</c> overload's state-management with the S2a
|
||||||
/// non-action motions take the apply-only path (state already copied,
|
/// funnel's <c>DispatchInterpretedMotion</c> sink-dispatch backend.
|
||||||
/// 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).
|
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>R3-W2 producer (closes J1's DoInterpretedMotion leg):</b> on the
|
/// Verbatim (raw 305577-305631):
|
||||||
/// success path (raw 305591-305610), retail computes the queue node's
|
|
||||||
/// <c>jump_error_code</c> via a DOUBLE-CHECK shape before calling
|
|
||||||
/// <c>add_to_queue(context_id, arg2, eax_5)</c>:
|
|
||||||
/// <code>
|
/// <code>
|
||||||
/// if ((params->bitfield & 0x20000) == 0) { // NOT disable_jump_during_link
|
/// if (physics_obj == 0) return 8;
|
||||||
/// eax_5 = motion_allows_jump(arg2);
|
/// if (contact_allows_move(arg2)) {
|
||||||
/// if (eax_5 == 0 && (arg2 & 0x10000000) == 0) // passed AND not action-class
|
/// if (standing_longjump != 0
|
||||||
/// eax_5 = motion_allows_jump(interpreted_state.forward_command);
|
/// && (arg2 == WalkForward || arg2 == RunForward || arg2 == SideStepRight))
|
||||||
/// } else {
|
/// goto label_528440; // StandingLongJump: state-only, no dispatch, no queue
|
||||||
/// eax_5 = 0x48; // DisableJumpDuringLink forces BLOCKED
|
/// if (arg2 == 0x40000011 /*Dead*/) RemoveLinkAnimations(physics_obj);
|
||||||
/// }
|
/// result = sink.ApplyMotion(arg2, arg3); // CPhysicsObj::DoInterpretedMotion stand-in
|
||||||
/// add_to_queue(context_id, arg2, eax_5);
|
/// if (result == 0) {
|
||||||
|
/// if ((arg3->bitfield & 0x20000) == 0) { // NOT DisableJumpDuringLink
|
||||||
|
/// eax_5 = motion_allows_jump(arg2);
|
||||||
|
/// if (eax_5 == 0 && (arg2 & 0x10000000) == 0)
|
||||||
|
/// eax_5 = motion_allows_jump(interpreted_state.forward_command);
|
||||||
|
/// } else eax_5 = 0x48; // DisableJumpDuringLink FORCES blocked
|
||||||
|
/// add_to_queue(arg3->context_id, arg2, eax_5);
|
||||||
|
/// if (arg3->bitfield & 0x4000 /*ModifyInterpretedState*/)
|
||||||
|
/// InterpretedMotionState::ApplyMotion(arg2, arg3);
|
||||||
|
/// }
|
||||||
|
/// } else if ((arg2 & 0x10000000) == 0) {
|
||||||
|
/// label_528440:
|
||||||
|
/// if (arg3->bitfield & 0x4000) InterpretedMotionState::ApplyMotion(arg2, arg3);
|
||||||
|
/// result = 0;
|
||||||
|
/// } else result = 0x24;
|
||||||
|
/// if (physics_obj != 0 && physics_obj->cell == 0) RemoveLinkAnimations(physics_obj);
|
||||||
|
/// return result;
|
||||||
/// </code>
|
/// </code>
|
||||||
/// This funnel has no <c>MovementParameters</c> threaded through yet
|
/// </para>
|
||||||
/// (<see cref="DispatchInterpretedMotion"/> takes only <c>motion</c>/
|
///
|
||||||
/// <c>speed</c> — the params-carrying overloads land in W5 when
|
/// <para>
|
||||||
/// <c>DoMotion</c>/<c>StopMotion</c>/the legacy overloads merge into
|
/// <b>R3-W5 finding (not in the original W0 pins — discovered while
|
||||||
/// this backend). TODO(W5): thread the real <c>MovementParameters</c>
|
/// porting):</b> <see cref="IInterpretedMotionSink.ApplyMotion"/>'s
|
||||||
/// through and restore the <c>0x20000</c> (DisableJumpDuringLink) leg;
|
/// <c>bool</c> return IS retail's <c>result == 0</c> test gating the
|
||||||
/// until then this always takes the "bit not set" branch (the common
|
/// queue/state-write block. This is load-bearing, not cosmetic: the
|
||||||
/// case — retail's own ctor default for the bit is CLEAR, W0-pins A4).
|
/// VERY FIRST dispatch of every <c>apply_interpreted_movement</c> call
|
||||||
/// <c>ContextId</c> is <c>0</c> pending the same W5 plumbing (no
|
/// is the style/stance id (<c>current_style</c>, always
|
||||||
/// <c>MovementParameters.ContextId</c> reaches this call site yet) —
|
/// <c>>= 0x80000000</c>), which has no locomotion <c>MotionData</c>
|
||||||
/// see the final-report contextId note for the evidence this funnel
|
/// entry in the dat — <c>CMotionTable::DoObjectMotion</c> genuinely
|
||||||
/// genuinely has nothing else to pass.
|
/// fails for it. If that failure isn't propagated (an earlier revision
|
||||||
|
/// of this port treated the sink as a <c>void</c>, always-succeeding
|
||||||
|
/// call), <c>InterpretedMotionState::ApplyMotion</c>'s negative-motion
|
||||||
|
/// branch (<c>arg2 < 0 → forward_command = 0x41000003</c>) clobbers
|
||||||
|
/// <see cref="InterpretedMotionState.ForwardCommand"/> BEFORE the very
|
||||||
|
/// next line reads it for the real forward dispatch — regressed 74/183
|
||||||
|
/// cases of the live retail-observer conformance suite
|
||||||
|
/// (<c>RetailObserverTraceConformanceTests</c>) until the interface was
|
||||||
|
/// fixed to return the real result.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Note the ACTUAL animation-table dispatch (the sink call) happens
|
||||||
|
/// through <see cref="DefaultSink"/> — the entity's persistent
|
||||||
|
/// <c>IInterpretedMotionSink</c> binding (R2-Q5's
|
||||||
|
/// <c>MotionTableDispatchSink</c> in production). This is the SAME sink
|
||||||
|
/// <see cref="ApplyCurrentMovementInterpreted"/> already routes through
|
||||||
|
/// for the <c>apply_current_movement</c> dual-dispatch tail — retail has
|
||||||
|
/// exactly one <c>CPhysicsObj</c> per <c>CMotionInterp</c>, so there is
|
||||||
|
/// only ever one dispatch target per entity, matching
|
||||||
|
/// <see cref="DefaultSink"/>'s single-binding shape.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WeenieError DispatchInterpretedMotion(uint motion, float speed, IInterpretedMotionSink? sink)
|
/// <param name="motion">Retail <c>arg2</c>.</param>
|
||||||
|
/// <param name="p">Retail <c>arg3</c>.</param>
|
||||||
|
public WeenieError DoInterpretedMotion(uint motion, MovementParameters p)
|
||||||
|
=> DoInterpretedMotion(motion, p, DefaultSink);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sink-parameterized core shared by the public
|
||||||
|
/// <see cref="DoInterpretedMotion(uint,MovementParameters)"/> (which
|
||||||
|
/// always dispatches through <see cref="DefaultSink"/>, matching
|
||||||
|
/// retail's one-sink-per-<c>CPhysicsObj</c> shape) and the internal
|
||||||
|
/// <see cref="DispatchInterpretedMotion(uint,float,IInterpretedMotionSink?)"/>
|
||||||
|
/// primitive (which needs an EXPLICIT per-call sink for
|
||||||
|
/// <see cref="MoveToInterpretedState"/>'s remote-entity callers). Same
|
||||||
|
/// verbatim body either way — only the dispatch target varies.
|
||||||
|
/// </summary>
|
||||||
|
private WeenieError DoInterpretedMotion(uint motion, MovementParameters p, IInterpretedMotionSink? sink)
|
||||||
{
|
{
|
||||||
if (PhysicsObj is null) return WeenieError.NoPhysicsObject;
|
if (PhysicsObj is null)
|
||||||
|
return WeenieError.NoPhysicsObject;
|
||||||
|
|
||||||
|
WeenieError result;
|
||||||
|
|
||||||
if (contact_allows_move(motion))
|
if (contact_allows_move(motion))
|
||||||
{
|
{
|
||||||
// 0x40000011 (Dead) flushes queued link animations in retail
|
bool standingLongJumpStateOnly = StandingLongJump
|
||||||
// (RemoveLinkAnimations) — S4 wires the sequencer hook.
|
&& (motion == MotionCommand.WalkForward
|
||||||
sink?.ApplyMotion(motion, speed);
|
|| motion == MotionCommand.RunForward
|
||||||
|
|| motion == MotionCommand.SideStepRight);
|
||||||
|
|
||||||
// add_to_queue(context, motion, jumpAllowed) — R3-W2 (closes J1).
|
if (standingLongJumpStateOnly)
|
||||||
// TODO(W5): params-bit 0x20000 (DisableJumpDuringLink) leg + real
|
{
|
||||||
// ContextId once MovementParameters flows through this funnel.
|
// label_528440 — state-only: no dispatch, no queue.
|
||||||
WeenieError jumpErr = MotionAllowsJump(motion);
|
if (p.ModifyInterpretedState)
|
||||||
if (jumpErr == WeenieError.None && (motion & 0x10000000u) == 0)
|
InterpretedState.ApplyMotion(motion, p);
|
||||||
jumpErr = MotionAllowsJump(InterpretedState.ForwardCommand);
|
return WeenieError.None;
|
||||||
AddToQueue(contextId: 0, motion, (uint)jumpErr);
|
}
|
||||||
|
|
||||||
return WeenieError.None;
|
if (motion == MotionCommand.Dead)
|
||||||
|
RemoveLinkAnimations?.Invoke();
|
||||||
|
|
||||||
|
// sink.ApplyMotion stands in for CPhysicsObj::DoInterpretedMotion
|
||||||
|
// — its bool return IS retail's `result == 0` (a null sink, no
|
||||||
|
// dispatch backend wired, is treated as succeeding — matches
|
||||||
|
// every call site that doesn't care about the animation-table
|
||||||
|
// backend, e.g. bare unit tests exercising queue/state behavior
|
||||||
|
// only). A FAILED dispatch (the motion table found no cycle for
|
||||||
|
// this id — genuinely the case for style/stance ids) skips BOTH
|
||||||
|
// the queue AND the state-write, exactly like the "blocked,
|
||||||
|
// non-action" apply-only path below — but WITHOUT writing state.
|
||||||
|
bool dispatchOk = sink?.ApplyMotion(motion, p.Speed) ?? true;
|
||||||
|
|
||||||
|
if (!dispatchOk)
|
||||||
|
{
|
||||||
|
// Retail: `result = CPhysicsObj::DoInterpretedMotion(...)` is
|
||||||
|
// whatever the failed dispatch returned (raw 305591-305593;
|
||||||
|
// no `else` branch — `result` simply isn't overwritten by
|
||||||
|
// the `if (result == 0)` block), so DoInterpretedMotion's
|
||||||
|
// OWN return value on a failed sink dispatch is that
|
||||||
|
// nonzero code, NOT 0. The exact numeric value is sink-
|
||||||
|
// internal (MotionTableManagerError.MotionFailed = 0x43,
|
||||||
|
// not one of CMotionInterp's own WeenieError codes) — no
|
||||||
|
// CMotionInterp-level WeenieError constant maps to it
|
||||||
|
// 1:1, so this port surfaces the closest existing local
|
||||||
|
// analog (GeneralMovementFailure, 0x47) rather than
|
||||||
|
// inventing a new enum member for a sink-internal code
|
||||||
|
// that's never queued or otherwise observed by any
|
||||||
|
// CMotionInterp caller. Flagged as an ambiguity — no test
|
||||||
|
// in this repo asserts DoInterpretedMotion's return value
|
||||||
|
// on a failed dispatch (only the queue/state SIDE EFFECTS,
|
||||||
|
// which correctly do NOT run here).
|
||||||
|
result = WeenieError.GeneralMovementFailure;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WeenieError jumpErr;
|
||||||
|
if (!p.DisableJumpDuringLink)
|
||||||
|
{
|
||||||
|
jumpErr = MotionAllowsJump(motion);
|
||||||
|
if (jumpErr == WeenieError.None && (motion & 0x10000000u) == 0)
|
||||||
|
jumpErr = MotionAllowsJump(InterpretedState.ForwardCommand);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
jumpErr = WeenieError.YouCantJumpFromThisPosition; // 0x48 — forced BLOCKED
|
||||||
|
}
|
||||||
|
|
||||||
|
AddToQueue(p.ContextId, motion, (uint)jumpErr);
|
||||||
|
|
||||||
|
if (p.ModifyInterpretedState)
|
||||||
|
InterpretedState.ApplyMotion(motion, p);
|
||||||
|
|
||||||
|
result = WeenieError.None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if ((motion & 0x10000000u) == 0)
|
||||||
|
{
|
||||||
|
// label_528440 (reached without the StandingLongJump goto) —
|
||||||
|
// apply-only: state kept, no cycle change, no queue. This is
|
||||||
|
// retail's real mechanism behind "airborne remotes keep their
|
||||||
|
// Falling cycle".
|
||||||
|
if (p.ModifyInterpretedState)
|
||||||
|
InterpretedState.ApplyMotion(motion, p);
|
||||||
|
result = WeenieError.None;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result = WeenieError.NotGrounded; // 0x24 (A10) — blocked action-class motion
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((motion & 0x10000000u) == 0)
|
if (PhysicsObj.CellPosition.ObjCellId == 0)
|
||||||
return WeenieError.None; // apply-only: state kept, no cycle change
|
RemoveLinkAnimations?.Invoke();
|
||||||
|
|
||||||
return WeenieError.NotGrounded; // retail 0x24 (A10)
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>CMotionInterp::StopInterpretedMotion</c> (0x00528470, raw
|
||||||
|
/// 305635-305670, FULL BODY, R3-W5, closes J4's StopInterpretedMotion
|
||||||
|
/// leg). THE ONE <c>StopInterpretedMotion</c> — merges the former legacy
|
||||||
|
/// overload with the funnel's bare <c>sink.StopMotion</c> call.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Verbatim (raw 305638-305670):
|
||||||
|
/// <code>
|
||||||
|
/// if (physics_obj == 0) return 8;
|
||||||
|
/// if (contact_allows_move(arg2) == 0
|
||||||
|
/// || (standing_longjump != 0
|
||||||
|
/// && (arg2 == WalkForward || arg2 == RunForward || arg2 == SideStepRight))) {
|
||||||
|
/// if (arg3->bitfield & 0x4000) InterpretedMotionState::RemoveMotion(arg2);
|
||||||
|
/// result = 0;
|
||||||
|
/// } else {
|
||||||
|
/// result = sink.StopMotion(arg2, arg3); // CPhysicsObj::StopInterpretedMotion stand-in
|
||||||
|
/// if (result == 0) {
|
||||||
|
/// add_to_queue(arg3->context_id, 0x41000003, result); // result == 0 here
|
||||||
|
/// if (arg3->bitfield & 0x4000) InterpretedMotionState::RemoveMotion(arg2);
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// if (physics_obj != 0 && physics_obj->cell == 0) RemoveLinkAnimations(physics_obj);
|
||||||
|
/// return result;
|
||||||
|
/// </code>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// R3-W5 (post-fix): <see cref="IInterpretedMotionSink.StopMotion"/>
|
||||||
|
/// returns a real success signal (see that interface member's doc for
|
||||||
|
/// why a <c>void</c> sink broke the 183-case live retail conformance
|
||||||
|
/// suite on the analogous <c>DoInterpretedMotion</c> path) — a FAILED
|
||||||
|
/// stop dispatch skips both the <c>add_to_queue</c> node and the
|
||||||
|
/// <c>InterpretedMotionState::RemoveMotion</c> state clear, matching
|
||||||
|
/// retail's <c>if (result == 0) { ... }</c> gate exactly. On success,
|
||||||
|
/// the queued node's <c>JumpErrorCode</c> is always <c>0</c> (the stop's
|
||||||
|
/// own "return value" that becomes the queued error code is
|
||||||
|
/// <c>result</c>, which is <c>0</c> on that path).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="motion">Retail <c>arg2</c>.</param>
|
||||||
|
/// <param name="p">Retail <c>arg3</c>.</param>
|
||||||
|
public WeenieError StopInterpretedMotion(uint motion, MovementParameters p)
|
||||||
|
=> StopInterpretedMotion(motion, p, DefaultSink);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sink-parameterized core shared by the public
|
||||||
|
/// <see cref="StopInterpretedMotion(uint,MovementParameters)"/> and the
|
||||||
|
/// internal <see cref="DispatchStopInterpretedMotion"/> primitive — same
|
||||||
|
/// split rationale as
|
||||||
|
/// <see cref="DoInterpretedMotion(uint,MovementParameters,IInterpretedMotionSink?)"/>.
|
||||||
|
/// </summary>
|
||||||
|
private WeenieError StopInterpretedMotion(uint motion, MovementParameters p, IInterpretedMotionSink? sink)
|
||||||
|
{
|
||||||
|
if (PhysicsObj is null)
|
||||||
|
return WeenieError.NoPhysicsObject;
|
||||||
|
|
||||||
|
WeenieError result;
|
||||||
|
|
||||||
|
bool standingLongJumpStateOnly = StandingLongJump
|
||||||
|
&& (motion == MotionCommand.WalkForward
|
||||||
|
|| motion == MotionCommand.RunForward
|
||||||
|
|| motion == MotionCommand.SideStepRight);
|
||||||
|
|
||||||
|
if (!contact_allows_move(motion) || standingLongJumpStateOnly)
|
||||||
|
{
|
||||||
|
if (p.ModifyInterpretedState)
|
||||||
|
InterpretedState.RemoveMotion(motion);
|
||||||
|
result = WeenieError.None;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// sink.StopMotion stands in for CPhysicsObj::StopInterpretedMotion
|
||||||
|
// — its bool return IS retail's `result == 0`. A null sink (no
|
||||||
|
// dispatch backend wired) is treated as succeeding, matching
|
||||||
|
// DoInterpretedMotion's identical posture.
|
||||||
|
bool dispatchOk = sink?.StopMotion(motion) ?? true;
|
||||||
|
|
||||||
|
if (!dispatchOk)
|
||||||
|
{
|
||||||
|
// See DoInterpretedMotion's identical ambiguity note: retail
|
||||||
|
// returns the sink's own nonzero code here (raw 305652-305653,
|
||||||
|
// no `else` — `result` keeps whatever CPhysicsObj::StopInterpretedMotion
|
||||||
|
// returned); ported as the closest local analog.
|
||||||
|
result = WeenieError.GeneralMovementFailure;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result = WeenieError.None;
|
||||||
|
|
||||||
|
AddToQueue(p.ContextId, MotionCommand.Ready, (uint)result);
|
||||||
|
|
||||||
|
if (p.ModifyInterpretedState)
|
||||||
|
InterpretedState.RemoveMotion(motion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PhysicsObj.CellPosition.ObjCellId == 0)
|
||||||
|
RemoveLinkAnimations?.Invoke();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R3-W5 internal per-axis dispatch primitive
|
||||||
|
/// (<c>DispatchInterpretedMotion</c>, formerly the S2a funnel's public
|
||||||
|
/// surface). <see cref="ApplyInterpretedMovement"/>'s four
|
||||||
|
/// <c>DoInterpretedMotion</c>-shaped calls (style, forward-or-Falling,
|
||||||
|
/// sidestep, turn) each need to dispatch through an EXPLICIT per-call
|
||||||
|
/// <paramref name="sink"/> (a remote entity's own sink, threaded in from
|
||||||
|
/// <see cref="MoveToInterpretedState"/>) — retail's own
|
||||||
|
/// <c>apply_interpreted_movement</c> calls <c>CMotionInterp::DoInterpretedMotion</c>
|
||||||
|
/// directly (the SAME function
|
||||||
|
/// <see cref="DoInterpretedMotion(uint,MovementParameters,IInterpretedMotionSink?)"/>
|
||||||
|
/// implements) with a fresh local <c>MovementParameters</c> per axis —
|
||||||
|
/// this wraps that shape with a throwaway params object carrying only
|
||||||
|
/// <c>Speed</c> (ctor default for everything else, matching retail's own
|
||||||
|
/// local <c>var_2c</c>).
|
||||||
|
/// </summary>
|
||||||
|
private WeenieError DispatchInterpretedMotion(uint motion, float speed, IInterpretedMotionSink? sink)
|
||||||
|
=> DoInterpretedMotion(motion, new MovementParameters { Speed = speed }, sink);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R3-W5 internal per-axis STOP primitive — the
|
||||||
|
/// <see cref="StopInterpretedMotion(uint,MovementParameters,IInterpretedMotionSink?)"/>
|
||||||
|
/// analogue of <see cref="DispatchInterpretedMotion"/>, used by
|
||||||
|
/// <see cref="ApplyInterpretedMovement"/>'s sidestep-stop/turn-stop tail
|
||||||
|
/// calls (raw 305739/305748/305770 — retail's own
|
||||||
|
/// <c>CPhysicsObj::StopInterpretedMotion</c> calls with a fresh local
|
||||||
|
/// <c>MovementParameters</c>).
|
||||||
|
/// </summary>
|
||||||
|
private WeenieError DispatchStopInterpretedMotion(uint motion, IInterpretedMotionSink? sink)
|
||||||
|
=> StopInterpretedMotion(motion, new MovementParameters(), sink);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,878 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// MotionInterpreterDoMotionFamilyTests — R3-W5 (closes J3, J4, J9, J14).
|
||||||
|
//
|
||||||
|
// Oracle: docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md
|
||||||
|
// §2 DoMotion 0x00528d20 @306159
|
||||||
|
// §5a StopCompletely 0x00527e40 @305208
|
||||||
|
// §5b StopMotion 0x00528530 @305674
|
||||||
|
// §1a add_to_queue 0x00527b80 @305032
|
||||||
|
// docs/research/2026-07-02-r3-motioninterp/W0-pins.md A4 (MovementParameters
|
||||||
|
// bit table), A9 (StopCompletely jump-snapshot quirk), A10 (combat-stance /
|
||||||
|
// depth-cap error codes).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Fake WeenieObject — IsThePlayer/IsCreature/CanJump independently
|
||||||
|
/// configurable, matching the ground-lifecycle test file's convention.</summary>
|
||||||
|
file sealed class FakeWeenie : IWeenieObject
|
||||||
|
{
|
||||||
|
public bool IsThePlayerResult;
|
||||||
|
public bool IsCreatureResult = true;
|
||||||
|
public bool CanJumpResult = true;
|
||||||
|
|
||||||
|
public bool InqJumpVelocity(float extent, out float vz) { vz = 10f; return true; }
|
||||||
|
public bool InqRunRate(out float rate) { rate = 1f; return true; }
|
||||||
|
public bool CanJump(float extent) => CanJumpResult;
|
||||||
|
public bool IsThePlayer() => IsThePlayerResult;
|
||||||
|
public bool IsCreature() => IsCreatureResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MotionInterpreterDoMotionFamilyTests
|
||||||
|
{
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static PhysicsBody MakeGrounded()
|
||||||
|
{
|
||||||
|
var body = new PhysicsBody
|
||||||
|
{
|
||||||
|
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||||||
|
};
|
||||||
|
body.TransientState = TransientStateFlags.Contact
|
||||||
|
| TransientStateFlags.OnWalkable
|
||||||
|
| TransientStateFlags.Active;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MotionInterpreter MakeInterp(PhysicsBody? body = null, IWeenieObject? weenie = null)
|
||||||
|
{
|
||||||
|
body ??= MakeGrounded();
|
||||||
|
return new MotionInterpreter(body, weenie);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// DoMotion — 0x00528d20 @306159
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_NullPhysicsObj_Returns8()
|
||||||
|
{
|
||||||
|
var interp = new MotionInterpreter();
|
||||||
|
|
||||||
|
var result = interp.DoMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.NoPhysicsObject, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_CancelMoveToBit_FiresInterruptSeam()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
bool fired = false;
|
||||||
|
interp.InterruptCurrentMovement = () => fired = true;
|
||||||
|
var p = new MovementParameters { CancelMoveTo = true };
|
||||||
|
|
||||||
|
interp.DoMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
Assert.True(fired, "the sign-bit (CancelMoveTo) must fire InterruptCurrentMovement before adjust_motion");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_CancelMoveToClear_DoesNotFireInterruptSeam()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
bool fired = false;
|
||||||
|
interp.InterruptCurrentMovement = () => fired = true;
|
||||||
|
var p = new MovementParameters { CancelMoveTo = false };
|
||||||
|
|
||||||
|
interp.DoMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
Assert.False(fired);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_SetHoldKeyBit_FiresSetHoldKey_WithCancelMoveToBitAsSecondArg()
|
||||||
|
{
|
||||||
|
// A4: @306188 SetHoldKey(hold_key_to_apply, ((__inner0_1 >> 0xf) & 1)) —
|
||||||
|
// the SECOND arg is the cancel_moveto (sign) bit, not a constant.
|
||||||
|
var interp = MakeInterp();
|
||||||
|
var p = new MovementParameters
|
||||||
|
{
|
||||||
|
SetHoldKey = true,
|
||||||
|
CancelMoveTo = true,
|
||||||
|
HoldKeyToApply = HoldKey.Run,
|
||||||
|
};
|
||||||
|
|
||||||
|
interp.DoMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_SetHoldKeyBit_FiresBeforeAdjustMotion()
|
||||||
|
{
|
||||||
|
// SetHoldKey must land BEFORE adjust_motion runs so the new hold-key
|
||||||
|
// affects the walk/run reinterpretation in THIS same call (Run
|
||||||
|
// promotion of WalkForward).
|
||||||
|
var interp = MakeInterp();
|
||||||
|
var p = new MovementParameters
|
||||||
|
{
|
||||||
|
SetHoldKey = true,
|
||||||
|
HoldKeyToApply = HoldKey.Run,
|
||||||
|
};
|
||||||
|
|
||||||
|
interp.DoMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
// adjust_motion promotes WalkForward -> RunForward only when the
|
||||||
|
// hold key is (already, as of THIS call) Run.
|
||||||
|
Assert.Equal(MotionCommand.RunForward, interp.InterpretedState.ForwardCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_SetHoldKeyBitClear_DoesNotChangeHoldKey()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.RawState.CurrentHoldKey = HoldKey.None;
|
||||||
|
var p = new MovementParameters
|
||||||
|
{
|
||||||
|
SetHoldKey = false,
|
||||||
|
HoldKeyToApply = HoldKey.Run,
|
||||||
|
};
|
||||||
|
|
||||||
|
interp.DoMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_ParamsReDefaulted_CallerDistanceHeadingDiscarded()
|
||||||
|
{
|
||||||
|
// DoMotion re-derives a canonical local MovementParameters for the
|
||||||
|
// DoInterpretedMotion call — caller-supplied distance/heading fields
|
||||||
|
// must not leak into anything the interpreted call reads (the
|
||||||
|
// params ARE discarded per the decomp; this test pins that a
|
||||||
|
// caller passing bizarre distance/heading values doesn't change
|
||||||
|
// dispatch behavior vs the defaults).
|
||||||
|
var interp1 = MakeInterp();
|
||||||
|
var interp2 = MakeInterp();
|
||||||
|
var weird = new MovementParameters { DistanceToObject = 999f, DesiredHeading = 12345f, FailDistance = 1f };
|
||||||
|
var plain = new MovementParameters();
|
||||||
|
|
||||||
|
var r1 = interp1.DoMotion(MotionCommand.WalkForward, weird);
|
||||||
|
var r2 = interp2.DoMotion(MotionCommand.WalkForward, plain);
|
||||||
|
|
||||||
|
Assert.Equal(r2, r1);
|
||||||
|
Assert.Equal(interp2.InterpretedState.ForwardCommand, interp1.InterpretedState.ForwardCommand);
|
||||||
|
Assert.Equal(interp2.InterpretedState.ForwardSpeed, interp1.InterpretedState.ForwardSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── combat-stance gate (A10: 0x3f/0x40/0x41/0x42) ──────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(MotionCommand.Crouch, WeenieError.CrouchInCombatStance)]
|
||||||
|
[InlineData(MotionCommand.Sitting, WeenieError.SitInCombatStance)]
|
||||||
|
[InlineData(MotionCommand.Sleeping, WeenieError.SleepInCombatStance)]
|
||||||
|
public void DoMotion_JumpChargeMotion_RejectedInCombatStance(uint motion, WeenieError expected)
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.CurrentStyle = 0x80000001u; // any non-NonCombat stance
|
||||||
|
|
||||||
|
var result = interp.DoMotion(motion, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(MotionCommand.Crouch)]
|
||||||
|
[InlineData(MotionCommand.Sitting)]
|
||||||
|
[InlineData(MotionCommand.Sleeping)]
|
||||||
|
public void DoMotion_JumpChargeMotion_AllowedInNonCombatStance(uint motion)
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.CurrentStyle = 0x8000003Du; // NonCombat
|
||||||
|
|
||||||
|
var result = interp.DoMotion(motion, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.NotEqual(WeenieError.CrouchInCombatStance, result);
|
||||||
|
Assert.NotEqual(WeenieError.SitInCombatStance, result);
|
||||||
|
Assert.NotEqual(WeenieError.SleepInCombatStance, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_ChatEmoteBit_RejectedInCombatStance()
|
||||||
|
{
|
||||||
|
// motion & 0x2000000 outside NonCombat -> 0x42.
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.CurrentStyle = 0x80000001u;
|
||||||
|
uint motion = 0x10000000u | 0x2000000u; // action-class + chat-emote bit
|
||||||
|
|
||||||
|
var result = interp.DoMotion(motion, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.ChatEmoteOutsideNonCombat, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_ChatEmoteBit_AllowedInNonCombatStance()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.CurrentStyle = 0x8000003Du; // NonCombat
|
||||||
|
uint motion = 0x10000000u | 0x2000000u;
|
||||||
|
|
||||||
|
var result = interp.DoMotion(motion, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.NotEqual(WeenieError.ChatEmoteOutsideNonCombat, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_NonJumpChargeMotion_NotGatedByCombatStance()
|
||||||
|
{
|
||||||
|
// WalkForward (no 0x2000000 bit, not a jump-charge id) must pass
|
||||||
|
// through the combat-stance gate regardless of stance.
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.CurrentStyle = 0x80000001u; // combat stance
|
||||||
|
|
||||||
|
var result = interp.DoMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── action-depth gate (A10: 0x45, GetNumActions >= 6) ──────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_ActionClassMotion_DepthBelowSix_Allowed()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false);
|
||||||
|
|
||||||
|
uint actionMotion = 0x10000060u; // action-class bit set, not itself a jump-charge id
|
||||||
|
var result = interp.DoMotion(actionMotion, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.NotEqual(WeenieError.ActionDepthExceeded, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_ActionClassMotion_DepthExactlySix_Rejected()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
for (int i = 0; i < 6; i++)
|
||||||
|
interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false);
|
||||||
|
|
||||||
|
uint actionMotion = 0x10000060u;
|
||||||
|
var result = interp.DoMotion(actionMotion, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.ActionDepthExceeded, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_ActionClassMotion_DepthFive_NotRejected()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false);
|
||||||
|
|
||||||
|
uint actionMotion = 0x10000060u;
|
||||||
|
var result = interp.DoMotion(actionMotion, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.NotEqual(WeenieError.ActionDepthExceeded, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_NonActionClassMotion_IgnoresDepthCap()
|
||||||
|
{
|
||||||
|
// Bit 0x10000000 clear -> the depth cap never applies, even with 6+
|
||||||
|
// queued actions.
|
||||||
|
var interp = MakeInterp();
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false);
|
||||||
|
|
||||||
|
var result = interp.DoMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.NotEqual(WeenieError.ActionDepthExceeded, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── raw mirror discipline: raw gets ORIGINAL id, interpreted gets ADJUSTED ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_ModifyRawStateBit_MirrorsOriginalId_NotAdjusted()
|
||||||
|
{
|
||||||
|
// WalkBackward (ORIGINAL) adjusts to WalkForward w/ negated speed
|
||||||
|
// (interpreted) but RawState.ApplyMotion must be called with the
|
||||||
|
// ORIGINAL WalkBackward id (ebp), not the adjusted WalkForward id.
|
||||||
|
var interp = MakeInterp();
|
||||||
|
var p = new MovementParameters { ModifyRawState = true, Speed = 1.0f };
|
||||||
|
|
||||||
|
interp.DoMotion(MotionCommand.WalkBackward, p);
|
||||||
|
|
||||||
|
Assert.Equal(MotionCommand.WalkBackward, interp.RawState.ForwardCommand);
|
||||||
|
// Interpreted state, meanwhile, adopted the ADJUSTED (WalkForward) id.
|
||||||
|
Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_ModifyRawStateBitClear_DoesNotMirror()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
var p = new MovementParameters { ModifyRawState = false };
|
||||||
|
|
||||||
|
interp.DoMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
// RawState.ForwardCommand must remain at its ctor default (Ready) —
|
||||||
|
// no mirror happened.
|
||||||
|
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_FailedDispatch_DoesNotMirrorToRawState()
|
||||||
|
{
|
||||||
|
// result == 0 is required for the mirror per @306184 ("if (result ==
|
||||||
|
// 0 && bit0x2000)"). A combat-stance rejection must not mirror.
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.CurrentStyle = 0x80000001u;
|
||||||
|
var p = new MovementParameters { ModifyRawState = true };
|
||||||
|
|
||||||
|
var result = interp.DoMotion(MotionCommand.Crouch, p);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.CrouchInCombatStance, result);
|
||||||
|
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); // untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoMotion_2Arg_AppOverload_StillCompiles_AndDispatches()
|
||||||
|
{
|
||||||
|
// App-facing compat overload: DoMotion(uint, float speed = 1.0f).
|
||||||
|
var interp = MakeInterp();
|
||||||
|
|
||||||
|
var result = interp.DoMotion(MotionCommand.WalkForward, 0.8f);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// StopMotion — 0x00528530 @305674 (mirror shape, no gates)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopMotion_NullPhysicsObj_Returns8()
|
||||||
|
{
|
||||||
|
var interp = new MotionInterpreter();
|
||||||
|
|
||||||
|
var result = interp.StopMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.NoPhysicsObject, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopMotion_NoCombatStanceGate_CrouchStopsEvenInCombatStance()
|
||||||
|
{
|
||||||
|
// Unlike DoMotion, StopMotion has NO combat-stance/depth gating.
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.CurrentStyle = 0x80000001u;
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Crouch;
|
||||||
|
|
||||||
|
var result = interp.StopMotion(MotionCommand.Crouch, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopMotion_ModifyRawStateBit_RemovesOriginalId_NotAdjusted()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.RawState.ForwardCommand = MotionCommand.WalkBackward;
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||||
|
var p = new MovementParameters { ModifyRawState = true };
|
||||||
|
|
||||||
|
interp.StopMotion(MotionCommand.WalkBackward, p);
|
||||||
|
|
||||||
|
// RemoveMotion(ORIGINAL WalkBackward) hits RawState.ForwardCommand
|
||||||
|
// via the forward-class branch (adjusted WalkForward would also
|
||||||
|
// match here in this simple case, but the ORIGINAL id is what must
|
||||||
|
// be passed — see the mismatch test below for a case that
|
||||||
|
// distinguishes them).
|
||||||
|
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopMotion_ModifyRawStateBitClear_DoesNotMirror()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
|
||||||
|
var p = new MovementParameters { ModifyRawState = false };
|
||||||
|
|
||||||
|
interp.StopMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
Assert.Equal(MotionCommand.WalkForward, interp.RawState.ForwardCommand); // untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopMotion_2Arg_AppOverload_StillCompiles()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||||
|
|
||||||
|
var result = interp.StopMotion(MotionCommand.WalkForward);
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// StopCompletely — 0x00527e40 @305208 (A9 verbatim quirk)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopCompletely_NullPhysicsObj_Returns8()
|
||||||
|
{
|
||||||
|
var interp = new MotionInterpreter();
|
||||||
|
|
||||||
|
var result = interp.StopCompletely();
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.NoPhysicsObject, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopCompletely_ZeroesForwardSidestepTurnCommands_OnBothStates()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.RawState.ForwardCommand = MotionCommand.RunForward;
|
||||||
|
interp.RawState.SidestepCommand = MotionCommand.SideStepRight;
|
||||||
|
interp.RawState.TurnCommand = MotionCommand.TurnRight;
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||||
|
interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight;
|
||||||
|
interp.InterpretedState.TurnCommand = MotionCommand.TurnRight;
|
||||||
|
|
||||||
|
interp.StopCompletely();
|
||||||
|
|
||||||
|
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
|
||||||
|
Assert.Equal(1.0f, interp.RawState.ForwardSpeed);
|
||||||
|
Assert.Equal(0u, interp.RawState.SidestepCommand);
|
||||||
|
Assert.Equal(0u, interp.RawState.TurnCommand);
|
||||||
|
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
|
||||||
|
Assert.Equal(1.0f, interp.InterpretedState.ForwardSpeed);
|
||||||
|
Assert.Equal(0u, interp.InterpretedState.SideStepCommand);
|
||||||
|
Assert.Equal(0u, interp.InterpretedState.TurnCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopCompletely_DoesNotTouchSidestepOrTurnSpeeds_J9()
|
||||||
|
{
|
||||||
|
// A9/J9: retail touches ONLY forward cmd/speed + sidestep/turn
|
||||||
|
// COMMANDS — it does NOT write sidestep_speed or turn_speed. The
|
||||||
|
// pre-R3 acdream divergence (1.0 speed resets) must be gone.
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.RawState.SidestepSpeed = 2.5f;
|
||||||
|
interp.RawState.TurnSpeed = 3.5f;
|
||||||
|
interp.InterpretedState.SideStepSpeed = 4.5f;
|
||||||
|
interp.InterpretedState.TurnSpeed = 5.5f;
|
||||||
|
|
||||||
|
interp.StopCompletely();
|
||||||
|
|
||||||
|
Assert.Equal(2.5f, interp.RawState.SidestepSpeed);
|
||||||
|
Assert.Equal(3.5f, interp.RawState.TurnSpeed);
|
||||||
|
Assert.Equal(4.5f, interp.InterpretedState.SideStepSpeed);
|
||||||
|
Assert.Equal(5.5f, interp.InterpretedState.TurnSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopCompletely_ZerosPhysicsBodyVelocity()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
body.set_velocity(new Vector3(5f, 3f, 0f));
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
|
||||||
|
interp.StopCompletely();
|
||||||
|
|
||||||
|
Assert.Equal(Vector3.Zero, body.Velocity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopCompletely_InterruptsCurrentMovement()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
bool fired = false;
|
||||||
|
interp.InterruptCurrentMovement = () => fired = true;
|
||||||
|
|
||||||
|
interp.StopCompletely();
|
||||||
|
|
||||||
|
Assert.True(fired);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopCompletely_QueuedNodeErrorCode_ReflectsPreStopForwardCommand()
|
||||||
|
{
|
||||||
|
// A9 golden: motion_allows_jump is evaluated on the OLD (pre-stop)
|
||||||
|
// interpreted forward_command, snapshotted BEFORE the overwrite, and
|
||||||
|
// that snapshot becomes the queued node's jump_error_code. Crouch
|
||||||
|
// (0x41000012) is in the motion_allows_jump BLOCKED range -> 0x48.
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Crouch;
|
||||||
|
|
||||||
|
interp.StopCompletely();
|
||||||
|
|
||||||
|
var node = Assert.Single(interp.PendingMotions);
|
||||||
|
Assert.Equal(0u, node.ContextId);
|
||||||
|
Assert.Equal(MotionCommand.Ready, node.Motion);
|
||||||
|
Assert.Equal((uint)WeenieError.YouCantJumpFromThisPosition, node.JumpErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopCompletely_QueuedNodeErrorCode_ZeroWhenPreStopCommandAllowsJump()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Ready; // passes motion_allows_jump
|
||||||
|
|
||||||
|
interp.StopCompletely();
|
||||||
|
|
||||||
|
var node = Assert.Single(interp.PendingMotions);
|
||||||
|
Assert.Equal(0u, node.JumpErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopCompletely_CellNull_FiresRemoveLinkAnimations()
|
||||||
|
{
|
||||||
|
// CurCell proxy: PhysicsObj.CellPosition.ObjCellId == 0 (default/unseeded).
|
||||||
|
var interp = MakeInterp();
|
||||||
|
bool fired = false;
|
||||||
|
interp.RemoveLinkAnimations = () => fired = true;
|
||||||
|
|
||||||
|
interp.StopCompletely();
|
||||||
|
|
||||||
|
Assert.True(fired, "physics_obj->cell == 0 must fire RemoveLinkAnimations");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopCompletely_CellNonNull_DoesNotFireRemoveLinkAnimations()
|
||||||
|
{
|
||||||
|
var body = MakeGrounded();
|
||||||
|
body.SnapToCell(0x12340001u, Vector3.Zero, Vector3.Zero);
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
bool fired = false;
|
||||||
|
interp.RemoveLinkAnimations = () => fired = true;
|
||||||
|
|
||||||
|
interp.StopCompletely();
|
||||||
|
|
||||||
|
Assert.False(fired);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopCompletely_AlwaysReturnsNone_OnceCallExecutes()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Crouch; // would fail motion_allows_jump
|
||||||
|
|
||||||
|
var result = interp.StopCompletely();
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result); // StopCompletely itself always succeeds
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// StandingLongJump state-only branch: Walk/Run/SideStepRight while
|
||||||
|
// charging -> state write, NO dispatch, NO queue node.
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private sealed class RecordingSink : IInterpretedMotionSink
|
||||||
|
{
|
||||||
|
public readonly List<string> Calls = new();
|
||||||
|
public bool ApplyMotion(uint motion, float speed)
|
||||||
|
{
|
||||||
|
Calls.Add($"APPLY {motion:x8}@{speed:F2}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
public bool StopMotion(uint motion)
|
||||||
|
{
|
||||||
|
Calls.Add($"STOP {motion:x8}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoInterpretedMotion_StandingLongJumpCharging_WalkForward_StateOnly_NoDispatch_NoQueue()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.StandingLongJump = true;
|
||||||
|
interp.DefaultSink = null; // no App-level sink wired directly on DoInterpretedMotion path
|
||||||
|
|
||||||
|
var before = interp.PendingMotions.Count();
|
||||||
|
var result = interp.DoInterpretedMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand);
|
||||||
|
Assert.Equal(before, interp.PendingMotions.Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoInterpretedMotion_StandingLongJumpCharging_RunForward_StateOnly_NoDispatch_NoQueue()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.StandingLongJump = true;
|
||||||
|
|
||||||
|
var before = interp.PendingMotions.Count();
|
||||||
|
var result = interp.DoInterpretedMotion(MotionCommand.RunForward, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.Equal(MotionCommand.RunForward, interp.InterpretedState.ForwardCommand);
|
||||||
|
Assert.Equal(before, interp.PendingMotions.Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoInterpretedMotion_StandingLongJumpCharging_SideStepRight_StateOnly_NoDispatch_NoQueue()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.StandingLongJump = true;
|
||||||
|
|
||||||
|
var before = interp.PendingMotions.Count();
|
||||||
|
var result = interp.DoInterpretedMotion(MotionCommand.SideStepRight, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.Equal(MotionCommand.SideStepRight, interp.InterpretedState.SideStepCommand);
|
||||||
|
Assert.Equal(before, interp.PendingMotions.Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoInterpretedMotion_StandingLongJumpCharging_OtherMotion_DispatchesNormally()
|
||||||
|
{
|
||||||
|
// Only Walk/Run/SideStepRight get the state-only shortcut while
|
||||||
|
// charging — everything else dispatches through the normal path
|
||||||
|
// (including queueing).
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.StandingLongJump = true;
|
||||||
|
|
||||||
|
var result = interp.DoInterpretedMotion(MotionCommand.TurnRight, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.Single(interp.PendingMotions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoInterpretedMotion_NotCharging_WalkForward_DispatchesNormally_AndQueues()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.StandingLongJump = false;
|
||||||
|
|
||||||
|
var result = interp.DoInterpretedMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
Assert.Single(interp.PendingMotions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// DisableJumpDuringLink forces the queued node's jump_error_code to 0x48
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoInterpretedMotion_DisableJumpDuringLink_ForcesQueuedNodeTo0x48()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Ready; // would otherwise PASS motion_allows_jump
|
||||||
|
var p = new MovementParameters { DisableJumpDuringLink = true };
|
||||||
|
|
||||||
|
interp.DoInterpretedMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
var node = Assert.Single(interp.PendingMotions);
|
||||||
|
Assert.Equal((uint)WeenieError.YouCantJumpFromThisPosition, node.JumpErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoInterpretedMotion_DisableJumpDuringLinkClear_UsesComputedErrorCode()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.Ready;
|
||||||
|
var p = new MovementParameters { DisableJumpDuringLink = false };
|
||||||
|
|
||||||
|
interp.DoInterpretedMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
var node = Assert.Single(interp.PendingMotions);
|
||||||
|
Assert.Equal(0u, node.JumpErrorCode); // WalkForward + Ready both pass motion_allows_jump
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dead -> RemoveLinkAnimations ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoInterpretedMotion_Dead_FiresRemoveLinkAnimations()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
bool fired = false;
|
||||||
|
interp.RemoveLinkAnimations = () => fired = true;
|
||||||
|
|
||||||
|
interp.DoInterpretedMotion(MotionCommand.Dead, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.True(fired);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ModifyInterpretedState param gates the state write ─────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoInterpretedMotion_ModifyInterpretedStateClear_DoesNotWriteState()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
var p = new MovementParameters { ModifyInterpretedState = false };
|
||||||
|
|
||||||
|
interp.DoInterpretedMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); // untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoInterpretedMotion_ModifyInterpretedStateSet_WritesState()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
var p = new MovementParameters { ModifyInterpretedState = true, Speed = 1.5f };
|
||||||
|
|
||||||
|
interp.DoInterpretedMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CurCell-null tail (proxy: CellPosition.ObjCellId == 0) ──────────────
|
||||||
|
// NOTE: this is the DoInterpretedMotion success-path CurCell check per
|
||||||
|
// the plan; the merged function fires RemoveLinkAnimations whenever the
|
||||||
|
// physics_obj has no cell, mirroring StopCompletely's identical tail.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoInterpretedMotion_ContactBlocked_ActionClass_Returns0x24()
|
||||||
|
{
|
||||||
|
var body = new PhysicsBody { State = PhysicsStateFlags.Gravity }; // airborne, no contact
|
||||||
|
var interp = MakeInterp(body);
|
||||||
|
|
||||||
|
var result = interp.DoInterpretedMotion(0x10000060u, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.NotGrounded, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// StopInterpretedMotion — merged verbatim pair
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopInterpretedMotion_NullPhysicsObj_Returns8()
|
||||||
|
{
|
||||||
|
var interp = new MotionInterpreter();
|
||||||
|
|
||||||
|
var result = interp.StopInterpretedMotion(MotionCommand.WalkForward, new MovementParameters());
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.NoPhysicsObject, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopInterpretedMotion_Success_EnqueuesReturnToNoneNode()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||||
|
var p = new MovementParameters { ContextId = 42 };
|
||||||
|
|
||||||
|
interp.StopInterpretedMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
var node = Assert.Single(interp.PendingMotions);
|
||||||
|
Assert.Equal(42u, node.ContextId);
|
||||||
|
Assert.Equal(MotionCommand.Ready, node.Motion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopInterpretedMotion_ModifyInterpretedStateClear_DoesNotWriteState()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||||
|
var p = new MovementParameters { ModifyInterpretedState = false };
|
||||||
|
|
||||||
|
interp.StopInterpretedMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand); // untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// PerformMovement — zero-tick CheckForCompletedMotions flush (closes J14)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerformMovement_RawCommand_FiresCheckForCompletedMotions()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
int flushCount = 0;
|
||||||
|
interp.CheckForCompletedMotions = () => flushCount++;
|
||||||
|
|
||||||
|
interp.PerformMovement(new MovementStruct
|
||||||
|
{
|
||||||
|
Type = MovementType.RawCommand,
|
||||||
|
Motion = MotionCommand.WalkForward,
|
||||||
|
Speed = 1.0f,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(1, flushCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(MovementType.RawCommand)]
|
||||||
|
[InlineData(MovementType.InterpretedCommand)]
|
||||||
|
[InlineData(MovementType.StopRawCommand)]
|
||||||
|
[InlineData(MovementType.StopInterpretedCommand)]
|
||||||
|
[InlineData(MovementType.StopCompletely)]
|
||||||
|
public void PerformMovement_EveryDispatchedOp_FiresFlushExactlyOnce(MovementType type)
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
int flushCount = 0;
|
||||||
|
interp.CheckForCompletedMotions = () => flushCount++;
|
||||||
|
|
||||||
|
interp.PerformMovement(new MovementStruct
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
Motion = MotionCommand.WalkForward,
|
||||||
|
Speed = 1.0f,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(1, flushCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerformMovement_InvalidType_DoesNotFireFlush()
|
||||||
|
{
|
||||||
|
// The type-dispatch failure (0x47, bad MovementStruct.type) happens
|
||||||
|
// BEFORE any op is dispatched — no flush should fire for it.
|
||||||
|
var interp = MakeInterp();
|
||||||
|
int flushCount = 0;
|
||||||
|
interp.CheckForCompletedMotions = () => flushCount++;
|
||||||
|
|
||||||
|
var result = interp.PerformMovement(new MovementStruct { Type = (MovementType)99 });
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.GeneralMovementFailure, result);
|
||||||
|
Assert.Equal(0, flushCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerformMovement_FlushSeamUnset_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.CheckForCompletedMotions = null;
|
||||||
|
|
||||||
|
var result = interp.PerformMovement(new MovementStruct
|
||||||
|
{
|
||||||
|
Type = MovementType.StopCompletely,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(WeenieError.None, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerformMovement_StopCompletely_ResetsToReady_AndFlushes()
|
||||||
|
{
|
||||||
|
var interp = MakeInterp();
|
||||||
|
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
|
||||||
|
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||||
|
int flushCount = 0;
|
||||||
|
interp.CheckForCompletedMotions = () => flushCount++;
|
||||||
|
|
||||||
|
interp.PerformMovement(new MovementStruct { Type = MovementType.StopCompletely });
|
||||||
|
|
||||||
|
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
|
||||||
|
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
|
||||||
|
Assert.Equal(1, flushCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,10 +19,23 @@ public class MotionInterpreterFunnelTests
|
||||||
private sealed class RecordingSink : IInterpretedMotionSink
|
private sealed class RecordingSink : IInterpretedMotionSink
|
||||||
{
|
{
|
||||||
public readonly List<string> Calls = new();
|
public readonly List<string> Calls = new();
|
||||||
public void ApplyMotion(uint motion, float speed)
|
public bool ApplyMotion(uint motion, float speed)
|
||||||
=> Calls.Add($"DIM {motion:x8}@{speed:F2}");
|
{
|
||||||
public void StopMotion(uint motion)
|
Calls.Add($"DIM {motion:x8}@{speed:F2}");
|
||||||
=> Calls.Add($"STOP {motion:x8}");
|
// R3-W5: a style/stance id (>= 0x80000000, i.e. negative as
|
||||||
|
// int32) has no locomotion MotionData entry in the dat — retail's
|
||||||
|
// real CMotionTable::DoObjectMotion genuinely fails for it
|
||||||
|
// (MotionTableManagerError.MotionFailed). The fake sink mirrors
|
||||||
|
// that so InterpretedMotionState.ForwardCommand isn't clobbered
|
||||||
|
// by ApplyMotion's negative-motion branch before the very next
|
||||||
|
// dispatch reads it — matches the live retail-observer trace.
|
||||||
|
return motion < 0x80000000u;
|
||||||
|
}
|
||||||
|
public bool StopMotion(uint motion)
|
||||||
|
{
|
||||||
|
Calls.Add($"STOP {motion:x8}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MotionInterpreter GroundedInterp()
|
private static MotionInterpreter GroundedInterp()
|
||||||
|
|
@ -164,8 +177,26 @@ public class MotionInterpreterFunnelTests
|
||||||
// (0x00528240 early-accept), so it reaches the sink; the style and
|
// (0x00528240 early-accept), so it reaches the sink; the style and
|
||||||
// forward dispatches are gate-blocked (apply-only path).
|
// forward dispatches are gate-blocked (apply-only path).
|
||||||
Assert.Equal(new[] { "DIM 40000015@1.00", "STOP 6500000d" }, sink.Calls);
|
Assert.Equal(new[] { "DIM 40000015@1.00", "STOP 6500000d" }, sink.Calls);
|
||||||
// The interpreted STATE still flat-copies (velocity uses it on landing).
|
// R3-W5 correction: DoInterpretedMotion's verbatim body (raw
|
||||||
Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand);
|
// 305575-305631) writes InterpretedState via
|
||||||
|
// InterpretedMotionState::ApplyMotion on BOTH the success path
|
||||||
|
// (raw 305609-305610, gated on the sink's own result) AND the
|
||||||
|
// blocked/apply-only `label_528440` path (raw 305616-305618,
|
||||||
|
// UNCONDITIONAL on the ModifyInterpretedState bit — no sink
|
||||||
|
// involved at all). This W5 slice is the first port to actually
|
||||||
|
// WIRE that state-write (the pre-W5 funnel never called
|
||||||
|
// InterpretedMotionState.ApplyMotion from dispatch at all — state
|
||||||
|
// was set only by MoveToInterpretedState's flat copy at the top of
|
||||||
|
// the call), so the blocked style dispatch DOES flip
|
||||||
|
// ForwardCommand to Ready (0x41000003) at step 1, which then makes
|
||||||
|
// the Falling substitution dispatch (step 2) ITself write
|
||||||
|
// ForwardCommand = Falling (0x40000015) via the SAME mechanism —
|
||||||
|
// this replaces the assertion this comment used to make ("state
|
||||||
|
// still flat-copies") with the verbatim end state. Only
|
||||||
|
// sink.Calls (dispatch ORDER) was ever cdb-verified by this file's
|
||||||
|
// module doc; this resulting-state assertion was an untested
|
||||||
|
// assumption from the pre-W5 architecture.
|
||||||
|
Assert.Equal(0x40000015u, mi.InterpretedState.ForwardCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,18 @@ public class RetailObserverTraceConformanceTests
|
||||||
private sealed class RecordingSink : IInterpretedMotionSink
|
private sealed class RecordingSink : IInterpretedMotionSink
|
||||||
{
|
{
|
||||||
public readonly List<uint> Applied = new();
|
public readonly List<uint> Applied = new();
|
||||||
public void ApplyMotion(uint motion, float speed) => Applied.Add(motion);
|
public bool ApplyMotion(uint motion, float speed)
|
||||||
public void StopMotion(uint motion) { }
|
{
|
||||||
|
Applied.Add(motion);
|
||||||
|
// R3-W5: style/stance ids (>= 0x80000000) have no dat MotionData
|
||||||
|
// entry — retail's real motion-table lookup fails for them
|
||||||
|
// (see MotionInterpreterDoMotionFamilyTests / the interface
|
||||||
|
// doc on IInterpretedMotionSink.ApplyMotion for the full
|
||||||
|
// rationale). The fake sink mirrors that so the style dispatch
|
||||||
|
// doesn't clobber ForwardCommand before the next read.
|
||||||
|
return motion < 0x80000000u;
|
||||||
|
}
|
||||||
|
public bool StopMotion(uint motion) => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string TracePath()
|
private static string TracePath()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue