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

@ -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);
}