feat(physics): R5-V5 — MovementManager facade owns each entity's interp+moveto pair
Structural capstone of the R5 movement-manager arc; zero behavior change. Retail MovementManager (acclient.h /* 3463 */, 16 bytes / four pointers) gives every CPhysicsObj ONE owner for its motion_interpreter + moveto_manager. acdream carried them as loose per-entity objects wired by hand at three sites. This slice: - New src/AcDream.Core/Physics/Motion/MovementManager.cs — owns MotionInterpreter + lazy MoveToManager (MakeMoveToManager 0x00524000 via a MoveToFactory closure, the acdream stand-in for the physics_obj/ weenie_obj backpointers) + the relays with retail call shapes: PerformMovement 0x005240d0 (types 1-5 -> minterp, 6-9 -> MakeMoveToManager + moveto, (type-1)>8 -> 0x47), UseTime 0x005242f0 (moveto only), HitGround 0x00524300 (minterp FIRST then moveto), HandleExitWorld 0x00524350 (minterp only), CancelMoveTo 0x005241b0, HandleUpdateTarget 0x00524790, IsMovingTo 0x00524260. - RemoteMotion.Movement + PlayerMovementController.Movement hold the ONE facade; Motion/MoveTo become child views so the comment-dense call sites read unchanged. The three wiring sites (EnsureRemoteMotionBindings, EnterPlayerModeNow, the chase harness — same commit per the mirror rule) construct through MoveToFactory + MakeMoveToManager(), preserving the pre-facade eager timing (side-effect-free ctor = unobservable either way). - Relay call sites repointed: both remote landing HitGround pairs + the player landing pair, despawn HandleExitWorld, TickRemoteMoveTo + the player Update UseTime, RouteServerMoveTo (takes the facade; routes via the retail PerformMovement dispatch), InstallSpeculativeTurnToTarget, host HandleUpdateTarget/InterruptCurrentMovement closures (retail CPhysicsObj::HandleUpdateTarget @0x00512bf0 fan head + the TS-36 interrupt chain, now the literal facade relays). - NOT absorbed per the slice spec: unpack_movement stays App (RouteServerMoveTo + UM heads; Core.Net types stay out of Core.Physics); TS-42 per-tick order untouched (R6); #170/#171 gate-passed machinery untouched. PerformMovement's set_active(1) head not re-asserted (spawn asserts Active; status quo — no new register row). - Register: TS-41/TS-42 source wording freshened to the facade shape; AD-36 retire note corrected (facade half closed; residue = entities that never get a RemoteMotion). No new rows. - Conformance: 15 new MovementManagerTests pin the dispatch table, lazy create, relay targets/order, null tolerance. Suite 4052 green; the 183-case/funnel/moveto/chase/sticky suites UNMODIFIED (harness construction mirrors production, test bodies untouched). Decomp: docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6feaab91e3
commit
dccd700991
6 changed files with 833 additions and 200 deletions
171
src/AcDream.Core/Physics/Motion/MovementManager.cs
Normal file
171
src/AcDream.Core/Physics/Motion/MovementManager.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5-V5 — retail <c>MovementManager</c> (acclient.h <c>/* 3463 */</c>, 0x10
|
||||
/// bytes / four pointers): the ONE per-entity owner of the movement pipeline's
|
||||
/// two managers. Decomp extract:
|
||||
/// <c>docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md</c>.
|
||||
///
|
||||
/// <code>
|
||||
/// struct MovementManager {
|
||||
/// CMotionInterp *motion_interpreter; // +0x0 → Minterp
|
||||
/// MoveToManager *moveto_manager; // +0x4 → MoveTo (lazy)
|
||||
/// CPhysicsObj *physics_obj; // +0x8 ┐ carried by the children +
|
||||
/// CWeenieObject *weenie_obj; // +0xc ┘ the MoveToFactory closure
|
||||
/// };
|
||||
/// </code>
|
||||
///
|
||||
/// <para><b>Construction mapping.</b> Retail lazily creates BOTH children
|
||||
/// (<c>CMotionInterp::Create</c> + <c>enter_default_state</c> at every entry
|
||||
/// point; <c>MoveToManager::Create</c> via <see cref="MakeMoveToManager"/>).
|
||||
/// acdream constructs the interp eagerly at entity construction
|
||||
/// (<c>RemoteMotion</c> / <c>PlayerMovementController</c> ctors — the lazy
|
||||
/// window is never observable; register TS-38 already covers the
|
||||
/// <c>Initted</c> side of this) and hands it to this ctor. The moveto side
|
||||
/// keeps retail's lazy <see cref="MakeMoveToManager"/> mechanism: retail
|
||||
/// constructs from the <c>physics_obj</c>/<c>weenie_obj</c> backpointers;
|
||||
/// acdream's <see cref="MoveToFactory"/> closure is the stand-in for those
|
||||
/// two fields, carrying the full seam wiring (set once at the bind site —
|
||||
/// <c>EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c> / the chase
|
||||
/// harness — which then calls <see cref="MakeMoveToManager"/> immediately,
|
||||
/// preserving the pre-facade eager timing; the ctor is side-effect-free so
|
||||
/// the timing is unobservable either way).</para>
|
||||
///
|
||||
/// <para><b>Deliberately NOT absorbed here</b> (the R5-V5 slice keeps these
|
||||
/// at their current owners): <c>unpack_movement</c> 0x00524440 ≡ the
|
||||
/// GameWindow UM path + <c>RouteServerMoveTo</c> (Core.Net wire types stay
|
||||
/// out of Core.Physics); <c>move_to_interpreted_state</c> 0x00524170 ≡ the
|
||||
/// funnel's <c>MotionInterpreter.MoveToInterpretedState</c> call sites;
|
||||
/// <c>MotionDone</c> 0x005242d0 ≡ the sequencer's <c>MotionDoneTarget</c>
|
||||
/// seam (register AD-36); <c>LeaveGround</c> 0x00524320's moveto half is a
|
||||
/// COMDAT no-op (see <c>PlayerMovementController</c>'s landing comment) so
|
||||
/// call sites keep invoking <c>Minterp.LeaveGround()</c> directly;
|
||||
/// <c>EnterDefaultState</c>/<c>HandleEnterWorld</c>/<c>ReportExhaustion</c>/
|
||||
/// <c>SetWeenieObject</c>/<c>Destroy</c> have no acdream caller yet —
|
||||
/// <c>get_minterp</c> 0x005242a0 ≡ the <see cref="Minterp"/> property.</para>
|
||||
///
|
||||
/// <para><b>PerformMovement's <c>set_active(1)</c> head</b>
|
||||
/// (0x005240d9) is not re-asserted here: acdream bodies assert the Active
|
||||
/// transient bit at spawn (<c>RemoteMotion</c> ctor) and the pre-facade
|
||||
/// route never re-asserted it — status quo preserved (zero-behavior-change
|
||||
/// slice), not a new deviation.</para>
|
||||
/// </summary>
|
||||
public sealed class MovementManager
|
||||
{
|
||||
/// <summary>Retail <c>motion_interpreter</c> (+0x0). Always present in
|
||||
/// acdream (eager construction — see the class doc); direct child access
|
||||
/// for interp-specific ops mirrors retail's <c>get_minterp</c>
|
||||
/// (0x005242a0) callers.</summary>
|
||||
public MotionInterpreter Minterp { get; }
|
||||
|
||||
/// <summary>Retail <c>moveto_manager</c> (+0x4) — null until
|
||||
/// <see cref="MakeMoveToManager"/> (or a type-6..9
|
||||
/// <see cref="PerformMovement"/>) creates it.</summary>
|
||||
public MoveToManager? MoveTo { get; private set; }
|
||||
|
||||
/// <summary>The acdream stand-in for retail's <c>physics_obj</c>/
|
||||
/// <c>weenie_obj</c> backpointers (+0x8/+0xc): the creation recipe
|
||||
/// <see cref="MakeMoveToManager"/> invokes. Set exactly once at the bind
|
||||
/// site; the closure carries the MoveToManager's seam wiring (and the
|
||||
/// StickTo/Unstick/MoveToComplete binds) that retail reads off
|
||||
/// <c>CPhysicsObj</c>.</summary>
|
||||
public Func<MoveToManager>? MoveToFactory { get; set; }
|
||||
|
||||
public MovementManager(MotionInterpreter minterp)
|
||||
{
|
||||
Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>MovementManager::MakeMoveToManager</c> (0x00524000):
|
||||
/// lazy-construct <see cref="MoveTo"/> if null; no-op if already present.
|
||||
/// No-op (instead of retail's unconditional create) when no factory has
|
||||
/// been bound yet — acdream's seams arrive from the bind site, and every
|
||||
/// consumer path runs after binding.
|
||||
/// </summary>
|
||||
public void MakeMoveToManager()
|
||||
{
|
||||
if (MoveTo is null && MoveToFactory is not null)
|
||||
MoveTo = MoveToFactory();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>MovementManager::PerformMovement</c> (0x005240d0): the
|
||||
/// type-1..9 two-way dispatch. <c>(type - 1) > 8</c> (type 0
|
||||
/// underflows unsigned) → 0x47; types 1-5 → <c>CMotionInterp::
|
||||
/// PerformMovement</c> (return propagated); types 6-9 →
|
||||
/// <see cref="MakeMoveToManager"/> + <c>MoveToManager::PerformMovement</c>
|
||||
/// whose return is NOT propagated (@0052414f <c>return 0</c>) — the
|
||||
/// facade reports <see cref="WeenieError.None"/> for that path.
|
||||
/// </summary>
|
||||
public WeenieError PerformMovement(MovementStruct mvs)
|
||||
{
|
||||
switch (mvs.Type)
|
||||
{
|
||||
case MovementType.RawCommand:
|
||||
case MovementType.InterpretedCommand:
|
||||
case MovementType.StopRawCommand:
|
||||
case MovementType.StopInterpretedCommand:
|
||||
case MovementType.StopCompletely:
|
||||
return Minterp.PerformMovement(mvs);
|
||||
|
||||
case MovementType.MoveToObject:
|
||||
case MovementType.MoveToPosition:
|
||||
case MovementType.TurnToObject:
|
||||
case MovementType.TurnToHeading:
|
||||
MakeMoveToManager();
|
||||
if (MoveTo is null)
|
||||
// acdream-only guard: a type-6..9 event before the bind
|
||||
// site set MoveToFactory (unreachable in production —
|
||||
// EnsureRemoteMotionBindings / EnterPlayerModeNow bind
|
||||
// before any route can fire). Retail would Create here.
|
||||
return WeenieError.GeneralMovementFailure;
|
||||
MoveTo.PerformMovement(mvs);
|
||||
return WeenieError.None;
|
||||
|
||||
default:
|
||||
return WeenieError.GeneralMovementFailure; // 0x47
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>MovementManager::UseTime</c> (0x005242f0): relay
|
||||
/// to <see cref="MoveTo"/> ONLY (does not touch the interp, does not
|
||||
/// lazy-create); no-op while null.</summary>
|
||||
public void UseTime() => MoveTo?.UseTime();
|
||||
|
||||
/// <summary>Retail <c>MovementManager::HitGround</c> (0x00524300): fan
|
||||
/// to BOTH children if present — <c>CMotionInterp::HitGround</c> FIRST
|
||||
/// (the falling-pose exit re-apply), then <c>MoveToManager::HitGround</c>
|
||||
/// (re-arms a moveto suspended by the airborne UseTime contact
|
||||
/// gate).</summary>
|
||||
public void HitGround()
|
||||
{
|
||||
Minterp.HitGround();
|
||||
MoveTo?.HitGround();
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>MovementManager::HandleExitWorld</c> (0x00524350):
|
||||
/// interp ONLY (drains <c>pending_motions</c>); does not touch
|
||||
/// <see cref="MoveTo"/>.</summary>
|
||||
public void HandleExitWorld() => Minterp.HandleExitWorld();
|
||||
|
||||
/// <summary>Retail <c>MovementManager::CancelMoveTo</c> (0x005241b0):
|
||||
/// relay to <see cref="MoveTo"/>; no-op while null. The retail
|
||||
/// <c>interrupt_current_movement → MovementManager::CancelMoveTo(0x36)</c>
|
||||
/// chain lands here.</summary>
|
||||
public void CancelMoveTo(WeenieError error) => MoveTo?.CancelMoveTo(error);
|
||||
|
||||
/// <summary>Retail <c>MovementManager::HandleUpdateTarget</c>
|
||||
/// (0x00524790): relay to <see cref="MoveTo"/>; no-op while null. The
|
||||
/// <c>CPhysicsObj::HandleUpdateTarget</c> fan (0x00512bc0) calls this
|
||||
/// leg first, then the PositionManager's (host order —
|
||||
/// <c>EntityPhysicsHost.HandleUpdateTarget</c>).</summary>
|
||||
public void HandleUpdateTarget(TargetInfo info) => MoveTo?.HandleUpdateTarget(info);
|
||||
|
||||
/// <summary>Retail <c>MovementManager::IsMovingTo</c> (0x00524260):
|
||||
/// true iff <see cref="MoveTo"/> exists AND reports an armed
|
||||
/// move.</summary>
|
||||
public bool IsMovingTo() => MoveTo?.IsMovingTo() ?? false;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue