fix(motion): restore retail object manager order

Process animation completion at the retail process_hooks boundary, then run targeting, movement, PartArray completion, and PositionManager in the named UpdateObjectInternal order for local, remote, hidden, and position-less animated objects. Retire TS-42 with deterministic conformance coverage.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-19 21:40:14 +02:00
parent 0f996db747
commit c5ab99081c
17 changed files with 493 additions and 98 deletions

View file

@ -1126,7 +1126,7 @@ public sealed class PlayerMovementController
/// live. A composed offset still commits through CTransition so cell
/// membership and contact state cannot become stale behind the hidden mesh.
/// </summary>
public MovementResult TickHidden(float dt)
public MovementResult TickHidden(float dt, Action? handleTargeting = null)
{
_simTimeSeconds += dt;
_physicsAccum = 0f;
@ -1180,8 +1180,14 @@ public sealed class PlayerMovementController
UpdateCellId(resolved.CellId, "hidden-position-manager");
}
Movement.UseTime();
PositionManager?.UseTime();
RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting,
movementUseTime: Movement.UseTime,
partArrayHandleMovement: _motion.CheckForCompletedMotions,
positionUseTime: PositionManager is { } positionManager
? positionManager.UseTime
: null);
_prevPhysicsPos = _body.Position;
_currPhysicsPos = _body.Position;
_wasAirborneLastFrame = !_body.OnWalkable;
@ -1201,42 +1207,13 @@ public sealed class PlayerMovementController
CurrentStyle: _motion.RawState.CurrentStyle);
}
public MovementResult Update(float dt, MovementInput input)
public MovementResult Update(
float dt,
MovementInput input,
Action? handleTargeting = null)
{
_simTimeSeconds += dt;
// R4-V5: tick the verbatim MoveToManager at the slot the deleted
// DriveServerAutoWalk occupied — after inbound wire routing, before
// the input-driven motion sections. UseTime runs the retail per-tick
// moveto drivers (HandleMoveToPosition aux-turn steering + arrival +
// fail-distance / HandleTurnToHeading); the motions it dispatches
// land in InterpretedState through _DoMotion → DoInterpretedMotion,
// and sections 1/2 below turn that state into Yaw rotation + body
// velocity — the same per-frame apply user input gets. No
// synthesized input exists, so the outbound-packet pipeline sees no
// user motion and never builds a MoveToState mid-moveto (the #75
// invariant, now by construction). Provisional tick placement until
// R6 ports retail's UpdateObjectInternal ordering (r4-port-plan.md
// §3 placement decision). R5-V5: the facade relay
// (MovementManager::UseTime 0x005242f0 — moveto side only).
Movement.UseTime();
// R4-V5 wedge fix (2026-07-03 live door bug), retail's per-tick
// completion slot: CPartArray::HandleMovement — a tailcall chain to
// MotionTableManager::CheckForCompletedMotions (0x00517d60 →
// 0x0051bfd0) — runs every tick right AFTER MovementManager::UseTime
// in UpdateObjectInternal (@005159a4). It flushes pending_animations
// entries whose animations already ran out so their AnimationDone →
// MotionDone pops land each tick even when no op fired that frame.
// (apply_current_movement is NOT part of the per-tick order — its
// retail callers are all event-driven: hold-key toggles, HitGround/
// LeaveGround, ReportExhaustion.) Full tick ordering remains R6
// scope; the companion fix is StopCompletely's previously-missing
// StopCompletely_Internal animation dispatch (see
// MotionInterpreter.StopCompletely), whose completable entry this
// slot drains.
_motion.CheckForCompletedMotions?.Invoke();
// Portal-space guard: while teleporting, no input is processed and
// no physics is resolved. Return a zero-movement result so the caller
// can detect the frozen state (MotionStateChanged = false, no commands).
@ -1710,13 +1687,6 @@ public sealed class PlayerMovementController
{
_prevPhysicsPos = oldTickEndPos;
_currPhysicsPos = _body.Position;
// R5-V3 (#171): retail UpdateObjectInternal TAIL — PositionManager::
// UseTime (@0x005159b3) runs AFTER the quantum's integration
// (whose head hosts adjust_offset) and only on frames where a
// physics quantum executed (retail's update_object MinQuantum
// gate skips the whole UpdateObjectInternal).
PositionManager?.UseTime();
}
// SetPositionInternal contact determination (pc:283468-510). acdream's resolver
@ -1778,6 +1748,22 @@ public sealed class PlayerMovementController
_wasAirborneLastFrame = !_body.OnWalkable;
UpdateCellId(resolveResult.CellId, "resolver");
// Named retail CPhysicsObj::UpdateObjectInternal (0x005156B0):
// process_hooks has already completed inside UpdatePositionInternal,
// then the transition commits, and only then does the ordered manager
// tail run. DetectionManager is not ported yet, so its slot is absent.
// PositionManager's clock stays tied to a consumed physics quantum;
// the remaining per-render versus per-quantum unification belongs to
// the R6 object scheduler rather than this ordering cutover.
RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting,
movementUseTime: Movement.UseTime,
partArrayHandleMovement: _motion.CheckForCompletedMotions,
positionUseTime: physicsTickRan && PositionManager is { } positionManager
? positionManager.UseTime
: null);
// ── 6. Determine outbound motion commands ─────────────────────────────
uint? outForwardCmd = null;
float? outForwardSpeed = null;

View file

@ -76,12 +76,11 @@ public sealed class RetailLocalPlayerFrameController
return;
controller.LocalEntityId = _resolveLocalEntityId();
_handleTargeting();
bool hidden = _isHidden();
MovementResult movement = hidden
? controller.TickHidden(deltaSeconds)
: controller.Update(deltaSeconds, _captureInput());
? controller.TickHidden(deltaSeconds, _handleTargeting)
: controller.Update(deltaSeconds, _captureInput(), _handleTargeting);
_project(controller, movement, hidden);
_sendPreNetwork(controller, movement, hidden);

View file

@ -84,7 +84,11 @@ internal sealed class RemotePhysicsUpdater
|| record.WorldEntity is not { } entity)
continue;
TickHidden(remote, entity, dt);
System.Action? handlePartArray = record.AnimationRuntime is AnimatedEntity
{ Sequencer: { } sequencer }
? sequencer.Manager.UseTime
: null;
TickHidden(remote, entity, dt, handlePartArray);
// PositionManager callbacks can rebucket, delete, or replace this
// GUID. Publish only while the captured record/runtime pair still
// owns a loaded spatial projection.
@ -116,16 +120,6 @@ internal sealed class RemotePhysicsUpdater
int liveCenterY)
{
uint serverGuid = ae.Entity.ServerGuid;
// R5-V2: retail UpdateObjectInternal ticks TargetManager::
// HandleTargetting UNCONDITIONALLY per entity, BEFORE the
// movement managers' UseTime. This is where this entity, as a
// watched target, pushes its position to its voyeurs (any entity
// moving-to it), and where its own target-info staleness times
// out. Runs for every remote regardless of the grounded/airborne
// branch below (which drive MoveToManager.UseTime via
// TickRemoteMoveTo). No-op for entities with no target + no voyeurs.
rm.Host?.HandleTargetting();
// #184 Slice 2b — the UNIFIED per-remote tick. The former Path A
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's
@ -221,7 +215,6 @@ internal sealed class RemotePhysicsUpdater
// the sink (the LEGS). Position comes from the catch-up; legs from
// this per-node dispatch + the funnel. The #170-deleted per-frame
// apply_current_movement is NOT reintroduced.
TickRemoteMoveTo(rm);
}
else
{
@ -609,7 +602,21 @@ internal sealed class RemotePhysicsUpdater
// sticky 1 s lease watchdog (StickyManager::UseTime
// 0x00555610 — a stick not re-issued by a fresh server arm
// within 1 s tears itself down). No-op while nothing is stuck.
rm.Host?.PositionManager.UseTime();
System.Action? handleTargeting = rm.Host is { } targetHost
? targetHost.HandleTargetting
: null;
System.Action? partArrayHandleMovement = ae.Sequencer is { } sequencer
? sequencer.Manager.UseTime
: null;
System.Action? positionUseTime = rm.Host is { } positionHost
? positionHost.PositionManager.UseTime
: null;
AcDream.Core.Physics.RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting,
movementUseTime: rm.Movement.UseTime,
partArrayHandleMovement,
positionUseTime);
}
/// <summary>
@ -624,7 +631,8 @@ internal sealed class RemotePhysicsUpdater
public void TickHidden(
RemoteMotion rm,
AcDream.Core.World.WorldEntity entity,
float dt)
float dt,
System.Action? partArrayHandleMovement = null)
{
ArgumentNullException.ThrowIfNull(rm);
ArgumentNullException.ThrowIfNull(entity);
@ -700,20 +708,18 @@ internal sealed class RemotePhysicsUpdater
entity.ParentCellId = rm.CellId;
entity.Rotation = rm.Body.Orientation;
rm.Host?.HandleTargetting();
TickRemoteMoveTo(rm);
rm.Host?.PositionManager.UseTime();
}
/// <summary>
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
/// side's steering, arrival, fail-distance; R5-V5 facade relay). Moved from
/// GameWindow (#184 Slice 2a); the DR tick is its only caller.
/// </summary>
private static void TickRemoteMoveTo(RemoteMotion rm)
{
rm.Movement.UseTime();
System.Action? handleTargeting = rm.Host is { } targetHost
? targetHost.HandleTargetting
: null;
System.Action? positionUseTime = rm.Host is { } positionHost
? positionHost.PositionManager.UseTime
: null;
AcDream.Core.Physics.RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting,
movementUseTime: rm.Movement.UseTime,
partArrayHandleMovement,
positionUseTime);
}
/// <summary>

View file

