feat(R3-W4): ground transitions + lifecycle verbatim; K-fix18 DELETED (closes J8, J10, J11-shape, J12, J13, J18, J19)

Core (dedicated agent, independently reviewed): HitGround 0x00528ac0 /
LeaveGround 0x00528b00 verbatim (creature+gravity gates, the
RemoveLinkAnimations seam — K-fix18's retail mechanism — velocity via
GetLeaveGroundVelocity with the autonomous flag, jump-state resets,
apply_current_movement re-sync); enter_default_state 0x00528c80 per A8
(fresh states, InitializeMotionTables seam, sentinel APPENDED without
draining pending_motions — pinned, Initted=1, LeaveGround tail);
Initted gates; the A3 IsThePlayer dual dispatch in
apply_current_movement / ReportExhaustion / SetWeenieObject /
SetPhysicsObject (a remote player routes INTERPRETED — the
ACE-divergence pin); set_hold_run 0x00528b70 + SetHoldKey 0x00528bb0
(XOR guard, None-only-from-Run); adjust_motion creature guard wired
(TS-34 retired); PhysicsBody.LastMoveWasAutonomous +
set_local_velocity(autonomous). Port discovery: retail's
apply_raw_movement 0x005287e0 / apply_interpreted_movement 0x00528600
ARE the already-shipped D6.2a/funnel functions — the dual dispatch
composes them instead of duplicating.

App cutover (orchestrator): the skipTransitionLink flag + both K-fix18
call sites DELETED (AP-74 retired). MotionInterpreter.DefaultSink routes
apply_current_movement's interpreted branch through the REAL funnel
dispatch when a sink is bound — so a remote's LeaveGround engages
Falling via the contact-gated funnel, replacing the forced SetCycle
(J19); the per-remote MotionTableDispatchSink is now PERSISTENT
(EnsureRemoteMotionBindings: DefaultSink + RemoveLinkAnimations +
InitializeMotionTables seams, idempotent from both the UM and
VectorUpdate paths; wire velocity re-applied after LeaveGround so it
stays authoritative). Player: seams bound to the player sequencer; the
controller's grounded→airborne EDGE now fires LeaveGround (jump()
clears OnWalkable and the same frame's transition detection fires it —
retail's order; walk-off-a-ledge gets the momentum fallback + link
strip it never had); the manual jump-block LeaveGround deleted;
LastMoveWasAutonomous set at the controller chokepoint (W6 refines).

Trace S8 re-expressed as the retail mechanism (Falling dispatch +
RemoveAllLinkAnimations = same final state the flag produced). 43 new
lifecycle tests. Registers: TS-34 + AP-74 retired; TS-38, AP-77, AP-78
added. Full suite: 3,665 passed. Live smoke: in-world clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 23:01:31 +02:00
parent af4764443f
commit e214acdf23
8 changed files with 1575 additions and 124 deletions

View file

@ -353,6 +353,11 @@ public sealed class PlayerMovementController
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300;
_weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
_motion = new MotionInterpreter(_body, _weenie);
// R3-W4 (A3): the local player's movement is input-driven —
// movement_is_autonomous true so apply_current_movement's dual
// dispatch routes apply_raw_movement (IsThePlayer && autonomous).
// R3-W6 refines this per-motion (server-controlled MoveTo clears it).
_body.LastMoveWasAutonomous = true;
}
public void SetCharacterSkills(int runSkill, int jumpSkill)
@ -1008,12 +1013,13 @@ public sealed class PlayerMovementController
var jumpResult = _motion.jump(_jumpExtent);
if (jumpResult == WeenieError.None)
{
// Capture jump_v_z BEFORE LeaveGround() — that call resets
// JumpExtent back to 0 (faithful to retail's FUN_00529710),
// after which GetJumpVZ() returns 0 because the extent
// gate at the top of the function fires.
// R3-W4 (J7): the manual LeaveGround call is DELETED —
// jump() clears OnWalkable, and the SAME frame's
// grounded→airborne edge (section 5's transition detection)
// fires _motion.LeaveGround() exactly where retail's
// transition sweep does. Capture jump_v_z NOW: the edge's
// LeaveGround resets JumpExtent to 0 later this frame.
float jumpVz = _motion.GetJumpVZ();
_motion.LeaveGround();
outJumpExtent = _jumpExtent;
// D6.2: get_state_velocity() is now correct for all directions
// (apply_raw_movement normalized backward/strafe above), so the
@ -1266,6 +1272,16 @@ public sealed class PlayerMovementController
}
// R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's
// LeaveGround (0x00528b00) — jump launches (jump() cleared
// OnWalkable earlier this frame) AND walk-off-a-ledge both route
// here, replacing the old manual jump-block call (and giving
// ledge departures the momentum fallback + link strip they never
// had). Edge = grounded last frame, airborne now; the landing
// branch above already fires HitGround on the opposite edge.
if (!_body.OnWalkable && !_wasAirborneLastFrame)
_motion.LeaveGround();
_wasAirborneLastFrame = !_body.OnWalkable;
UpdateCellId(resolveResult.CellId, "resolver");

View file

@ -412,6 +412,11 @@ public sealed class GameWindow : IDisposable
{
public AcDream.Core.Physics.PhysicsBody Body;
public AcDream.Core.Physics.MotionInterpreter Motion;
/// <summary>R3-W4: the persistent per-remote dispatch sink (created
/// once by EnsureRemoteMotionBindings; also bound as
/// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch
/// cycles through the motion-table backend).</summary>
public AcDream.Core.Physics.Motion.MotionTableDispatchSink? Sink;
/// <summary>Last UpdatePosition timestamp — drives body.update_object sub-stepping.</summary>
public double LastServerPosTime;
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
@ -4176,6 +4181,45 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// R3-W4: one-time per-remote wiring of the animation-dispatch stack —
/// the persistent <see cref="RemoteMotion.Sink"/> (ObservedOmega turn
/// callbacks), <c>Motion.DefaultSink</c> (so
/// <c>apply_current_movement</c>'s interpreted branch dispatches cycles
/// — the retail mechanism behind the deleted K-fix18 forced-Falling),
/// and the <c>RemoveLinkAnimations</c>/<c>InitializeMotionTables</c>
/// seams (retail CPhysicsObj::RemoveLinkAnimations 0x0050fe20 /
/// InitializeMotionTables). Idempotent; safe from both the UM path and
/// the VectorUpdate path regardless of arrival order.
/// </summary>
private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
RemoteMotion rm, AnimatedEntity ae)
{
if (ae.Sequencer is null)
return rm.Sink;
if (rm.Sink is not null)
return rm.Sink;
var rmForSink = rm;
rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(ae.Sequencer)
{
TurnApplied = (turnMotion, turnSpeed) =>
{
float signed = (turnMotion & 0xFFu) == 0x0E
? -System.MathF.Abs(turnSpeed)
: turnSpeed;
rmForSink.ObservedOmega = new System.Numerics.Vector3(
0f, 0f, -(System.MathF.PI / 2f) * signed);
},
TurnStopped = () =>
rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
};
rm.Motion.DefaultSink = rm.Sink;
rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations;
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
return rm.Sink;
}
private bool RemoveLiveEntityByServerGuid(uint serverGuid)
{
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
@ -4623,31 +4667,13 @@ public sealed class GameWindow : IDisposable
ims.Actions = actionList;
}
// R2-Q5: funnel dispatches go STRAIGHT into the motion-
// table stack (GetObjectSequence + is_allowed decide) —
// RemoteMotionSink's single-cycle pick + fallback chains
// are deleted (AP-73 retired). The callbacks are the
// ObservedOmega seam: seed remote rotation from the wire
// turn this tick (retail rotates from sequence omega in
// the per-tick apply_physics chain — R6; register row).
AcDream.Core.Physics.Motion.MotionTableDispatchSink? sink = null;
if (ae.Sequencer is not null)
{
var rmForSink = remoteMot;
sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(ae.Sequencer)
{
TurnApplied = (turnMotion, turnSpeed) =>
{
float signed = (turnMotion & 0xFFu) == 0x0E
? -System.MathF.Abs(turnSpeed)
: turnSpeed;
rmForSink.ObservedOmega = new System.Numerics.Vector3(
0f, 0f, -(System.MathF.PI / 2f) * signed);
},
TurnStopped = () =>
rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
};
}
// R2-Q5/R3-W4: funnel dispatches go STRAIGHT into the
// motion-table stack (GetObjectSequence + is_allowed
// decide). The sink is PERSISTENT per remote and also
// bound as Motion.DefaultSink + the RemoveLink/InitTables
// seams (EnsureRemoteMotionBindings) so LeaveGround/
// HitGround dispatch through the same backend.
var sink = EnsureRemoteMotionBindings(remoteMot, ae);
remoteMot.Motion.MoveToInterpretedState(ims, sink);
}
}
@ -4813,26 +4839,24 @@ public sealed class GameWindow : IDisposable
| AcDream.Core.Physics.TransientStateFlags.OnWalkable);
rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity;
// K-fix10 (2026-04-26): force the Falling animation cycle on
// the remote so the legs match the arc. Mirrors the local
// player's UpdatePlayerAnimation path which sets
// animCommand = Falling whenever !IsOnGround.
//
// K-fix18 (2026-04-26): pass skipTransitionLink:true so the
// RunForward→Falling transition frames don't play first.
// Without that flag the remote stood still for ~100 ms at
// the start of the jump while the link drained, then
// folded into Falling. Skipping the link makes the pose
// engage immediately on jump start.
// R3-W4 (J19 — K-fix10/K-fix18 DELETED): the retail mechanism.
// The remote's ground departure fires LeaveGround (0x00528b00):
// strips pending transition links (the RemoveLinkAnimations
// seam) + re-applies movement through DefaultSink, whose
// contact-gated funnel dispatch engages Falling — no forced
// SetCycle, no skip flag. The wire velocity/omega are re-applied
// AFTER so they stay authoritative over LeaveGround's
// state-derived velocity write (adaptation note: retail's
// equivalence comes from the per-tick transition-sweep order —
// R6 scope).
if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent)
&& _animatedEntities.TryGetValue(ent.Id, out var ae)
&& ae.Sequencer is not null)
{
uint style = ae.Sequencer.CurrentStyle != 0
? ae.Sequencer.CurrentStyle
: 0x8000003Du; // NonCombat default
ae.Sequencer.SetCycle(style, AcDream.Core.Physics.MotionCommand.Falling, 1.0f,
skipTransitionLink: true);
EnsureRemoteMotionBindings(rm, ae);
rm.Motion.LeaveGround();
rm.Body.Velocity = update.Velocity;
rm.Body.Omega = update.Omega;
}
}
@ -10241,14 +10265,11 @@ public sealed class GameWindow : IDisposable
{
animScale = s;
}
// K-fix18 (2026-04-26): when transitioning into Falling
// (jump start), skip the link so the legs engage Falling
// immediately. Without this the local player visibly
// stood still for ~100 ms at the start of every jump
// while the RunForward→Falling transition link drained.
// For everything else (Walk → Run, Run → Ready, etc.) we
// keep the link so transitions stay smooth.
bool skipLink = animCommand == AcDream.Core.Physics.MotionCommand.Falling;
// R3-W4: the K-fix18 skipLink flag is DELETED — the instant
// Falling engage comes from the controller's grounded→airborne
// edge firing MotionInterpreter.LeaveGround, whose
// RemoveLinkAnimations seam (bound at sequencer creation)
// strips pending links exactly where retail does.
// #45 (2026-05-06): scale sidestep speedMod to match ACE's
// wire formula. PlayerMovementController hands us a raw
@ -10270,8 +10291,7 @@ public sealed class GameWindow : IDisposable
* 0.5f;
}
ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * animScale,
skipTransitionLink: skipLink);
ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * animScale);
}
// Legacy path: update the manual slerp fields (for entities without sequencer)
@ -12965,6 +12985,13 @@ public sealed class GameWindow : IDisposable
&& playerAE.Sequencer is { } playerSeq)
{
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
// R3-W4: bind the player interp's retail seams to the player
// sequencer — LeaveGround/HitGround strip transition links here
// (the K-fix18 replacement); DefaultSink stays null until R3-W6
// (UpdatePlayerAnimation still drives the player's cycles).
_playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations;
_playerController.Motion.InitializeMotionTables =
() => playerSeq.Manager.InitializeState();
}
var q = playerEntity.Rotation;

View file