@ -11113,6 +11113,7 @@ public sealed class GameWindow : IDisposable
var ae = kv.Value;
bool sequenceAdvancedBeforeAnimationPass =
ae.SequenceAdvancedBeforeAnimationPass;
bool managerTailHandledForObject = ae.Entity.ServerGuid == _playerServerGuid;
IReadOnlyList<AcDream.Core.Physics.PartTransform>? preparedSequenceFrames =
ae.PreparedSequenceFrames;
ae.SequenceAdvancedBeforeAnimationPass = false;
@ -11208,6 +11209,7 @@ public sealed class GameWindow : IDisposable
remoteRootMotionFrame.Origin,
_liveCenterX,
_liveCenterY);
managerTailHandledForObject = true;
}
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
@ -11264,7 +11266,9 @@ public sealed class GameWindow : IDisposable
// This matches CPhysicsObj::UpdateObjectInternal followed by
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
// reads this frame's pose, never the previous frame's pose.
// AnimationDone/UseTime remain paired with the deferred drain.
// AnimationDone is processed semantically by Capture at
// retail's process_hooks slot. Pose-dependent delivery is
// deferred; the later manager-tail block owns UseTime.
if (!hidden)
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
}
@ -11287,6 +11291,41 @@ public sealed class GameWindow : IDisposable
}
}
// R6: objects which did not enter the local or moving-remote
// physics owner still need their CPartArray manager tail. Doors
// and levers commonly receive UpdateMotion without ever receiving
// UpdatePosition; classifying the tail by LastServerPosTime made
// their zero-tick completions depend on the old global hook drain.
// Hidden remotes were already handled by TickHiddenEntities above.
bool hiddenRemoteManagerTailHandled = hidden
&& _remoteDeadReckon.TryGetValue(serverGuid, out _);
AcDream.Core.Physics.AnimationSequencer? tailSequencer = ae.Sequencer;
if (!managerTailHandledForObject
&& !hiddenRemoteManagerTailHandled
&& tailSequencer is not null)
{
if (_remoteDeadReckon.TryGetValue(serverGuid, out var tailRemote)
&& _projectileController?.HandlesMovement(serverGuid) != true)
{
System.Action? handleTargeting = tailRemote.Host is { } targetHost
? targetHost.HandleTargetting
: null;
System.Action? positionUseTime = tailRemote.Host is { } positionHost
? positionHost.PositionManager.UseTime
: null;
AcDream.Core.Physics.RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting,
movementUseTime: tailRemote.Movement.UseTime,
partArrayHandleMovement: tailSequencer.Manager.UseTime,
positionUseTime);
}
else
{
tailSequencer.Manager.UseTime();
}
}
int partCount = ae.PartTemplate.Count;
// D5 (Commit A 2026-05-03): PARTSDIAG — proves whether

View file

@ -45,9 +45,22 @@ public sealed class AnimationHookFrameQueue
{
ArgumentNullException.ThrowIfNull(sequencer);
ArgumentNullException.ThrowIfNull(hooks);
// Retail CPhysicsObj::process_hooks (0x00511550) executes AnimDone
// inside UpdatePositionInternal, before the transition and every
// UpdateObjectInternal manager. Complete the semantic motion state at
// capture time so that this object's later Target/Movement/PartArray
// tail sees the new queue in the same quantum. The hook remains in the
// deferred entry so presentation sinks still observe the complete
// authored hook stream after the final part poses are published.
for (int i = 0; i < hooks.Count; i++)
{
if (hooks[i] is AnimationDoneHook)
sequencer.Manager.AnimationDone(success: true);
}
_entries.Add(new Entry(
ownerLocalId,
sequencer,
hooks));
}
@ -69,13 +82,7 @@ public sealed class AnimationHookFrameQueue
continue;
if (hasRootPose)
_router.OnHook(entry.OwnerLocalId, worldPosition, hook);
if (hook is AnimationDoneHook)
entry.Sequencer.Manager.AnimationDone(success: true);
}
// Retail sweeps zero-tick motion entries even on frames without a
// hook. It remains paired with every sequencer advanced this frame.
entry.Sequencer.Manager.UseTime();
}
_entries.Clear();
}
@ -84,6 +91,5 @@ public sealed class AnimationHookFrameQueue
private readonly record struct Entry(
uint OwnerLocalId,
AnimationSequencer Sequencer,
IReadOnlyList<AnimationHook> Hooks);
}

View file

@ -0,0 +1,28 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Executes the manager tail of retail
/// <c>CPhysicsObj::UpdateObjectInternal</c> (<c>0x005156B0</c>).
/// </summary>
/// <remarks>
/// The order is observable. In particular, animation completion hooks have
/// already run when this tail begins, so MoveTo may react to a completion in
/// the same object quantum. A null callback represents an absent retail
/// manager; it is not a separate execution path.
/// </remarks>
public static class RetailObjectManagerTail
{
public static void Run(
Action? checkDetection,
Action? handleTargeting,
Action? movementUseTime,
Action? partArrayHandleMovement,
Action? positionUseTime)
{
checkDetection?.Invoke();
handleTargeting?.Invoke();
movementUseTime?.Invoke();
partArrayHandleMovement?.Invoke();
positionUseTime?.Invoke();
}
}