@ -40,7 +40,7 @@ public sealed class DatCollectionLoader : IAnimationLoader
// see CSequence.cs/AnimSequenceNode.cs/FrameOps.cs). This adapter keeps every
// public member's signature (the API-migration table in the gap map is the
// contract) and re-expresses each one over the core. Invented mechanisms that
// are still R2/R3 scope (K-fix18 skipTransitionLink, Fix B locomotion
// were R2/R3 scope (K-fix18 skipTransitionLink DELETED in R3-W4, Fix B locomotion
// link-skip, stop-anim fallback, GetLink's reversed branch, velocity/omega
// synthesis constants) SURVIVE unchanged at the adapter level per the gap
// map's "Invented behaviors NOT in the gap list" note.
@ -339,15 +339,7 @@ public sealed class AnimationSequencer
/// <param name="style">MotionCommand style / stance (e.g. NonCombat 0x003D0000).</param>
/// <param name="motion">Target motion command (e.g. WalkForward 0x45000005).</param>
/// <param name="speedMod">Speed multiplier applied to framerates (1.0 = normal).</param>
/// <param name="skipTransitionLink">K-fix18 (2026-04-26): when true, do
/// NOT enqueue the transition-link frames between the previous and
/// new cycle. Used when the caller wants the new cycle to engage
/// instantly — e.g. swapping to Falling on a jump start, where the
/// RunForward→Falling link is a short "stop running" pose that
/// makes the jump look delayed (legs stand still for ~100 ms while
/// the link drains, then fold into Falling). Defaults to false to
/// preserve normal smooth transitions for everything else.</param>
public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false)
public void SetCycle(uint style, uint motion, float speedMod = 1f)
{
EnsureInitialized();
@ -394,17 +386,11 @@ public sealed class AnimationSequencer
uint dispatchResult = PerformMovement(
MotionTableMovement.Interpreted(adjustedMotion, adjustedSpeed));
// K-fix18 (2026-04-26, register row survives → R3): instant-engage
// cases (Falling on jump start) strip the just-selected transition
// links so the new cycle starts immediately. Post-Q4 expression:
// the retail primitive CPhysicsObj::RemoveLinkAnimations
// (0x0050fe20 → CSequence::remove_all_link_animations) — exactly
// what R3-W4's LeaveGround will call, at which point this flag and
// its GameWindow call sites are deleted. The pending-queue entry's
// tick count intentionally stays (retail's RemoveLinkAnimations
// does not touch the manager queue; the countdown chain absorbs it).
if (skipTransitionLink)
_core.RemoveAllLinkAnimations();
// R3-W4: the K-fix18 skipTransitionLink flag is DELETED — the
// instant-Falling engage is now retail's own mechanism: the entity's
// MotionInterpreter.LeaveGround (0x00528b00) fires the
// RemoveLinkAnimations seam (bound to this sequencer's
// RemoveAllLinkAnimations) when the body leaves the ground.
// Failed dispatch (missing cycle for this style, is_allowed reject):
// retail leaves sequence AND state untouched — no synthesis either.
@ -446,6 +432,18 @@ public sealed class AnimationSequencer
}
}
/// <summary>
/// R3-W4: the retail link-strip primitive
/// (<c>CPhysicsObj::RemoveLinkAnimations</c> 0x0050fe20 →
/// <c>CSequence::remove_all_link_animations</c> 0x00524ca0). The App
/// binds the entity's <c>MotionInterpreter.RemoveLinkAnimations</c> seam
/// here so HitGround/LeaveGround strip pending transition links exactly
/// where retail does. The pending-queue tick counts intentionally stay
/// (retail's primitive does not touch the manager queue; the countdown
/// chain absorbs it).
/// </summary>
public void RemoveAllLinkAnimations() => _core.RemoveAllLinkAnimations();
/// <summary>
/// R2-Q5: run retail's <c>enter_default_state</c> analog now if it
/// hasn't run yet (idempotent). Spawn paths call this so an entity with

View file

@ -439,10 +439,12 @@ public interface IWeenieObject
/// <summary>
/// Retail <c>CWeenieObject::IsCreature</c>. Non-creature weenies bypass
/// the ground-contact gate in <c>contact_allows_move</c> (0x00528240)
/// and the run remap in <c>adjust_motion</c>. Players, NPCs, and
/// the ground-contact gate in <c>contact_allows_move</c> (0x00528240),
/// the run remap in <c>adjust_motion</c> (wired R3-W4, closes J18 —
/// register TS-34 retired), and the <see cref="HitGround"/>/
/// <see cref="LeaveGround"/> creature gates. Players, NPCs, and
/// monsters are creatures — default true keeps existing implementers
/// retail-correct (register TS-34 tracks the adjust_motion side).
/// retail-correct.
/// </summary>
bool IsCreature() => true;
@ -639,6 +641,76 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// </summary>
public Action? InterruptCurrentMovement { get; set; }
/// <summary>
/// R3-W4 seam standing in for retail <c>CPhysicsObj::RemoveLinkAnimations</c>
/// (0x0050fe20, decomp §9 summary table) → <c>CPartArray</c> →
/// <c>CSequence::remove_all_link_animations</c> — called from
/// <see cref="HitGround"/>/<see cref="LeaveGround"/> (§4a/4b) whenever
/// the ground-transition body actually runs. This is retail's REAL
/// mechanism for "leaving the ground strips any pending link
/// animation so Falling engages instantly" — the K-fix18 App-side
/// forced-<c>SetCycle(skipTransitionLink:true)</c> hack this seam
/// replaces is deleted in the orchestrator's App-side half (register
/// row retirement rides with that deletion, not this commit — Core
/// has no K-fix18 code to remove). The App layer binds this to the
/// entity's <c>AnimationSequencer</c>/<c>CSequence</c> core
/// (<c>RemoveAllLinkAnimations</c>-equivalent) per entity, matching the
/// existing <see cref="UnstickFromObject"/>/<see cref="InterruptCurrentMovement"/>
/// <c>Action?</c> seam convention. Optional — a null binding is a
/// silent no-op (safe default for tests and for any physics body with
/// no sequencer attached yet).
/// </summary>
public Action? RemoveLinkAnimations { get; set; }
/// <summary>
/// R3-W4 no-op seam standing in for retail
/// <c>CPhysicsObj::InitializeMotionTables</c>, called from
/// <see cref="EnterDefaultState"/> (§4d, raw @306124:
/// <c>CPhysicsObj::InitializeMotionTables(this-&gt;physics_obj);</c>).
/// This re-seeds the physics object's <c>MotionTableManager</c>/motion
/// table stack — a manager-side (App-bound) concern, not something
/// <c>MotionInterpreter</c> itself owns state for. Register row: no-op
/// until the App layer binds it to the entity's motion-table
/// initialization; matches the <see cref="RemoveLinkAnimations"/> seam
/// convention.
/// </summary>
public Action? InitializeMotionTables { get; set; }
/// <summary>
/// R3-W4 — retail <c>CMotionInterp::initted</c> (set by
/// <see cref="EnterDefaultState"/>, raw @306152: <c>this-&gt;initted =
/// 1;</c>). Gates <see cref="apply_current_movement"/> and
/// <see cref="ReportExhaustion"/>'s entry (A3: <c>physics_obj != 0
/// &amp;&amp; initted != 0</c>).
///
/// <para>
/// <b>Constructor default is <c>true</c>, NOT retail's <c>false</c>
/// (see the final-report note on this divergence).</b> Retail's
/// <c>CMotionInterp</c> is never used standalone — every real
/// construction path (<c>MovementManager::PerformMovement</c>'s
/// lazy-create, <c>MovementManager::Create</c>) immediately calls
/// <see cref="EnterDefaultState"/> (retail's <c>enter_default_state</c>)
/// before the interpreter is exposed to any caller, so <c>initted</c>
/// is always <c>1</c> by the time application code can observe it.
/// acdream's <c>MotionInterpreter</c> constructor is used directly by
/// ~40 pre-existing tests AND by both App call sites
/// (<c>PlayerMovementController</c>, <c>GameWindow</c>) as a
/// complete, immediately-usable object with no separate
/// "enter default state" step — porting retail's literal
/// <c>false</c> default would silently no-op every one of those
/// call sites' <c>apply_current_movement</c>/<c>ReportExhaustion</c>
/// calls until something remembers to call
/// <see cref="EnterDefaultState"/> (which nothing currently does).
/// Defaulting <c>true</c> here is the C# equivalent of "the
/// constructor already did what <c>enter_default_state</c> would have
/// done to this flag" — <see cref="EnterDefaultState"/> remains the
/// verbatim, fully-testable retail method for callers (tests, and a
/// future App reset-to-idle path) that need the REST of its behavior
/// (state reset, sentinel enqueue, <see cref="LeaveGround"/> tail).
/// </para>
/// </summary>
public bool Initted { get; set; } = true;
/// <summary>
/// Optional accessor for the owning entity's current animation cycle
/// velocity (AnimationSequencer.CurrentVelocity, i.e. MotionData.Velocity
@ -1038,12 +1110,12 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// </para>
///
/// <para>
/// <b>Creature guard (retail <c>weenie.IsCreature()</c>) is UNWIRED.</b>
/// <see cref="IWeenieObject"/> does not currently expose an
/// <c>IsCreature</c> query, so this port always proceeds (equivalent to
/// "no weenie" or "is a creature" — true for the player, which is the
/// only caller as of D6.1). Flagged for D6.2 / a future IWeenieObject
/// extension; do not fabricate a guess at the query here.
/// <b>R3-W4 (closes J18, retires register TS-34): creature guard WIRED.</b>
/// Retail (raw @305343): <c>if (weenie != 0 &amp;&amp; weenie-&gt;IsCreature() == 0)
/// return;</c> — a weenie present whose <c>IsCreature()</c> returns
/// false short-circuits the ENTIRE function (no normalization, no
/// hold-key path). <see cref="IWeenieObject.IsCreature"/> has existed
/// since W3; this was the one caller left un-gated.
/// </para>
/// </summary>
/// <param name="motion">Raw motion command, normalized in place.</param>
@ -1055,7 +1127,8 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// </param>
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
{
// Creature guard unwired — IWeenieObject has no IsCreature(); always proceed.
if (WeenieObj is not null && !WeenieObj.IsCreature())
return;
switch (motion)
{
@ -1187,26 +1260,145 @@ public sealed class MotionInterpreter : IMotionDoneSink
InterpretedState.TurnCommand = tCmd; InterpretedState.TurnSpeed = tSpd;
}
// ── FUN_00529210 — apply_current_movement ─────────────────────────────────
// ── FUN_00528870 — apply_current_movement ─────────────────────────────────
/// <summary>
/// Apply the current interpreted motion state as a local velocity to the PhysicsBody.
/// <c>CMotionInterp::apply_current_movement</c> (0x00528870, decomp §4e,
/// W0-pins.md A3, FULL BODY dual dispatch — replaces the pre-W4
/// direct-velocity approximation).
///
/// Decompiled logic (FUN_00529210):
/// if PhysicsObj == null: return
/// FUN_00524f80() — internal animation state update
/// If ForwardCommand == RunForward: update MyRunRate = ForwardSpeed
/// Then delegates to DoInterpretedMotion for each active command,
/// which ultimately calls set_local_velocity via FUN_00528960.
/// <para>
/// Verbatim (raw 305838-305857):
/// <code>
/// if (physics_obj != 0 &amp;&amp; initted != 0) {
/// if (weenie_obj != 0) eax_2 = weenie_obj-&gt;IsThePlayer();
/// if ((weenie_obj == 0 || eax_2 != 0)
/// &amp;&amp; movement_is_autonomous(physics_obj) != 0) {
/// apply_raw_movement(this, arg2, arg3);
/// return;
/// }
/// apply_interpreted_movement(this, arg2, arg3);
/// }
/// </code>
/// </para>
///
/// In our physics-only port we compute the body-local velocity via
/// get_state_velocity() and push it directly to PhysicsBody.set_local_velocity.
/// <para>
/// A3: the dispatch gate is <c>IsThePlayer</c> (no weenie = treat as
/// the player), NOT <c>IsCreature</c> — a remote player weenie
/// (<c>IsThePlayer()==false</c>, <c>IsCreature()==true</c>) routes
/// INTERPRETED even when <c>movement_is_autonomous</c> is true. This is
/// the genuine divergence from ACE's server-side <c>IsCreature</c> gate
/// (ACE MotionInterp.cs:430-438) — do not copy.
/// </para>
/// </summary>
public void apply_current_movement(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj is null || !Initted)
return;
bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer();
if (isThePlayer && PhysicsObj.LastMoveWasAutonomous)
{
apply_raw_movement(cancelMoveTo, allowJump);
return;
}
ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump);
}
/// <summary>
/// <c>CMotionInterp::apply_raw_movement</c> (0x005287e0, decomp §4e
/// referenced body, raw 305817-305834), the <c>(cancelMoveTo,
/// allowJump)</c>-arg overload reached from
/// <see cref="apply_current_movement"/>'s dual dispatch (A3's
/// "IsThePlayer + autonomous" branch), <see cref="ReportExhaustion"/>,
/// <see cref="SetWeenieObject"/>, and <see cref="SetPhysicsObject"/>.
///
/// <para>
/// Verbatim: copy the SEVEN raw-state fields (style + all three
/// axis command/speed pairs) into <see cref="InterpretedState"/>,
/// normalize each of the three axes IN PLACE via <see cref="adjust_motion"/>
/// using THAT axis's own <see cref="RawState"/> hold key, then
/// tail-call <c>apply_interpreted_movement(this, arg2, arg3)</c> — the
/// SAME retail function (0x00528600) as <see cref="ApplyInterpretedMovement"/>
/// (the funnel producer). This is NOT a new function: it is the
/// existing <see cref="apply_raw_movement(RawMotionState)"/> overload's
/// body (the D6.2a raw-state-orchestrator port), reached here with
/// <see cref="RawState"/> itself as the source instead of an externally
/// supplied snapshot, plus the previously-missing
/// <c>apply_interpreted_movement(arg2, arg3)</c> tail call this
/// dispatch site needs.
/// </para>
/// </summary>
public void apply_raw_movement(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj is null)
return;
apply_raw_movement(RawState);
ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump);
}
/// <summary>
/// The retail <c>apply_interpreted_movement</c> tail reached from BOTH
/// dual-dispatch branches of <see cref="apply_current_movement"/> (raw
/// 305855/305832 — <c>CMotionInterp::apply_interpreted_movement</c>,
/// 0x00528600, the SAME function as the funnel's
/// <see cref="ApplyInterpretedMovement"/>, per the raw's identical call
/// target at every one of its six call sites).
///
/// <para>
/// <b>Physics-only-port body (pre-W4 approximation, SURVIVES per the
/// plan's "less invasive" choice — see the final-report note):</b> this
/// port has no <see cref="IInterpretedMotionSink"/> wired at this call
/// site (retail's real body drives <c>DoInterpretedMotion</c>/
/// <c>StopInterpretedMotion</c> through the animation-table backend,
/// exactly like <see cref="ApplyInterpretedMovement"/> already does for
/// the INBOUND funnel path — but that path always has a sink available
/// from its caller, while <c>apply_current_movement</c>'s callers
/// (<see cref="HitGround"/>/<see cref="LeaveGround"/>/
/// <see cref="ReportExhaustion"/>/hold-key toggles/jump) do not thread
/// one through). Kept as the pre-existing direct
/// <c>get_state_velocity</c> → grounded <c>set_local_velocity</c> write
/// — an acdream adaptation of root-motion-driven velocity, register row
/// added same commit — until R6 (root motion drives the body directly
/// and this local-only fallback retires in favor of a real sink here
/// too).
/// </para>
/// </summary>
/// <summary>
/// R3-W4 (App-bound): the entity's persistent animation-dispatch sink.
/// When set, <see cref="ApplyCurrentMovementInterpreted"/> routes the
/// REAL <see cref="ApplyInterpretedMovement"/> (retail
/// <c>apply_interpreted_movement</c> 0x00528600 — the same function the
/// inbound funnel drives), so HitGround/LeaveGround/hold-key re-applies
/// dispatch cycles (the airborne-Falling engage) through the
/// motion-table backend exactly as retail does. Null (the local player
/// until R3-W6, interp-less entities) → the AP-77 physics-only tail.
/// </summary>
public IInterpretedMotionSink? DefaultSink { get; set; }
private void ApplyCurrentMovementInterpreted(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj is null)
return;
// R3-W4: with a persistent sink bound (remotes), this IS retail's
// apply_interpreted_movement — full dispatch through the funnel
// body (style → forward-or-Falling → sidestep → turn), which is
// what makes LeaveGround engage Falling without the deleted
// K-fix18 forced-SetCycle. Without a sink, fall through to the
// AP-77 physics-only adaptation below.
if (DefaultSink is not null)
{
ApplyInterpretedMovement(InterpretedState.CurrentStyle, DefaultSink,
cancelMoveTo, allowJump);
return;
}
_ = cancelMoveTo;
_ = allowJump;
// Decompile writes back MyRunRate when in run state (offset +0x7C).
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
MyRunRate = InterpretedState.ForwardSpeed;
@ -1220,10 +1412,159 @@ public sealed class MotionInterpreter : IMotionDoneSink
if (PhysicsObj.OnWalkable)
{
var localVelocity = get_state_velocity();
PhysicsObj.set_local_velocity(localVelocity);
// AP-77: this adaptation's fallback write has no real opinion on
// "was this move autonomous" (retail's real set_local_velocity
// call for this dispatch path lives deep inside the animation
// backend this stand-in doesn't have — see the seam's doc
// comment). Preserve whatever LastMoveWasAutonomous already
// holds rather than resetting it, so a caller like LeaveGround
// (which just set it true via its OWN direct set_local_velocity
// call, autonomous=1 per raw @305763-305765) isn't clobbered by
// this immediately-following re-sync tail call.
PhysicsObj.set_local_velocity(localVelocity, PhysicsObj.LastMoveWasAutonomous);
}
}
// ── FUN_005288d0 — ReportExhaustion ────────────────────────────────────────
/// <summary>
/// <c>CMotionInterp::ReportExhaustion</c> (0x005288d0, decomp §4c
/// @305861, FULL BODY, W0-pins.md A3). Identical entry gate and
/// dual-dispatch predicate to <see cref="apply_current_movement"/>,
/// with HARDCODED <c>(0, 0)</c> dispatch args (raw 305874/305878) —
/// the retail caller (<c>CPhysicsObj::report_exhaustion</c> 0x0050fdd0
/// → <c>MovementManager::ReportExhaustion</c> 0x00524360) passes no
/// per-call parameters through.
///
/// <para>
/// Verbatim (raw 305861-305880):
/// <code>
/// if (physics_obj != 0 &amp;&amp; initted != 0) {
/// if (weenie_obj != 0) eax_2 = weenie_obj-&gt;IsThePlayer();
/// if ((weenie_obj == 0 || eax_2 != 0)
/// &amp;&amp; movement_is_autonomous(physics_obj) != 0) {
/// apply_raw_movement(this, 0, 0);
/// return;
/// }
/// apply_interpreted_movement(this, 0, 0);
/// }
/// </code>
/// </para>
/// </summary>
public void ReportExhaustion()
{
if (PhysicsObj is null || !Initted)
return;
bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer();
if (isThePlayer && PhysicsObj.LastMoveWasAutonomous)
{
apply_raw_movement(cancelMoveTo: false, allowJump: false);
return;
}
ApplyCurrentMovementInterpreted(cancelMoveTo: false, allowJump: false);
}
// ── FUN_00528920 / FUN_00528970 — SetWeenieObject / SetPhysicsObject ───────
/// <summary>
/// <c>CMotionInterp::SetWeenieObject</c> (0x00528920, decomp §4f
/// @305884, FULL BODY, W0-pins.md A3). Assigns <see cref="WeenieObj"/>,
/// then — if already physics-bound AND <see cref="Initted"/> — re-applies
/// movement via the SAME dual-dispatch predicate as
/// <see cref="apply_current_movement"/>, EXCEPT the weenie tested for
/// <c>IsThePlayer</c> is the INCOMING <paramref name="weenie"/> (not
/// <see cref="WeenieObj"/> post-assignment — they're the same value
/// here, but retail's raw makes the incoming-arg read explicit: a null
/// incoming weenie takes the SAME branch as a non-null-but-IsThePlayer
/// weenie, both via <c>goto label_528946</c>). Dispatch args are
/// hardcoded <c>(1, 0)</c>.
///
/// <para>
/// Verbatim (raw 305884-305907):
/// <code>
/// bool noPhysics = (physics_obj == 0);
/// weenie_obj = arg2;
/// if (!noPhysics &amp;&amp; initted != 0) {
/// if (arg2 == 0) {
/// label_528946:
/// if (movement_is_autonomous(physics_obj) != 0) {
/// apply_raw_movement(this, 1, 0);
/// return;
/// }
/// } else if (arg2-&gt;IsThePlayer() != 0)
/// goto label_528946;
/// apply_interpreted_movement(this, 1, 0);
/// }
/// </code>
/// </para>
/// </summary>
public void SetWeenieObject(IWeenieObject? weenie)
{
WeenieObj = weenie;
if (PhysicsObj is null || !Initted)
return;
bool isThePlayer = weenie is null || weenie.IsThePlayer();
if (isThePlayer && PhysicsObj.LastMoveWasAutonomous)
{
apply_raw_movement(cancelMoveTo: true, allowJump: false);
return;
}
ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: false);
}
/// <summary>
/// <c>CMotionInterp::SetPhysicsObject</c> (0x00528970, decomp §4g
/// @305911, FULL BODY, W0-pins.md A3). Assigns <see cref="PhysicsObj"/>
/// unconditionally FIRST, then — if the incoming
/// <paramref name="physicsObj"/> is non-null AND <see cref="Initted"/> —
/// re-applies movement via the standard dual-dispatch predicate (this
/// time reading <see cref="WeenieObj"/>, unchanged by this call).
/// Dispatch args are hardcoded <c>(1, 0)</c>, matching
/// <see cref="SetWeenieObject"/>.
///
/// <para>
/// Verbatim (raw 305911-305932):
/// <code>
/// this-&gt;physics_obj = arg2;
/// if (arg2 != 0 &amp;&amp; initted != 0) {
/// if (weenie_obj != 0) eax_1 = weenie_obj-&gt;IsThePlayer();
/// if ((weenie_obj == 0 || eax_1 != 0)
/// &amp;&amp; movement_is_autonomous(physics_obj) != 0) {
/// apply_raw_movement(this, 1, 0);
/// return;
/// }
/// apply_interpreted_movement(this, 1, 0);
/// }
/// </code>
/// Note the entry gate is on the INCOMING <c>arg2</c> (not the
/// just-assigned <c>this-&gt;physics_obj</c> — same value, but the raw
/// tests the parameter register directly), unlike
/// <see cref="SetWeenieObject"/>'s entry gate which tests the
/// PRE-assignment <c>physics_obj</c> snapshot (<c>noPhysics</c>).
/// </para>
/// </summary>
public void SetPhysicsObject(PhysicsBody? physicsObj)
{
PhysicsObj = physicsObj;
if (physicsObj is null || !Initted)
return;
bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer();
if (isThePlayer && physicsObj.LastMoveWasAutonomous)
{
apply_raw_movement(cancelMoveTo: true, allowJump: false);
return;
}
ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: false);
}
// ── FUN_00527a50 — jump_charge_is_allowed ─────────────────────────────────
/// <summary>
@ -1832,51 +2173,245 @@ public sealed class MotionInterpreter : IMotionDoneSink
return WeenieError.YouCantJumpFromThisPosition;
}
// ── FUN_00529710 — LeaveGround ────────────────────────────────────────────
// ── FUN_00528b00 — LeaveGround ────────────────────────────────────────────
/// <summary>
/// Called when the body becomes airborne. Applies the leave-ground velocity
/// and resets the jump state.
/// <c>CMotionInterp::LeaveGround</c> (0x00528b00, decomp §4b @306022,
/// FULL BODY — replaces the pre-W4 approximation, closes J8's
/// LeaveGround leg). Called when the body becomes airborne (also
/// tail-called unconditionally from <see cref="EnterDefaultState"/>,
/// §4d).
///
/// Decompiled logic (FUN_00529710):
/// if PhysicsObj == null: return
/// velocity = GetLeaveGroundVelocity()
/// PhysicsObj.set_local_velocity(velocity)
/// StandingLongJump = false
/// JumpExtent = 0
/// <para>
/// Verbatim (raw 306022-306040, quoted in full in the final-report):
/// creature gate (<c>weenie_obj == 0 || IsCreature() != 0</c>) AND
/// state-0x400 (Gravity) gate — SAME shape as <see cref="HitGround"/>.
/// When both pass: compute the launch velocity via
/// <see cref="GetLeaveGroundVelocity"/>, apply it with
/// <c>set_local_velocity(&amp;v, autonomous=1)</c> (the literal
/// <c>1</c> arg — <see cref="PhysicsBody.LastMoveWasAutonomous"/>
/// becomes true), reset <see cref="StandingLongJump"/> and
/// <see cref="JumpExtent"/> to their zero/false defaults (the jump
/// charge is consumed the instant you actually leave the ground), then
/// <see cref="RemoveLinkAnimations"/> + <c>apply_current_movement(0,
/// 0)</c> (the same re-sync <see cref="HitGround"/> performs).
/// </para>
/// </summary>
public void LeaveGround()
{
if (PhysicsObj is null)
return;
bool isCreature = WeenieObj is null || WeenieObj.IsCreature();
if (!isCreature)
return;
if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
return;
var velocity = GetLeaveGroundVelocity();
PhysicsObj.set_local_velocity(velocity);
PhysicsObj.set_local_velocity(velocity, autonomous: true);
StandingLongJump = false;
JumpExtent = 0f;
RemoveLinkAnimations?.Invoke();
apply_current_movement(cancelMoveTo: false, allowJump: false);
}
// ── FUN_005296d0 — HitGround ──────────────────────────────────────────────
// ── FUN_00528ac0 — HitGround ──────────────────────────────────────────────
/// <summary>
/// Called when the body lands on a walkable surface.
/// <c>CMotionInterp::HitGround</c> (0x00528ac0, decomp §4a @305996,
/// FULL BODY — replaces the pre-W4 approximation, closes J8's
/// HitGround leg). Called when the body lands on a walkable surface.
///
/// Decompiled logic (FUN_005296d0):
/// if PhysicsObj == null: return
/// if WeenieObj != null AND NOT creature: return
/// if Gravity flag not set: return
/// apply_current_movement(false, true)
/// <para>
/// Verbatim (raw 305996-306014):
/// <code>
/// if (physics_obj != 0) {
/// if (weenie_obj != 0) eax_2 = weenie_obj-&gt;IsCreature();
/// if (weenie_obj == 0 || eax_2 != 0) {
/// if (physics_obj != 0 &amp;&amp; (state &amp; 0x400) != 0) {
/// RemoveLinkAnimations(physics_obj);
/// apply_current_movement(this, 0, 0);
/// }
/// }
/// }
/// </code>
/// </para>
///
/// <para>
/// A3 note: this creature gate uses <c>IsCreature</c> (vtable +0x2c),
/// NOT <c>IsThePlayer</c> (+0x14) — the anti-artifact proof for A3's
/// pin (BN distinguishes the two slots locally, ~0x250 bytes from the
/// IsThePlayer-gated functions).
/// </para>
/// </summary>
public void HitGround()
{
if (PhysicsObj is null)
return;
bool isCreature = WeenieObj is null || WeenieObj.IsCreature();
if (!isCreature)
return;
if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
return;
apply_current_movement(cancelMoveTo: false, allowJump: true);
RemoveLinkAnimations?.Invoke();
apply_current_movement(cancelMoveTo: false, allowJump: false);
}
// ── FUN_00528c80 — enter_default_state ────────────────────────────────────
/// <summary>
/// <c>CMotionInterp::enter_default_state</c> (0x00528c80, decomp §4d
/// @306124, FULL BODY, W0-pins.md A8, closes J10). Resets both motion
/// states to fresh defaults, re-initializes the physics object's
/// motion tables, APPENDS the canonical <c>{0, Ready, 0}</c> sentinel
/// to <see cref="PendingMotions"/> (NO drain — pre-existing queued
/// nodes survive, A8), marks <see cref="Initted"/>, then tail-calls
/// <see cref="LeaveGround"/> unconditionally.
///
/// <para>
/// Verbatim (raw 306124-306154):
/// <code>
/// raw_state = RawMotionState(); // fresh ctor defaults
/// interpreted_state = InterpretedMotionState(); // fresh ctor defaults
/// CPhysicsObj::InitializeMotionTables(physics_obj);
/// node = new MotionNode { context_id=0, motion=0x41000003, jump_error_code=0 };
/// pending_motions.append(node); // tail-splice, NO clear
/// initted = 1;
/// LeaveGround(this);
/// </code>
/// </para>
///
/// <para>
/// Retail's real construction paths (<c>MovementManager::Create</c> +
/// the lazy-create guard at every <c>MovementManager</c> entry point)
/// call this exactly once (or twice on the lazy-create double-call —
/// §6g, genuine retail, not "fixed") before exposing the interpreter to
/// any caller. acdream's constructors default <see cref="Initted"/> to
/// <c>true</c> directly (see that property's doc comment) rather than
/// routing every construction through this method, so this port is the
/// verbatim, fully-testable "reset to default/idle state" operation —
/// callers that want retail's FULL reset semantics (state defaults +
/// sentinel + LeaveGround tail), not just the <c>Initted</c> flag, call
/// this explicitly.
/// </para>
/// </summary>
public void EnterDefaultState()
{
RawState = new RawMotionState();
InterpretedState = InterpretedMotionState.Default();
InitializeMotionTables?.Invoke();
AddToQueue(contextId: 0, MotionCommand.Ready, jumpErrorCode: 0);
Initted = true;
LeaveGround();
}
// ── FUN_00528b70 / FUN_00528bb0 — set_hold_run / SetHoldKey ────────────────
/// <summary>
/// <c>CMotionInterp::set_hold_run</c> (0x00528b70, decomp §5c @306053,
/// FULL BODY, closes J13's set_hold_run leg). XOR toggle guard: only
/// acts when <paramref name="holdingRun"/> actually FLIPS the current
/// effective hold-run state (skip redundant re-application).
///
/// <para>
/// Verbatim (raw 306053-306070):
/// <code>
/// eax = (arg2 == 0); // "run key is up"
/// edx = (raw_state.current_holdkey != Run);
/// if (eax != edx) { // XOR: state actually changes
/// raw_state.current_holdkey = (arg2 != 0) + 1; // false->1(None), true->2(Run)
/// apply_current_movement(this, arg3, 0);
/// }
/// </code>
/// </para>
/// </summary>
/// <param name="holdingRun">Retail <c>arg2</c> — nonzero = the run key
/// is currently held down.</param>
/// <param name="interrupt">Retail <c>arg3</c> — passed through as
/// <c>apply_current_movement</c>'s <c>cancelMoveTo</c> arg.</param>
public void set_hold_run(bool holdingRun, bool interrupt)
{
bool runKeyUp = !holdingRun;
bool notCurrentlyRun = RawState.CurrentHoldKey != HoldKey.Run;
if (runKeyUp != notCurrentlyRun)
{
RawState.CurrentHoldKey = holdingRun ? HoldKey.Run : HoldKey.None;
apply_current_movement(cancelMoveTo: interrupt, allowJump: false);
}
}
/// <summary>
/// <c>CMotionInterp::SetHoldKey</c> (0x00528bb0, decomp §5d @306072,
/// FULL BODY, closes J13's SetHoldKey leg). No-op if
/// <paramref name="key"/> already equals <see cref="RawMotionState.CurrentHoldKey"/>.
/// Setting <see cref="HoldKey.None"/> only takes effect (and
/// re-applies movement) if the CURRENT hold key is
/// <see cref="HoldKey.Run"/> — requesting None while already something
/// else (e.g. Invalid) is silently ignored. Setting
/// <see cref="HoldKey.Run"/> takes effect whenever the current key
/// isn't already Run.
///
/// <para>
/// Verbatim (raw 306072-306095):
/// <code>
/// current = raw_state.current_holdkey;
/// if (arg2 != current) {
/// if (arg2 == HoldKey_None) {
/// if (current == HoldKey_Run) {
/// raw_state.current_holdkey = HoldKey_None;
/// apply_current_movement(this, arg3, 0);
/// }
/// } else if (arg2 == HoldKey_Run &amp;&amp; current != HoldKey_Run) {
/// raw_state.current_holdkey = HoldKey_Run;
/// apply_current_movement(this, arg3, 0);
/// }
/// }
/// </code>
/// </para>
///
/// <para>
/// This is the function <c>DoMotion</c> (W5) calls when bit
/// <c>0x800</c> (SetHoldKey) of the incoming <c>MovementParameters</c>
/// requests a hold-key change, and what <see cref="adjust_motion"/>
/// reads back via <see cref="RawMotionState.CurrentHoldKey"/> (mirrored
/// onto <see cref="CurrentHoldKey"/> — see the field doc) to decide
/// whether <see cref="apply_run_to_command"/> fires.
/// </para>
/// </summary>
/// <param name="key">Retail <c>arg2</c>.</param>
/// <param name="cancelMoveTo">Retail <c>arg3</c> — passed through as
/// <c>apply_current_movement</c>'s <c>cancelMoveTo</c> arg.</param>
public void SetHoldKey(HoldKey key, bool cancelMoveTo)
{
HoldKey current = RawState.CurrentHoldKey;
if (key == current)
return;
if (key == HoldKey.None)
{
if (current == HoldKey.Run)
{
RawState.CurrentHoldKey = HoldKey.None;
apply_current_movement(cancelMoveTo, allowJump: false);
}
}
else if (key == HoldKey.Run && current != HoldKey.Run)
{
RawState.CurrentHoldKey = HoldKey.Run;
apply_current_movement(cancelMoveTo, allowJump: false);
}
}
// ── CMotionInterp::get_max_speed (0x00527cb0) ─────────────────────────────
@ -1990,6 +2525,25 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// every axis overwritten, defaults included), re-apply the whole
/// movement through the sink, then replay fresh actions under the
/// 15-bit stamp gate.
///
/// <para>
/// <b>R3-W4 (Adjacent finding, W0-pins.md):</b> raw 305946-305949
/// computes <c>eax_2 = motion_allows_jump(this-&gt;interpreted_state.forward_command)</c>
/// — the OLD forward command, BEFORE <c>copy_movement_from</c>
/// overwrites <see cref="InterpretedState"/> two lines later — then
/// calls <c>apply_current_movement(this, 1, allowJump)</c> where
/// <c>allowJump = (eax_2 == 0)</c> (the raw's own
/// <c>-((esi_1 - esi_1))</c> is decompiler noise for that same
/// boolean). Ported here as <see cref="MotionAllowsJump"/> on the
/// PRE-overwrite <see cref="InterpretedState"/>.ForwardCommand,
/// snapshotted before the flat-copy below. Passed through to
/// <see cref="ApplyInterpretedMovement"/> per its TODO(W5) — this
/// funnel calls the interpreted body directly (not the
/// <see cref="apply_current_movement"/> dual-dispatch gate), matching
/// the existing S2 architecture: inbound funnel state is always
/// server-authoritative, so it always wants the interpreted path
/// regardless of IsThePlayer/autonomous.
/// </para>
/// </summary>
public int MoveToInterpretedState(in InboundInterpretedState ims, IInterpretedMotionSink? sink = null)
{
@ -1997,6 +2551,10 @@ public sealed class MotionInterpreter : IMotionDoneSink
RawState.CurrentStyle = ims.CurrentStyle;
// motion_allows_jump on the OLD forward_command, BEFORE the
// flat-copy below overwrites it (Adjacent finding).
bool allowJump = MotionAllowsJump(InterpretedState.ForwardCommand) == WeenieError.None;
// copy_movement_from — flat overwrite, no per-field presence checks.
InterpretedState.ForwardCommand = ims.ForwardCommand;
InterpretedState.ForwardSpeed = ims.ForwardSpeed;
@ -2005,7 +2563,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
InterpretedState.TurnCommand = ims.TurnCommand;
InterpretedState.TurnSpeed = ims.TurnSpeed;
ApplyInterpretedMovement(ims.CurrentStyle, sink);
ApplyInterpretedMovement(ims.CurrentStyle, sink, cancelMoveTo: true, allowJump: allowJump);
if (ims.Actions is { } actions)
{
@ -2053,11 +2611,39 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// node's <c>jump_error_code</c> — since the stop succeeded, that value
/// is <c>0</c>.
/// </para>
///
/// <para>
/// <b>R3-W4 (closes J11's param-plumbing leg):</b> <paramref name="cancelMoveTo"/>/
/// <paramref name="allowJump"/> are retail's <c>arg2</c>/<c>arg3</c> —
/// threaded through NOW per the plan (every real caller already has an
/// opinion: <see cref="apply_current_movement"/>'s dual-dispatch tail
/// passes its own args through unchanged; <see cref="MoveToInterpretedState"/>
/// passes <c>(true, motion_allows_jump(OLD forward_command) == 0)</c> per
/// the Adjacent finding). Their ONLY retail consumer in this function is
/// the garbled tail expression at raw 305778
/// (<c>(((arg3&amp;1)&lt;&lt;2)|(arg2&amp;1))&lt;&lt;0xf</c> feeding an
/// uninitialized-local byte test before conditionally calling
/// <c>InterpretedMotionState::RemoveMotion(0x6500000d)</c> a SECOND time)
/// — a BN x87/uninit-local artifact class, not a readable retail
/// algorithm. TODO(W5): resolve that tail against a clean decompile (or
/// cdb capture) once <c>MovementParameters</c> flows through this
/// funnel end to end; until then the params are accepted and preserved
/// for signature parity but do not change this function's control flow
/// (matches the funnel's existing "no MovementParameters yet" posture,
/// e.g. <see cref="DispatchInterpretedMotion"/>'s own TODO(W5)).
/// </para>
/// </summary>
public void ApplyInterpretedMovement(uint currentStyle, IInterpretedMotionSink? sink)
public void ApplyInterpretedMovement(
uint currentStyle, IInterpretedMotionSink? sink,
bool cancelMoveTo = false, bool allowJump = false)
{
if (PhysicsObj is null) return;
// cancelMoveTo/allowJump: accepted for signature parity (see the
// TODO(W5) above) — not yet consumed by this function's body.
_ = cancelMoveTo;
_ = allowJump;
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
MyRunRate = InterpretedState.ForwardSpeed;

View file

@ -245,6 +245,24 @@ public sealed class PhysicsBody
/// </summary>
public bool IsFullyConstrained { get; set; }
/// <summary>
/// R3-W4 — retail <c>CPhysicsObj::last_move_was_autonomous</c>, read by
/// <c>CPhysicsObj::movement_is_autonomous</c> (0x0050eb30, decomp §7a
/// @276443: <c>return this-&gt;last_move_was_autonomous;</c>). Gates the
/// A3 dual-dispatch predicate in <c>MotionInterpreter.apply_current_movement</c>/
/// <c>ReportExhaustion</c>/<c>SetWeenieObject</c>/<c>SetPhysicsObject</c>:
/// true means the last motion on this body was locally-simulated
/// (player input / local prediction), false means it was a
/// server-driven dead-reckoning update. Set true at the local-player
/// input chokepoint (App layer — <c>PlayerMovementController</c>);
/// left false (the safe default — routes to
/// <c>apply_interpreted_movement</c>) for DR-applied remote updates.
/// <see cref="MotionInterpreter.LeaveGround"/> also sets this true
/// itself: retail's <c>set_local_velocity(&amp;var_c, 1)</c> call passes
/// the autonomous flag literal <c>1</c> (raw @305763-305765).
/// </summary>
public bool LastMoveWasAutonomous { get; set; }
// ── convenience helpers ────────────────────────────────────────────────
public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity);
@ -324,10 +342,23 @@ public sealed class PhysicsBody
/// worldY = col0.y*localX + col1.y*localY + col2.y*localZ
/// worldZ = col0.z*localX + col1.z*localY + col2.z*localZ
/// We replicate this as a Quaternion rotation, which is equivalent.
///
/// <para>
/// R3-W4: retail's <c>set_local_velocity</c> takes a second
/// <c>autonomous</c> arg (<c>CPhysicsObj::set_local_velocity</c>,
/// stores it to <see cref="LastMoveWasAutonomous"/> — read by
/// <c>CPhysicsObj::movement_is_autonomous</c>, the A3 dual-dispatch
/// predicate). Defaults to <c>false</c> to preserve every pre-W4 call
/// site's behavior (server/interpreted-driven callers never asserted
/// autonomy); <see cref="MotionInterpreter.LeaveGround"/> is the one
/// caller that passes <c>true</c> (raw @305763-305765,
/// <c>set_local_velocity(&amp;var_c, 1)</c>).
/// </para>
/// </summary>
public void set_local_velocity(Vector3 localVelocity)
public void set_local_velocity(Vector3 localVelocity, bool autonomous = false)
{
var worldVelocity = Vector3.Transform(localVelocity, Orientation);
LastMoveWasAutonomous = autonomous;
set_velocity(worldVelocity);
}