diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 475fbaf9..fecdc1a7 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -206,6 +206,7 @@ src/
LiveEntityRuntime.cs -> canonical identity/state/body/projectile/spatial ownership
LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation
LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain
+ RetailLiveFrameCoordinator.cs -> object/physics-before-network frame barrier
Rendering/
GameWindow.cs -> still owns too much runtime wiring
TerrainModernRenderer.cs -> mandatory bindless+MDI terrain path
@@ -642,7 +643,19 @@ canonical local physics controller and chase-camera handoff.
The destination residency gate supplies retail's `EndTeleportAnimation` edge
once asynchronous streaming is ready. Player placement remains authoritative
and separate from presentation; arrival resets camera viewer state but does
-not reset the character animation sequence. See
+not reset the character animation sequence. `RetailLiveFrameCoordinator`
+matches `SmartBox::UseTime @ 0x00455410`: it advances local and remote object/
+projectile runtime, composes final animated/equipped poses, drains animation
+hooks, then advances particles before owner scripts and inbound network dispatch.
+`RetailLocalPlayerFrameController` owns the player's exactly-once tick and sends
+input-originated movement/jump output on the object side of that barrier. After
+inbound dispatch, the retail command-interpreter phase evaluates periodic
+AutonomousPosition from current authoritative state, then a non-advancing spatial
+reconciliation updates equipped children and live effect/light anchors. Consequently a
+teleport Hidden transition cannot freeze the last fraction of recall and replay
+it after UnHide, and ForcePosition/F751 cannot combine old one-shot input with a new cell.
+The draw pass consumes that completed update snapshot and never advances live
+animation independently. See
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
## Roadmap Model
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index e5d538da..99948acb 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -593,7 +593,7 @@ Research: R9 + R12 + R13.
- **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys.
- **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload.
- **Indoor portal-based cell tracking (follow-up to Indoor walking Phase 1 / issue #87).** Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's `CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses a cell portal boundary, `CellId` propagates through the `CEnvCell` portal connectivity graph. Prerequisite for wall collision from outside (#85) and the remaining #84 threshold symptom. PDB symbols and `acclient.h` `CCellStructure` refs are in place (see #87). **Unblocks G.3.**
-- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/view-plane transitions, forward-axis roll, direct portal→world viewport switch, projection warp, and destination viewer reset; corrective visual gate pending 2026-07-15). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
+- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/view-plane transitions, forward-axis roll, direct portal→world viewport switch, projection warp, destination viewer reset, object/physics-before-network frame barrier, pre-network input movement/jump output, post-inbound AutonomousPosition, and non-advancing spatial reconciliation; recall-tail corrective visual gate pending 2026-07-16). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end dungeon round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 6f74d445..3c71623d 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -312,6 +312,16 @@ a black alpha fade; both compose below retained UI, so
chat, panels, toolbar, cursor, and input remain live throughout travel. See
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
+The live frame barrier now also matches `SmartBox::UseTime`: local/remote
+object, projectile, animation, final-pose, animation-hook, particle, and
+owner-script work completes before the inbound network queue is dispatched.
+Local movement/jump output is committed on that same pre-network side, while a
+non-advancing post-network pass reconciles children and attached effect/light
+anchors after authoritative root moves. This prevents ACE's teleport Hidden state
+from freezing the final fraction of recall and replaying it after materialize,
+without an arrival reset or Hidden-time animation exception. Automated recall
+DAT conformance is green; destination-side visual confirmation remains.
+
---
#### (historical M1.5 working notes below)
@@ -513,9 +523,9 @@ include dungeons.
zero-time recursive chains fail diagnostically while valid timed weather loops
continue. The
live-pose step now publishes exact root/indexed rigid-part transforms after
- animation and equipped-child composition, then drains animation hooks,
- PhysicsScripts, attached emitters/lights, and particle simulation in fixed
- frame order. World-released particles remain at their birth positions while
+ animation and equipped-child composition, advances existing FP hooks, drains
+ animation hooks, refreshes attached emitters/lights, advances particles, then
+ ticks PhysicsScripts in fixed retail frame order. World-released particles remain at their birth positions while
parent-local particles follow; normal/blocking/anonymous logical emitter
semantics are exact; pending projections skip particle updates/draw while
retaining absolute creation times, the same emitter, and its logical ID, so
diff --git a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
index 6cd796c8..e0eedb10 100644
--- a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
+++ b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
@@ -844,8 +844,17 @@ Repository links:
4. No client-side impact, explosion, damage, or deletion is synthesized.
5. Presentation frame ordering is fixed: advance animation/root motion,
publish indexed parts, compose equipped children, drain animation hooks,
- tick owner PhysicsScripts, refresh attached emitters/lights, then advance
- particle simulation and draw.
+ refresh attached emitters/lights, advance particle simulation, tick owner
+ PhysicsScripts, dispatch inbound network events, run the post-inbound
+ command-interpreter position check, reconcile spatial derivatives without
+ advancing time, then draw. The explicit network boundary ports
+ `SmartBox::UseTime @ 0x00455410`: `CPhysics::UseTime` updates every object
+ (including its particle and script managers) before SmartBox drains its
+ inbound queue, after which `CommandInterpreter::UseTime @ 0x006B3BF0`
+ evaluates AutonomousPosition. Within each object,
+ `CPhysicsObj::UpdateObjectInternal @ 0x005156B0` updates
+ `ParticleManager` before `ScriptManager`, so a particle created by a script
+ begins simulation on the following object frame.
Retail stages animation hooks inside `CSequence::update`, then executes them
from `CPhysicsObj::process_hooks` before the new root is committed. The same
diff --git a/docs/research/2026-07-15-retail-portal-space-pseudocode.md b/docs/research/2026-07-15-retail-portal-space-pseudocode.md
index 47fd5ef7..2aba5560 100644
--- a/docs/research/2026-07-15-retail-portal-space-pseudocode.md
+++ b/docs/research/2026-07-15-retail-portal-space-pseudocode.md
@@ -276,10 +276,45 @@ The viewer reset copies the player's complete position into both `viewer` and
boom outward through the same damped and swept path used after camera
collision. Reusing the source-world viewer is not retail behavior.
+## Recall completion versus Hidden
+
The retail teleport hook does not clear the character's animation sequence.
-The arrival presentation is instead hidden by the portal scene and its final
-world view-plane transition while the normal motion/update stream settles. Therefore acdream
-must not add an arrival-only animation reset.
+Recall completion is instead guaranteed by the enclosing frame order:
+
+```text
+Client.UseTime:
+ Timer.update_time()
+ ClientNet.UseTime()
+ process packet controller/cache/UI work
+ SmartBox.UseTime()
+ SmartBox.Draw()
+
+SmartBox.UseTime:
+ CObjectMaint.UseTime()
+ CPhysics.UseTime() // advances PartArray, particles, scripts
+ GameTime/LScape/Ambient.UseTime()
+ while inbound queue not empty:
+ DispatchSmartBoxEvent() // SetState/Hidden lands here
+ CommandInterpreter.UseTime() // ShouldSendPositionEvent -> AP
+```
+
+The exact call sites are `Client::UseTime` `0x00411C40` and
+`SmartBox::UseTime` `0x00455410`, and `CommandInterpreter::UseTime`
+`0x006B3BF0`. `SmartBox::DoSetState` `0x004520D0`
+applies Hidden directly and does not advance or reset animation. Separately,
+`CPhysicsObj::UpdatePositionInternal` `0x00512C30` skips `CPartArray::Update`
+while Hidden. Therefore the ordering boundary is load-bearing: the current
+frame's animation must advance before an inbound Hidden transition can freeze
+it.
+
+ACE schedules lifestone recall teleport after the MotionTable-reported action
+length. With the installed human DATs, the boundary is about 15.06024 seconds;
+floating-point conversion can leave acdream at frame 149.999998 of the
+0..149 recall node if it dispatches Hidden before the current object tick. On
+UnHide that microscopic remainder becomes a visible destination-side recall
+tail. Retail's object-before-network order retires the node first. The faithful
+fix is that frame barrier, never an arrival-only Ready/reset command and never
+advancing PartArray while Hidden.
## acdream integration translation
@@ -303,6 +338,23 @@ must not add an arrival-only animation reset.
strand the transit, while canonical physics retains its own retail gates.
- Destination placement resets the retail chase camera's published and sought
positions to the player before the normal update path resumes.
+ - `RetailLiveFrameCoordinator` owns the `SmartBox::UseTime` phase barrier:
+ local/remote object and projectile advancement, final pose/hook publication,
+ attached emitters/lights, particle simulation, and PhysicsScripts all precede
+ inbound session dispatch in that retail order; rendering consumes the resulting
+ coherent snapshot.
+ - `RetailLocalPlayerFrameController` owns the local player's exactly-once
+ object tick. Input-originated movement and jump packets are serialized from
+ its pre-network result; F751 or ForcePosition received later in the frame
+ cannot relocate or replay those one-shot commands. After inbound dispatch,
+ `CommandInterpreter::UseTime @ 0x006B3BF0` evaluates AutonomousPosition from
+ the current controller frame, matching its exact retail slot.
+ - After inbound dispatch, a non-time-advancing reconciliation composes equipped
+ children and refreshes emitter/light anchors from the authoritative root. It
+ does not rerun animation, scripts, particles, or fades. A player first created
+ by that inbound pass is likewise projected without receiving a late physics
+ tick; its first normal object tick occurs on the next update.
+ This is update-loop ordering, not portal-specific animation logic.
- Destination residency remains acdream's asynchronous adaptation. It supplies
the same `EndTeleportAnimation` edge retail receives when its blocking cell
load completes; it does not alter presentation ordering.
diff --git a/src/AcDream.App/Input/LocalPlayerOutboundController.cs b/src/AcDream.App/Input/LocalPlayerOutboundController.cs
new file mode 100644
index 00000000..b6a6d1cf
--- /dev/null
+++ b/src/AcDream.App/Input/LocalPlayerOutboundController.cs
@@ -0,0 +1,209 @@
+using System.Numerics;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Input;
+
+///
+/// Serializes input-originated output from the object-phase result and the
+/// periodic position report from post-inbound controller state.
+///
+public sealed class LocalPlayerOutboundController
+{
+ private readonly Func _toWireRotation;
+ private readonly Action _diagnostic;
+
+ public LocalPlayerOutboundController(
+ Func toWireRotation,
+ Action diagnostic)
+ {
+ _toWireRotation = toWireRotation
+ ?? throw new ArgumentNullException(nameof(toWireRotation));
+ _diagnostic = diagnostic
+ ?? throw new ArgumentNullException(nameof(diagnostic));
+ }
+
+ ///
+ /// Sends input-originated movement and jump packets before inbound
+ /// dispatch. Retail emits these from input/object processing rather than
+ /// from CommandInterpreter::UseTime.
+ ///
+ public void SendPreNetworkActions(
+ WorldSession? session,
+ PlayerMovementController controller,
+ MovementResult movement,
+ bool hidden)
+ {
+ if (session is null || hidden)
+ return;
+
+ Quaternion wireRotation = _toWireRotation(controller.Yaw);
+ if (!controller.TryGetOutboundPosition(
+ wireRotation,
+ out uint wireCellId,
+ out Vector3 wirePosition))
+ {
+ return;
+ }
+
+ if (movement.ShouldSendMovementEvent)
+ TrySendMovement(session, controller, movement);
+
+ if (movement.JumpExtent.HasValue && movement.JumpVelocity.HasValue)
+ {
+ uint sequence = session.NextGameActionSequence();
+ byte[] body = JumpAction.Build(
+ gameActionSequence: sequence,
+ extent: movement.JumpExtent.Value,
+ velocity: movement.JumpVelocity.Value,
+ cellId: wireCellId,
+ position: wirePosition,
+ rotation: wireRotation,
+ instanceSequence: session.InstanceSequence,
+ serverControlSequence: session.ServerControlSequence,
+ teleportSequence: session.TeleportSequence,
+ forcePositionSequence: session.ForcePositionSequence);
+ session.SendGameAction(body);
+ }
+ }
+
+ ///
+ /// Ports the position-send slot in CommandInterpreter::UseTime
+ /// (0x006B3BF0), which SmartBox calls after draining inbound
+ /// events. The predicate and serialized frame therefore observe any
+ /// accepted ForcePosition or teleport state from this update.
+ ///
+ public void SendPostNetworkPosition(
+ WorldSession? session,
+ PlayerMovementController controller,
+ bool hidden)
+ {
+ if (session is null || hidden)
+ return;
+
+ Quaternion wireRotation = _toWireRotation(controller.Yaw);
+ if (!controller.TryGetOutboundPosition(
+ wireRotation,
+ out uint wireCellId,
+ out Vector3 wirePosition))
+ {
+ return;
+ }
+
+ var position = new Position(wireCellId, wirePosition, wireRotation);
+ if (!controller.ShouldSendPositionEvent(
+ position,
+ controller.ContactPlane,
+ controller.SimTimeSeconds)
+ || !controller.CanSendPositionEvent)
+ {
+ return;
+ }
+
+ MovementResult movement = controller.CapturePresentationResult();
+ byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0;
+ uint sequence = session.NextGameActionSequence();
+ byte[] body = AutonomousPosition.Build(
+ gameActionSequence: sequence,
+ cellId: wireCellId,
+ position: wirePosition,
+ rotation: wireRotation,
+ instanceSequence: session.InstanceSequence,
+ serverControlSequence: session.ServerControlSequence,
+ teleportSequence: session.TeleportSequence,
+ forcePositionSequence: session.ForcePositionSequence,
+ lastContact: contactByte);
+ _diagnostic(
+ "AP",
+ sequence,
+ movement,
+ wirePosition,
+ wireCellId,
+ contactByte);
+ session.SendGameAction(body);
+ controller.NotePositionSent(
+ position,
+ controller.ContactPlane,
+ controller.SimTimeSeconds);
+ }
+
+ public bool TrySendMovement(
+ WorldSession? session,
+ PlayerMovementController? controller,
+ MovementResult movement)
+ {
+ if (session is null || controller is null)
+ return false;
+
+ Quaternion wireRotation = _toWireRotation(controller.Yaw);
+ if (!controller.TryGetOutboundPosition(
+ wireRotation,
+ out uint wireCellId,
+ out Vector3 wirePosition))
+ {
+ return false;
+ }
+
+ byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0;
+ RawMotionState rawMotionState = BuildRawMotionState(movement);
+ uint sequence = session.NextGameActionSequence();
+ byte[] body = MoveToState.Build(
+ gameActionSequence: sequence,
+ rawMotionState: rawMotionState,
+ cellId: wireCellId,
+ position: wirePosition,
+ rotation: wireRotation,
+ instanceSequence: session.InstanceSequence,
+ serverControlSequence: session.ServerControlSequence,
+ teleportSequence: session.TeleportSequence,
+ forcePositionSequence: session.ForcePositionSequence,
+ contact: contactByte != 0,
+ standingLongjump: false);
+ _diagnostic(
+ "MTS",
+ sequence,
+ movement,
+ wirePosition,
+ wireCellId,
+ contactByte);
+ session.SendGameAction(body);
+ controller.NoteMovementSent(
+ controller.SimTimeSeconds,
+ movement.IsMouseLookMovementEvent);
+ return true;
+ }
+
+ public static RawMotionState BuildRawMotionState(MovementResult movement)
+ {
+ HoldKey axisHoldKey = movement.IsRunning ? HoldKey.Run : HoldKey.None;
+ return new RawMotionState
+ {
+ CurrentHoldKey = axisHoldKey,
+ ForwardCommand = movement.ForwardCommand
+ ?? RawMotionState.Default.ForwardCommand,
+ ForwardHoldKey = movement.ForwardCommand.HasValue
+ ? axisHoldKey : HoldKey.Invalid,
+ ForwardSpeed = movement.ForwardSpeed
+ ?? RawMotionState.Default.ForwardSpeed,
+ SidestepCommand = movement.SidestepCommand
+ ?? RawMotionState.Default.SidestepCommand,
+ SidestepHoldKey = movement.SidestepCommand.HasValue
+ ? movement.SidestepUsesRunHold
+ ? HoldKey.Run
+ : axisHoldKey
+ : HoldKey.Invalid,
+ SidestepSpeed = movement.SidestepSpeed
+ ?? RawMotionState.Default.SidestepSpeed,
+ TurnCommand = movement.TurnCommand
+ ?? RawMotionState.Default.TurnCommand,
+ TurnHoldKey = movement.TurnCommand.HasValue
+ ? movement.TurnUsesRunHold
+ ? HoldKey.Run
+ : axisHoldKey
+ : HoldKey.Invalid,
+ TurnSpeed = movement.TurnSpeed
+ ?? RawMotionState.Default.TurnSpeed,
+ };
+ }
+}
diff --git a/src/AcDream.App/Input/LocalPlayerProjectionController.cs b/src/AcDream.App/Input/LocalPlayerProjectionController.cs
new file mode 100644
index 00000000..9b851bb8
--- /dev/null
+++ b/src/AcDream.App/Input/LocalPlayerProjectionController.cs
@@ -0,0 +1,68 @@
+using AcDream.Core.World;
+
+namespace AcDream.App.Input;
+
+///
+/// Projects the canonical local physics body into world rendering and spatial
+/// buckets without advancing it.
+///
+public sealed class LocalPlayerProjectionController
+{
+ private readonly Func _resolveEntity;
+ private readonly Func _liveCenterX;
+ private readonly Func _liveCenterY;
+ private readonly Action _syncShadow;
+ private readonly Action _rebucket;
+
+ public LocalPlayerProjectionController(
+ Func resolveEntity,
+ Func liveCenterX,
+ Func liveCenterY,
+ Action syncShadow,
+ Action rebucket)
+ {
+ _resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
+ _liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX));
+ _liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY));
+ _syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow));
+ _rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket));
+ }
+
+ public void Project(
+ PlayerMovementController controller,
+ MovementResult movement,
+ bool hidden)
+ {
+ WorldEntity? entity = _resolveEntity();
+ if (entity is null)
+ return;
+
+ entity.SetPosition(movement.RenderPosition);
+ entity.ParentCellId = movement.CellId;
+ entity.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
+ System.Numerics.Vector3.UnitZ,
+ controller.Yaw - MathF.PI / 2f);
+ if (!hidden)
+ _syncShadow(entity, movement.CellId);
+
+ uint currentLandblock;
+ if (movement.CellId != 0 && (movement.CellId & 0xFFFFu) >= 0x0100u)
+ {
+ // Indoor positions belong to the cell's landblock. Dungeon frame
+ // origins can be negative, so render XYZ is not a landblock key.
+ currentLandblock = (movement.CellId & 0xFFFF0000u) | 0xFFFFu;
+ }
+ else
+ {
+ System.Numerics.Vector3 position = controller.Position;
+ int landblockX = _liveCenterX() + (int)Math.Floor(position.X / 192f);
+ int landblockY = _liveCenterY() + (int)Math.Floor(position.Y / 192f);
+ currentLandblock = (uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF);
+ }
+
+ // The teleport owner alone projects the destination while the local
+ // controller deliberately retains its frozen source cell.
+ if (controller.State != PlayerState.PortalSpace)
+ _rebucket(entity.ServerGuid, currentLandblock);
+ }
+}
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index ddace4ec..b09f2036 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -916,6 +916,21 @@ public sealed class PlayerMovementController
IsMouseLookMovementEvent: mouseLookEvent);
}
+ ///
+ /// Captures the current controller state for presentation without
+ /// advancing physics or requesting an outbound movement event.
+ ///
+ public MovementResult CapturePresentationResult()
+ {
+ MovementResult current = CaptureMovementResult(mouseLookEvent: false);
+ return current with
+ {
+ MotionStateChanged = false,
+ ShouldSendMovementEvent = false,
+ IsMouseLookMovementEvent = false,
+ };
+ }
+
///
/// Retail CommandInterpreter::SendPositionEvent (0x006B4770)
/// stamps the send time, complete cell-local frame (origin and
diff --git a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
new file mode 100644
index 00000000..cf0e393c
--- /dev/null
+++ b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
@@ -0,0 +1,155 @@
+namespace AcDream.App.Input;
+
+///
+/// Owns the local player's once-per-frame retail object phase.
+///
+///
+/// Retail advances the player and sends input-originated movement/jump output
+/// before SmartBox::UseTime drains inbound events. Its later
+/// CommandInterpreter::UseTime position check uses current post-inbound
+/// state. The cached input result is presentation-only after the barrier: its
+/// one-shot commands are never replayed against a later authoritative cell.
+///
+public sealed class RetailLocalPlayerFrameController
+{
+ private readonly Func _canPresentPlayer;
+ private readonly Func _getController;
+ private readonly Func _captureInput;
+ private readonly Func _resolveLocalEntityId;
+ private readonly Action _handleTargeting;
+ private readonly Func _isHidden;
+ private readonly Action _project;
+ private readonly Action _sendPreNetwork;
+ private readonly Action _sendPostNetwork;
+
+ private AdvancedFrame? _advancedFrame;
+
+ private readonly record struct AdvancedFrame(
+ PlayerMovementController Controller,
+ MovementResult Movement);
+
+ public readonly record struct PresentationFrame(
+ MovementResult Movement,
+ bool Hidden,
+ bool AdvancedBeforeNetwork);
+
+ public RetailLocalPlayerFrameController(
+ Func canPresentPlayer,
+ Func getController,
+ Func captureInput,
+ Func resolveLocalEntityId,
+ Action handleTargeting,
+ Func isHidden,
+ Action project,
+ Action sendPreNetwork,
+ Action sendPostNetwork)
+ {
+ _canPresentPlayer = canPresentPlayer
+ ?? throw new ArgumentNullException(nameof(canPresentPlayer));
+ _getController = getController
+ ?? throw new ArgumentNullException(nameof(getController));
+ _captureInput = captureInput
+ ?? throw new ArgumentNullException(nameof(captureInput));
+ _resolveLocalEntityId = resolveLocalEntityId
+ ?? throw new ArgumentNullException(nameof(resolveLocalEntityId));
+ _handleTargeting = handleTargeting
+ ?? throw new ArgumentNullException(nameof(handleTargeting));
+ _isHidden = isHidden
+ ?? throw new ArgumentNullException(nameof(isHidden));
+ _project = project
+ ?? throw new ArgumentNullException(nameof(project));
+ _sendPreNetwork = sendPreNetwork
+ ?? throw new ArgumentNullException(nameof(sendPreNetwork));
+ _sendPostNetwork = sendPostNetwork
+ ?? throw new ArgumentNullException(nameof(sendPostNetwork));
+ }
+
+ ///
+ /// Advances the existing local object exactly once on the object side of
+ /// the inbound-network barrier.
+ ///
+ public void AdvanceBeforeNetwork(float deltaSeconds)
+ {
+ _advancedFrame = null;
+ PlayerMovementController? controller = _getController();
+ if (!_canPresentPlayer() || controller is null)
+ return;
+
+ controller.LocalEntityId = _resolveLocalEntityId();
+ _handleTargeting();
+
+ bool hidden = _isHidden();
+ MovementResult movement = hidden
+ ? controller.TickHidden(deltaSeconds)
+ : controller.Update(deltaSeconds, _captureInput());
+
+ _project(controller, movement, hidden);
+ _sendPreNetwork(controller, movement, hidden);
+ _advancedFrame = new AdvancedFrame(controller, movement);
+ }
+
+ ///
+ /// Runs retail's post-inbound command-interpreter phase. The split
+ /// controller/render projection is reconciled without advancing physics,
+ /// then AutonomousPosition is evaluated from that current state.
+ ///
+ public void RunPostNetworkCommandPhase()
+ {
+ PlayerMovementController? controller = _getController();
+ if (!_canPresentPlayer() || controller is null)
+ return;
+
+ bool hidden = _isHidden();
+ if (_advancedFrame is { } advanced
+ && ReferenceEquals(advanced.Controller, controller))
+ {
+ MovementResult movement = RefreshSpatialFields(
+ advanced.Movement,
+ controller);
+ _project(controller, movement, hidden);
+ _advancedFrame = new AdvancedFrame(controller, movement);
+ }
+
+ _sendPostNetwork(controller, hidden);
+ }
+
+ ///
+ /// Produces the post-network presentation sample without advancing
+ /// physics. An owner first created by the inbound pass is seeded from its
+ /// current state and receives its first real object tick next frame.
+ ///
+ public bool TryGetPresentationAfterNetwork(out PresentationFrame frame)
+ {
+ frame = default;
+ PlayerMovementController? controller = _getController();
+ if (!_canPresentPlayer() || controller is null)
+ return false;
+
+ bool hidden = _isHidden();
+ if (_advancedFrame is { } advanced
+ && ReferenceEquals(advanced.Controller, controller))
+ {
+ MovementResult movement = RefreshSpatialFields(
+ advanced.Movement,
+ controller);
+ frame = new PresentationFrame(movement, hidden, AdvancedBeforeNetwork: true);
+ return true;
+ }
+
+ MovementResult initial = controller.CapturePresentationResult();
+ _project(controller, initial, hidden);
+ frame = new PresentationFrame(initial, hidden, AdvancedBeforeNetwork: false);
+ return true;
+ }
+
+ private static MovementResult RefreshSpatialFields(
+ MovementResult movement,
+ PlayerMovementController controller) =>
+ movement with
+ {
+ Position = controller.Position,
+ RenderPosition = controller.RenderPosition,
+ CellId = controller.CellId,
+ IsOnGround = controller.CanSendPositionEvent,
+ };
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 38c227a0..ed34c4bd 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -163,6 +163,10 @@ public sealed class GameWindow : IDisposable
// once the injected shared helpers exist as method groups (GetSetupCylinder,
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
+ private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
+ private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
+ private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
+ private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map;
// each runtime component is stored on the canonical LiveEntityRecord.
@@ -1182,6 +1186,51 @@ public sealed class GameWindow : IDisposable
// handler) stay on GameWindow and are injected as delegates.
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
_physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle);
+ _localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController(
+ resolveEntity: () =>
+ _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
+ ? entity
+ : null,
+ liveCenterX: () => _liveCenterX,
+ liveCenterY: () => _liveCenterY,
+ syncShadow: (entity, cellId) => SyncLocalPlayerShadow(entity, cellId),
+ rebucket: (serverGuid, landblockId) =>
+ _liveEntities?.RebucketLiveEntity(serverGuid, landblockId));
+ _localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController(
+ YawToAcQuaternion,
+ DumpMovementTruthOutbound);
+ _localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController(
+ canPresentPlayer: CanAdvanceLocalPlayer,
+ getController: () => _playerController,
+ captureInput: () =>
+ {
+ _playerMouseDeltaX = 0f;
+ return CaptureMovementInput();
+ },
+ resolveLocalEntityId: () =>
+ _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
+ ? entity.Id
+ : 0u,
+ handleTargeting: () => _playerHost?.HandleTargetting(),
+ isHidden: () => _liveEntities?.IsHidden(_playerServerGuid) == true,
+ project: (controller, movement, hidden) =>
+ _localPlayerProjection.Project(controller, movement, hidden),
+ sendPreNetwork: (controller, movement, hidden) =>
+ _localPlayerOutbound.SendPreNetworkActions(
+ _liveSession,
+ controller,
+ movement,
+ hidden),
+ sendPostNetwork: (controller, hidden) =>
+ _localPlayerOutbound.SendPostNetworkPosition(
+ _liveSession,
+ controller,
+ hidden));
+ _liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
+ AdvanceLiveObjectRuntime,
+ () => _liveSessionController?.Tick(),
+ _localPlayerFrame.RunPostNetworkCommandPhase,
+ ReconcileLiveObjectSpatialPresentation);
}
public void Run()
@@ -7066,6 +7115,7 @@ public sealed class GameWindow : IDisposable
// camera path re-extends the boom. Updating directly from the stale
// source-world viewer made the destination bend across the reveal.
_retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw);
+ ReconcileLiveObjectSpatialPresentation();
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}");
@@ -8764,11 +8814,15 @@ public sealed class GameWindow : IDisposable
{
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
+ double frameSeconds =
+ AcDream.App.World.RetailLiveFrameCoordinator.NormalizeDeltaSeconds(dt);
+ float frameDelta = (float)frameSeconds;
+
// Retail ScriptManager::AddScriptInternal (0x0051B310) stamps
// Timer::cur_time at the packet/default-script call site. Publish this
// update frame's clock before streaming and network dispatch, then
// reuse the same timestamp for animation hooks and the later drain.
- _physicsScriptGameTime += Math.Max(0.0, dt);
+ _physicsScriptGameTime += frameSeconds;
_scriptRunner?.PublishTime(_physicsScriptGameTime);
// [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick;
@@ -8955,12 +9009,64 @@ public sealed class GameWindow : IDisposable
}
}
+ // Input callbacks feed the current object tick. Packets already read
+ // by Core.Net remain queued until the retail object/physics phase has
+ // consumed that input and published its final pose.
+ _inputDispatcher?.Tick();
+
+ // Phase K.2 — mouse-look is an input source for this object's
+ // movement tick, so sample it before the retail CPhysics/network
+ // barrier just like held keyboard actions.
+ if (_mouseLook is not null)
+ {
+ bool wantCaptureMouse = IsUiCapturingMouse();
+ if (wantCaptureMouse != _lastWantCaptureMouse)
+ {
+ if (wantCaptureMouse && _mouseLook.Active)
+ EndMouseLookAndRestoreCursor();
+ _mouseLook.OnWantCaptureMouseChanged(wantCaptureMouse);
+ _lastWantCaptureMouse = wantCaptureMouse;
+ }
+
+ float nowSeconds = (float)(Environment.TickCount64 / 1000.0);
+ if (_mouseLook.TryTakeRawSample(nowSeconds, out float rawX, out float rawY))
+ {
+ // GetInput synthesizes (0,0) only after >0.2 s idle.
+ // MouseLookHandler stops drift before filtering that sample;
+ // the filter tail may then start a smaller Rotate again.
+ if (rawX == 0f && rawY == 0f)
+ _playerController?.StopMouseDrift(CaptureMovementInput());
+
+ (float filteredX, float filteredY) =
+ AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera
+ && _retailChaseCamera is not null
+ ? _retailChaseCamera.FilterMouseDelta(
+ rawX,
+ rawY,
+ weight: 0.5f,
+ nowSec: nowSeconds)
+ : (rawX, rawY);
+ _mouseLook.ApplyDelta(filteredX, _sensChase);
+ if (_retailChaseCamera is not null)
+ _retailChaseCamera.AdjustPitch(filteredY * 0.003f * _sensChase);
+ else
+ _chaseCamera?.AdjustPitch(filteredY * 0.003f * _sensChase);
+ }
+ }
+
+ _combatAttackController?.Tick();
+
// Drain pending live-session traffic AFTER streaming so any incoming
// CreateObject events find their landblock already loaded in
// GpuWorldState. Non-blocking — returns immediately if no datagrams
// are in the kernel buffer. Fires EntitySpawned events synchronously.
// Step 2: routed through the controller; functionally identical.
- _liveSessionController?.Tick();
+ // Retail SmartBox::UseTime (0x00455410) advances CObjectMaint and
+ // CPhysics before it drains the inbound event queue. Keep the complete
+ // live-object phase on that side of the barrier too. In particular,
+ // the recall action retires before ACE's Hidden SetState freezes its
+ // PartArray at the teleport boundary.
+ _liveFrameCoordinator.Tick(frameDelta);
// Usually F751 activates immediately. This no-op convergence check
// covers the session edge where the canonical player exists before
@@ -8980,7 +9086,7 @@ public sealed class GameWindow : IDisposable
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
if (haveDest && !ready)
{
- _teleportHoldSeconds += (float)dt;
+ _teleportHoldSeconds += frameDelta;
if (_teleportHoldSeconds >= TeleportMaxHoldSeconds)
{
ready = true;
@@ -8989,7 +9095,7 @@ public sealed class GameWindow : IDisposable
}
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
- var (snap, evts) = _teleportAnim.Tick((float)dt, ready, tunnelFrame);
+ var (snap, evts) = _teleportAnim.Tick(frameDelta, ready, tunnelFrame);
_teleportViewPlane.Update(snap);
foreach (var e in evts)
@@ -9027,62 +9133,16 @@ public sealed class GameWindow : IDisposable
}
}
- _portalTunnel?.Tick((float)dt);
+ _portalTunnel?.Tick(frameDelta);
}
// Phase K.1a — tick the input dispatcher so Hold-type bindings
// re-fire while their chord is held. K.1b adds the subscribers
// that actually consume the events.
- _inputDispatcher?.Tick();
- _combatAttackController?.Tick();
-
// Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse
// on selection, then revert). No-op when nothing is flashing.
// Retained panel-local timers advance through RetailUiRuntime.Tick on the draw seam.
- // Phase K.2 — re-evaluate WantCaptureMouse for the MMB
- // mouse-look state machine. Detect rising/falling edges so the
- // state suspends correctly when ImGui claims the cursor while
- // MMB is held (e.g. a tooltip pop-up over the cursor). When the
- // suspend deactivates an active session, restore the cursor so
- // it doesn't get stuck hidden under a panel.
- if (_mouseLook is not null)
- {
- bool wcm = IsUiCapturingMouse();
- if (wcm != _lastWantCaptureMouse)
- {
- if (wcm && _mouseLook.Active)
- EndMouseLookAndRestoreCursor();
- _mouseLook.OnWantCaptureMouseChanged(wcm);
- _lastWantCaptureMouse = wcm;
- }
-
- float nowSeconds = (float)(Environment.TickCount64 / 1000.0);
- if (_mouseLook.TryTakeRawSample(nowSeconds, out float rawX, out float rawY))
- {
- // GetInput synthesizes (0,0) only after >0.2 s idle.
- // MouseLookHandler stops drift before filtering that sample;
- // the filter tail may then start a smaller Rotate again.
- if (rawX == 0f && rawY == 0f)
- _playerController?.StopMouseDrift(CaptureMovementInput());
-
- (float filteredX, float filteredY) =
- AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera
- && _retailChaseCamera is not null
- ? _retailChaseCamera.FilterMouseDelta(
- rawX,
- rawY,
- weight: 0.5f,
- nowSec: nowSeconds)
- : (rawX, rawY);
- _mouseLook.ApplyDelta(filteredX, _sensChase);
- if (_retailChaseCamera is not null)
- _retailChaseCamera.AdjustPitch(filteredY * 0.003f * _sensChase);
- else
- _chaseCamera?.AdjustPitch(filteredY * 0.003f * _sensChase);
- }
- }
-
// Phase K.2 — auto-enter player mode at login. The guard
// returns true on the firing tick (one-shot); subsequent ticks
// are no-ops. Skipped offline (no _liveSession → IsLiveInWorld
@@ -9116,7 +9176,7 @@ public sealed class GameWindow : IDisposable
// delta drives fly heading instead).
if (_inputDispatcher is null) return;
_cameraController.Fly.Update(
- dt,
+ frameSeconds,
w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward),
a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft),
s: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup),
@@ -9142,7 +9202,7 @@ public sealed class GameWindow : IDisposable
// retail chase is selected; legacy camera ignores these.
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
{
- float adj = AcDream.Core.Rendering.CameraDiagnostics.CameraAdjustmentSpeed * (float)dt;
+ float adj = AcDream.Core.Rendering.CameraDiagnostics.CameraAdjustmentSpeed * frameDelta;
if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomIn))
_retailChaseCamera.AdjustDistance(-adj);
if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomOut))
@@ -9161,82 +9221,17 @@ public sealed class GameWindow : IDisposable
// until Q is pressed again. Handled in OnInputAction; here
// we just OR _autoRunActive into the Forward flag.
// * Mouse never drives character yaw (K.1b regression-prevention).
- var input = CaptureMovementInput();
+ if (!_localPlayerFrame.TryGetPresentationAfterNetwork(out var playerFrame))
+ return;
- // Fix #42 (2026-05-05): keep PlayerMovementController's
- // LocalEntityId in sync with the live local player entity so
- // FindObjCollisions skips its own ShadowEntry. Re-fetched per
- // tick so re-spawns / character switches don't leave a stale
- // id on the controller. Pre-spawn or between-character it
- // stays 0 (no filter), which is harmless because there's no
- // ShadowEntry registered yet.
- _playerController.LocalEntityId =
- _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var localEnt)
- ? localEnt.Id
- : 0u;
-
- // R5-V2: tick the player's TargetManager BEFORE Update ticks
- // MoveToManager.UseTime (retail UpdateObjectInternal order). This is
- // the load-bearing call for creature-chase: the player, as a watched
- // target, pushes its position to its voyeurs (the NPCs moving-to it)
- // here — that push lands in each NPC's HandleUpdateTarget the same
- // frame, ahead of the NPCs' own UseTime in the per-remote loop
- // (which runs after this block). Replaces the AP-79 player poll.
- _playerHost?.HandleTargetting();
-
- bool localPlayerHidden = _liveEntities?.IsHidden(_playerServerGuid) == true;
- var result = localPlayerHidden
- ? _playerController.TickHidden((float)dt)
- : _playerController.Update((float)dt, input);
-
- // Update the player entity's position + rotation so it renders at
- // the physics-resolved location each frame.
- if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
+ AcDream.App.Input.MovementResult result = playerFrame.Movement;
+ bool localPlayerHidden = playerFrame.Hidden;
+ if (!playerFrame.AdvancedBeforeNetwork)
{
- pe.SetPosition(result.RenderPosition); // A.5 T18: SetPosition propagates AabbDirty
- pe.ParentCellId = result.CellId;
- pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
- System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
- if (!localPlayerHidden)
- SyncLocalPlayerShadow(pe, result.CellId);
-
- // Move the player entity to its current landblock in GpuWorldState
- // so it doesn't get frustum-culled when the player walks away from
- // the spawn landblock. Without this, the entity stays in the spawn
- // landblock's entity list and disappears when that landblock is culled.
- uint currentLb;
- if (result.CellId != 0 && (result.CellId & 0xFFFFu) >= 0x0100u)
- {
- // Indoor cell (dungeon/building EnvCell): the entity's landblock is
- // the CELL's landblock. Dungeon EnvCells sit at arbitrary "ocean"
- // world coords with negative local-Y, so floor(pp.Y/192) lands one
- // landblock off (the Bug-A class) — relocating the player into the
- // landblock the dungeon collapse unloaded, making the avatar
- // invisible. The cell id is authoritative.
- currentLb = (result.CellId & 0xFFFF0000u) | 0xFFFFu;
- }
- else
- {
- var pp = _playerController.Position;
- int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
- int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
- currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
- }
- // #138-B (2026-06-24): do NOT relocate the avatar while a teleport
- // is in transit (PortalSpace). The controller's cell is still the
- // FROZEN SOURCE until PlaceTeleportArrival materializes the
- // destination, so relocating here drags the avatar — which the
- // teleport's rescue/re-inject (GpuWorldState.DrainRescued) already
- // placed at the destination center — back into the now-UNLOADED
- // SOURCE landblock's pending bucket, where nothing recovers it
- // (the old relocate path only scanned _loaded). Net: the avatar vanishes
- // after teleporting out. The teleport machinery owns the avatar's
- // landblock during transit; per-frame relocation resumes at
- // FireLoginComplete (InWorld). Probe-confirmed: launch4 line 561
- // APPEND guid=player lb=0x0007FFFF(source dungeon) -> PENDING ->
- // DRAWSET ABSENT, never recovered.
- if (_playerController.State != AcDream.App.Input.PlayerState.PortalSpace)
- _liveEntities?.RebucketLiveEntity(pe.ServerGuid, currentLb);
+ // The player was materialized by this frame's inbound pass.
+ // Its non-advancing initial projection must publish child and
+ // effect anchors before the first draw too.
+ ReconcileLiveObjectSpatialPresentation();
}
// Update chase camera(s). The CameraController exposes whichever
@@ -9249,7 +9244,7 @@ public sealed class GameWindow : IDisposable
// low-stiffness damping).
_chaseCamera.Update(result.RenderPosition, _playerController.Yaw,
isOnGround: result.IsOnGround,
- dt: (float)dt);
+ dt: frameDelta);
// RetailChaseCamera: heading is the player's facing direction
// projected onto the contact plane when grounded, or the
// world XY plane when airborne. The contact plane normal
@@ -9263,98 +9258,26 @@ public sealed class GameWindow : IDisposable
playerVelocity: _playerController.BodyVelocity,
isOnGround: result.IsOnGround,
contactPlaneNormal: _playerController.ContactPlane.Normal,
- dt: (float)dt,
+ dt: frameDelta,
cellId: _playerController.CellId,
selfEntityId: _playerController.LocalEntityId,
trackedTargetPoint: trackedCombatTarget);
- // Send outbound movement messages to the live server.
- if (_liveSession is not null
- && !localPlayerHidden
- && _playerController.TryGetOutboundPosition(
- YawToAcQuaternion(_playerController.Yaw),
- out uint wireCellId,
- out System.Numerics.Vector3 wirePos))
- {
- // Retail serializes CPhysicsObj::m_position directly. The
- // controller owns this canonical (cell, landblock-local origin)
- // pair; the render/streaming origin never enters wire coordinates.
- var wireRot = YawToAcQuaternion(_playerController.Yaw);
- byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
- var wirePosition = new AcDream.Core.Physics.Position(
- wireCellId,
- wirePos,
- wireRot);
-
- // Snapshot the AP predicate before this path mutates its send
- // tracker. Retail's UseTime calls ShouldSendPositionEvent then
- // SendPositionEvent; MTS originates in separate input handlers,
- // so their relative same-tick callback order remains TS-33.
- bool positionEventDue = _playerController.ShouldSendPositionEvent(
- wirePosition,
- _playerController.ContactPlane,
- _playerController.SimTimeSeconds);
-
- // 2026-05-16 (issue #75) / R4-V5: user-MoveToState packets
- // are ONLY for user-initiated motion intent — retail's
- // architectural split between user-input motion and
- // server-driven motion. Post-V5 the split holds BY
- // By construction, ShouldSendMovementEvent derives from user
- // input edges plus CameraSet's mouse-look start/stop/0.5-second
- // cadence. MoveToManager dispatches never touch either source,
- // so server-driven movement cannot echo a MoveToState. A user
- // edge cancels the moveto and legitimately takes control.
- if (result.ShouldSendMovementEvent)
- TrySendPlayerMovementEvent(result);
-
- // SendPositionEvent itself admits only Contact+OnWalkable.
- if (positionEventDue && _playerController.CanSendPositionEvent)
- {
- var seq = _liveSession.NextGameActionSequence();
- var body = AcDream.Core.Net.Messages.AutonomousPosition.Build(
- gameActionSequence: seq,
- cellId: wireCellId,
- position: wirePos,
- rotation: wireRot,
- instanceSequence: _liveSession.InstanceSequence,
- serverControlSequence: _liveSession.ServerControlSequence,
- teleportSequence: _liveSession.TeleportSequence,
- forcePositionSequence: _liveSession.ForcePositionSequence,
- lastContact: contactByte);
- DumpMovementTruthOutbound(
- "AP", seq, result, wirePos, wireCellId, contactByte);
- _liveSession.SendGameAction(body);
- _playerController.NotePositionSent(
- position: wirePosition,
- contactPlane: _playerController.ContactPlane,
- nowSeconds: _playerController.SimTimeSeconds);
- }
-
- if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue)
- {
- // D4/L.2b (2026-06-30): JumpPack::Pack (0x00516d10) packs the
- // full Position, not an objectGuid/spellId — pass the same
- // wireCellId/wirePos/wireRot the MoveToState send above uses.
- var seq = _liveSession.NextGameActionSequence();
- var jumpBody = AcDream.Core.Net.Messages.JumpAction.Build(
- gameActionSequence: seq,
- extent: result.JumpExtent.Value,
- velocity: result.JumpVelocity.Value,
- cellId: wireCellId,
- position: wirePos,
- rotation: wireRot,
- instanceSequence: _liveSession.InstanceSequence,
- serverControlSequence: _liveSession.ServerControlSequence,
- teleportSequence: _liveSession.TeleportSequence,
- forcePositionSequence: _liveSession.ForcePositionSequence);
- _liveSession.SendGameAction(jumpBody);
- }
- }
// Update the player entity's animation cycle to match current motion.
}
}
+ private bool CanAdvanceLocalPlayer() =>
+ _cameraController is not null
+ && _input is not null
+ && !_cameraController.IsFlyMode
+ && _playerMode
+ && _playerController is not null
+ && _chaseCamera is not null
+ && _inputDispatcher is not null
+ && !(DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
+
private void DumpMovementTruthOutbound(
string kind,
uint sequence,
@@ -9589,40 +9512,6 @@ public sealed class GameWindow : IDisposable
if (DevToolsEnabled && _imguiBootstrap is not null)
_imguiBootstrap.BeginFrame((float)deltaSeconds);
- // Phase 6.4: advance per-entity animation playback before drawing
- // so the renderer always sees the up-to-date per-part transforms.
- // PhysicsScript timed hooks and animation hooks read the same current
- // retail update-frame clock published before network dispatch. Drain
- // the script pass later after final root/part poses are composed.
- // Re-publish without advancing so an extra render between updates still
- // retains retail's animation-hook versus object-hook phase barrier.
- _scriptRunner?.PublishTime(_physicsScriptGameTime);
- if (_liveEntities is { } liveEntities)
- {
- _remotePhysicsUpdater.TickHiddenEntities(
- liveEntities,
- _playerServerGuid,
- (float)deltaSeconds,
- entity => _effectPoses.UpdateRoot(entity));
- }
- _projectileController?.Tick(
- _physicsScriptGameTime,
- _liveCenterX,
- _liveCenterY,
- _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var projectileViewer)
- ? projectileViewer.Position
- : null);
- if (_animatedEntities.Count > 0)
- TickAnimations((float)deltaSeconds);
- _equippedChildRenderer?.Tick();
- _animationHookFrames?.Drain();
-
- // #188 — advance translucency fades UNCONDITIONALLY (not gated on
- // _animatedEntities.Count): a one-shot open-cycle animation can
- // finish and drop its entity from _animatedEntities before the
- // fade's own Time has elapsed, but the ramp must keep running.
- _translucencyFades.AdvanceAll((float)deltaSeconds);
-
// Phase G.1: weather state machine — deterministic per-day roll
// + transitions + lightning flash.
var cal = WorldTime.CurrentCalendar;
@@ -9784,12 +9673,6 @@ public sealed class GameWindow : IDisposable
// debug-only and disabled for normal retail rendering.
if (_options.EnableSkyPesDebug)
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
- _entityEffects?.RefreshLiveOwnerPoses();
- _scriptRunner?.Tick(_physicsScriptGameTime);
- _particleSink?.RefreshAttachedEmitters();
- _liveEntityLights?.Refresh();
- _particleSystem?.Tick((float)deltaSeconds);
-
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
// the scene-lighting UBO once per frame. Every shader that
// consumes binding=1 reads the same data for the rest of the
@@ -10737,6 +10620,71 @@ public sealed class GameWindow : IDisposable
ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)));
}
+ ///
+ /// Advances the live object's retail UseTime phase before inbound packet
+ /// dispatch. Final root/part poses and their animation hooks are published
+ /// together so rendering consumes one coherent update snapshot.
+ ///
+ private void AdvanceLiveObjectRuntime(float dt)
+ {
+ // The local body participates in the same retail CPhysics phase as
+ // remote objects. Its outbound movement snapshot is completed here,
+ // before SmartBox dispatch can apply F751/ForcePosition.
+ _localPlayerFrame.AdvanceBeforeNetwork(dt);
+ _scriptRunner?.PublishTime(_physicsScriptGameTime);
+ if (_liveEntities is { } liveEntities)
+ {
+ _remotePhysicsUpdater.TickHiddenEntities(
+ liveEntities,
+ _playerServerGuid,
+ dt,
+ entity => _effectPoses.UpdateRoot(entity));
+ }
+
+ _projectileController?.Tick(
+ _physicsScriptGameTime,
+ _liveCenterX,
+ _liveCenterY,
+ _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var projectileViewer)
+ ? projectileViewer.Position
+ : null);
+ if (_animatedEntities.Count > 0)
+ TickAnimations(dt);
+ _equippedChildRenderer?.Tick();
+
+ // Advance already-active FP hooks before routing hooks emitted by
+ // this PartArray update. A newly created translucency hook begins at
+ // its authored Start value this frame; it does not consume dt twice.
+ _translucencyFades.AdvanceAll(dt);
+ _animationHookFrames?.Drain();
+ _entityEffects?.RefreshLiveOwnerPoses();
+ _particleSink?.RefreshAttachedEmitters();
+ _liveEntityLights?.Refresh();
+
+ // Retail CPhysicsObj::UpdateObjectInternal (0x005156B0) advances
+ // ParticleManager before ScriptManager. A particle created by a PES
+ // hook therefore begins simulation on the following object frame.
+ _particleSystem?.Tick(dt);
+ _scriptRunner?.Tick(_physicsScriptGameTime);
+ }
+
+ ///
+ /// Re-composes spatial derivatives after authoritative packet handlers
+ /// move roots. This pass advances no time, animation, scripts, particles,
+ /// or fades; it only makes children and live anchors agree with the root
+ /// that the same frame will draw.
+ ///
+ private void ReconcileLiveObjectSpatialPresentation()
+ {
+ // Authoritative handlers mutate WorldEntity roots directly. Publish
+ // those roots before children read their parent's pose; child Tick
+ // then publishes each newly composed child pose for attached effects.
+ _entityEffects?.RefreshLiveOwnerPoses();
+ _equippedChildRenderer?.Tick();
+ _particleSink?.RefreshAttachedEmitters();
+ _liveEntityLights?.Refresh();
+ }
+
///
/// Phase 6.4: advance every animated entity's frame counter by
/// * Framerate, wrapping around the cycle's
@@ -12921,79 +12869,14 @@ public sealed class GameWindow : IDisposable
}
internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState(
- AcDream.App.Input.MovementResult result)
- {
- var axisHoldKey = result.IsRunning
- ? AcDream.Core.Physics.HoldKey.Run
- : AcDream.Core.Physics.HoldKey.None;
- return new AcDream.Core.Physics.RawMotionState
- {
- CurrentHoldKey = axisHoldKey,
- ForwardCommand = result.ForwardCommand
- ?? AcDream.Core.Physics.RawMotionState.Default.ForwardCommand,
- ForwardHoldKey = result.ForwardCommand.HasValue
- ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
- ForwardSpeed = result.ForwardSpeed
- ?? AcDream.Core.Physics.RawMotionState.Default.ForwardSpeed,
- SidestepCommand = result.SidestepCommand
- ?? AcDream.Core.Physics.RawMotionState.Default.SidestepCommand,
- SidestepHoldKey = result.SidestepCommand.HasValue
- ? result.SidestepUsesRunHold
- ? AcDream.Core.Physics.HoldKey.Run
- : axisHoldKey
- : AcDream.Core.Physics.HoldKey.Invalid,
- SidestepSpeed = result.SidestepSpeed
- ?? AcDream.Core.Physics.RawMotionState.Default.SidestepSpeed,
- TurnCommand = result.TurnCommand
- ?? AcDream.Core.Physics.RawMotionState.Default.TurnCommand,
- TurnHoldKey = result.TurnCommand.HasValue
- ? result.TurnUsesRunHold
- ? AcDream.Core.Physics.HoldKey.Run
- : axisHoldKey
- : AcDream.Core.Physics.HoldKey.Invalid,
- TurnSpeed = result.TurnSpeed
- ?? AcDream.Core.Physics.RawMotionState.Default.TurnSpeed,
- };
- }
+ AcDream.App.Input.MovementResult result) =>
+ AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result);
private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result)
- {
- if (_liveSession is not { } session
- || _playerController is not { } controller)
- return false;
-
- var wireRot = YawToAcQuaternion(controller.Yaw);
- if (!controller.TryGetOutboundPosition(
- wireRot,
- out uint wireCellId,
- out System.Numerics.Vector3 wirePos))
- return false;
-
- byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
- AcDream.Core.Physics.RawMotionState rawMotionState =
- BuildOutboundRawMotionState(result);
-
- uint seq = session.NextGameActionSequence();
- byte[] body = AcDream.Core.Net.Messages.MoveToState.Build(
- gameActionSequence: seq,
- rawMotionState: rawMotionState,
- cellId: wireCellId,
- position: wirePos,
- rotation: wireRot,
- instanceSequence: session.InstanceSequence,
- serverControlSequence: session.ServerControlSequence,
- teleportSequence: session.TeleportSequence,
- forcePositionSequence: session.ForcePositionSequence,
- contact: contactByte != 0,
- standingLongjump: false);
- DumpMovementTruthOutbound(
- "MTS", seq, result, wirePos, wireCellId, contactByte);
- session.SendGameAction(body);
- controller.NoteMovementSent(
- controller.SimTimeSeconds,
- result.IsMouseLookMovementEvent);
- return true;
- }
+ => _localPlayerOutbound.TrySendMovement(
+ _liveSession,
+ _playerController,
+ result);
private uint? GetSelectedOrClosestCombatTarget()
{
diff --git a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs
new file mode 100644
index 00000000..b625e18b
--- /dev/null
+++ b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs
@@ -0,0 +1,53 @@
+namespace AcDream.App.World;
+
+///
+/// Owns retail's live-object, inbound-dispatch, and command-interpreter frame
+/// phases.
+///
+///
+/// Retail SmartBox::UseTime (0x00455410) advances
+/// CObjectMaint::UseTime and CPhysics::UseTime before draining
+/// SmartBox::in_queue, then calls CommandInterpreter::UseTime.
+/// Keeping both boundaries explicit prevents an inbound Hidden transition
+/// from freezing an action due to complete this frame while ensuring the
+/// periodic AutonomousPosition check observes accepted inbound state.
+///
+public sealed class RetailLiveFrameCoordinator
+{
+ private readonly Action _advanceObjectRuntime;
+ private readonly Action _dispatchInboundNetwork;
+ private readonly Action _runPostNetworkCommands;
+ private readonly Action _reconcileSpatialPresentation;
+
+ public RetailLiveFrameCoordinator(
+ Action advanceObjectRuntime,
+ Action dispatchInboundNetwork,
+ Action runPostNetworkCommands,
+ Action reconcileSpatialPresentation)
+ {
+ _advanceObjectRuntime = advanceObjectRuntime
+ ?? throw new ArgumentNullException(nameof(advanceObjectRuntime));
+ _dispatchInboundNetwork = dispatchInboundNetwork
+ ?? throw new ArgumentNullException(nameof(dispatchInboundNetwork));
+ _runPostNetworkCommands = runPostNetworkCommands
+ ?? throw new ArgumentNullException(nameof(runPostNetworkCommands));
+ _reconcileSpatialPresentation = reconcileSpatialPresentation
+ ?? throw new ArgumentNullException(nameof(reconcileSpatialPresentation));
+ }
+
+ public void Tick(float deltaSeconds)
+ {
+ float frameDelta = (float)NormalizeDeltaSeconds(deltaSeconds);
+ _advanceObjectRuntime(frameDelta);
+ _dispatchInboundNetwork();
+ _runPostNetworkCommands();
+ _reconcileSpatialPresentation();
+ }
+
+ public static double NormalizeDeltaSeconds(double deltaSeconds) =>
+ double.IsFinite(deltaSeconds)
+ && deltaSeconds > 0.0
+ && deltaSeconds <= float.MaxValue
+ ? deltaSeconds
+ : 0.0;
+}
diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
new file mode 100644
index 00000000..29929f19
--- /dev/null
+++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
@@ -0,0 +1,151 @@
+using System.Numerics;
+using AcDream.App.Input;
+using AcDream.App.World;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Tests.Input;
+
+public sealed class RetailLocalPlayerFrameControllerTests
+{
+ [Fact]
+ public void F751BetweenPhases_CannotRelocateOrReplayCapturedOutboundMovement()
+ {
+ PlayerMovementController controller = CreateController();
+ var calls = new List();
+ var preNetwork = new List<(PlayerState State, MovementResult Movement)>();
+ var postNetwork = new List();
+ var local = new RetailLocalPlayerFrameController(
+ canPresentPlayer: () => true,
+ getController: () => controller,
+ captureInput: () => new MovementInput(Forward: true),
+ resolveLocalEntityId: () => 7u,
+ handleTargeting: () => calls.Add("target"),
+ isHidden: () => false,
+ project: (_, _, _) => calls.Add("project"),
+ sendPreNetwork: (owner, movement, _) =>
+ {
+ calls.Add("pre-outbound");
+ preNetwork.Add((owner.State, movement));
+ },
+ sendPostNetwork: (owner, _) =>
+ {
+ calls.Add("post-position");
+ postNetwork.Add(owner.State);
+ });
+ var live = new RetailLiveFrameCoordinator(
+ dt =>
+ {
+ calls.Add("objects");
+ local.AdvanceBeforeNetwork(dt);
+ },
+ () =>
+ {
+ calls.Add("f751");
+ controller.State = PlayerState.PortalSpace;
+ },
+ () =>
+ {
+ calls.Add("commands");
+ local.RunPostNetworkCommandPhase();
+ },
+ () => calls.Add("spatial"));
+
+ live.Tick(1f / 60f);
+ Assert.True(local.TryGetPresentationAfterNetwork(out var presentation));
+
+ Assert.Equal(
+ [
+ "objects", "target", "project", "pre-outbound", "f751",
+ "commands", "project", "post-position", "spatial",
+ ],
+ calls);
+ Assert.Single(preNetwork);
+ Assert.Equal(PlayerState.InWorld, preNetwork[0].State);
+ Assert.True(preNetwork[0].Movement.ShouldSendMovementEvent);
+ Assert.Equal([PlayerState.PortalSpace], postNetwork);
+ Assert.Equal(PlayerState.PortalSpace, controller.State);
+ Assert.True(presentation.AdvancedBeforeNetwork);
+ }
+
+ [Fact]
+ public void OwnerCreatedByInboundPass_IsProjectedWithoutPhysicsOrOutboundTick()
+ {
+ PlayerMovementController? controller = null;
+ int projections = 0;
+ int preNetwork = 0;
+ int postNetwork = 0;
+ var local = new RetailLocalPlayerFrameController(
+ canPresentPlayer: () => controller is not null,
+ getController: () => controller,
+ captureInput: () => new MovementInput(Forward: true),
+ resolveLocalEntityId: () => 9u,
+ handleTargeting: () => { },
+ isHidden: () => false,
+ project: (_, _, _) => projections++,
+ sendPreNetwork: (_, _, _) => preNetwork++,
+ sendPostNetwork: (_, _) => postNetwork++);
+
+ local.AdvanceBeforeNetwork(1f / 60f);
+ controller = CreateController();
+ float timeBefore = controller.SimTimeSeconds;
+
+ Assert.True(local.TryGetPresentationAfterNetwork(out var presentation));
+
+ Assert.False(presentation.AdvancedBeforeNetwork);
+ Assert.Equal(timeBefore, controller.SimTimeSeconds);
+ Assert.Equal(1, projections);
+ Assert.Equal(0, preNetwork);
+ Assert.Equal(0, postNetwork);
+ Assert.False(presentation.Movement.ShouldSendMovementEvent);
+ }
+
+ [Fact]
+ public void OrdinaryInboundRootMutation_IsReconciledWithoutSecondPhysicsTick()
+ {
+ PlayerMovementController controller = CreateController();
+ Vector3 projectedRoot = default;
+ var local = new RetailLocalPlayerFrameController(
+ canPresentPlayer: () => true,
+ getController: () => controller,
+ captureInput: () => new MovementInput(),
+ resolveLocalEntityId: () => 7u,
+ handleTargeting: () => { },
+ isHidden: () => false,
+ project: (_, movement, _) => projectedRoot = movement.RenderPosition,
+ sendPreNetwork: (_, _, _) => { },
+ sendPostNetwork: (_, _) => { });
+ var live = new RetailLiveFrameCoordinator(
+ local.AdvanceBeforeNetwork,
+ () => projectedRoot = new Vector3(999f, 999f, 999f),
+ local.RunPostNetworkCommandPhase,
+ () => { });
+
+ live.Tick(1f / 60f);
+ float timeAfterOneTick = controller.SimTimeSeconds;
+
+ Assert.Equal(controller.RenderPosition, projectedRoot);
+ Assert.Equal(1f / 60f, timeAfterOneTick);
+ }
+
+ private static PlayerMovementController CreateController()
+ {
+ var engine = new PhysicsEngine();
+ var heights = new byte[81];
+ Array.Fill(heights, (byte)50);
+ var heightTable = new float[256];
+ for (int i = 0; i < heightTable.Length; i++)
+ heightTable[i] = i;
+
+ engine.AddLandblock(
+ 0xA9B4FFFFu,
+ new TerrainSurface(heights, heightTable),
+ Array.Empty(),
+ Array.Empty(),
+ worldOffsetX: 0f,
+ worldOffsetY: 0f);
+
+ var controller = new PlayerMovementController(engine);
+ controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
+ return controller;
+ }
+}
diff --git a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
new file mode 100644
index 00000000..2340d529
--- /dev/null
+++ b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
@@ -0,0 +1,118 @@
+using AcDream.App.Rendering;
+using AcDream.App.World;
+using AcDream.Content.Vfx;
+using AcDream.Core.Physics;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Options;
+using DatReaderWriter.Types;
+using Xunit.Sdk;
+
+namespace AcDream.App.Tests.World;
+
+public sealed class RecallTeleportAnimationTests
+{
+ private const uint HumanSetup = 0x02000001u;
+ private const uint HumanMotionTable = 0x09000001u;
+ private const uint NonCombat = 0x8000003Du;
+ private const uint Ready = 0x41000003u;
+ private const uint LifestoneRecall = 0x10000153u;
+
+ [Fact]
+ public void LiveFrame_AdvancesObjectRuntimeBeforeInboundNetwork()
+ {
+ var calls = new List();
+ var frame = new RetailLiveFrameCoordinator(
+ _ => calls.Add("objects"),
+ () => calls.Add("network"),
+ () => calls.Add("commands"),
+ () => calls.Add("spatial"));
+
+ frame.Tick(1f / 60f);
+
+ Assert.Equal(["objects", "network", "commands", "spatial"], calls);
+ }
+
+ [Fact]
+ public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch()
+ {
+ var deltas = new List();
+ int networkDispatches = 0;
+ var frame = new RetailLiveFrameCoordinator(
+ deltas.Add,
+ () => networkDispatches++,
+ () => { },
+ () => { });
+
+ frame.Tick(float.NaN);
+ frame.Tick(float.PositiveInfinity);
+ frame.Tick(-1f);
+
+ Assert.Equal([0f, 0f, 0f], deltas);
+ Assert.Equal(3, networkDispatches);
+ Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.NaN));
+ Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.NegativeInfinity));
+ Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.PositiveInfinity));
+ Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.MaxValue));
+ }
+
+ [Fact]
+ public void InstalledRecall_RetiresBeforeTeleportHiddenStateIsDispatched()
+ {
+ string datDir = @"C:\Turbine\Asheron's Call";
+ if (!File.Exists(Path.Combine(datDir, "client_portal.dat")))
+ throw SkipException.ForSkip("Installed retail DATs are required.");
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ Setup setup = Assert.IsType(dats.Get(HumanSetup));
+ MotionTable table = Assert.IsType(dats.Get(HumanMotionTable));
+ var loader = new RetailAnimationLoader(dats);
+ var sequencer = new AnimationSequencer(setup, table, loader);
+ sequencer.SetCycle(NonCombat, Ready);
+ sequencer.PlayAction(LifestoneRecall);
+
+ int readyKey = unchecked((int)((NonCombat << 16) | (Ready & 0xFFFFFFu)));
+ MotionData action = table.Links[readyKey].MotionData[unchecked((int)LifestoneRecall)];
+ double aceDuration = 0.0;
+ foreach (var anim in action.Anims)
+ {
+ Animation loaded = Assert.IsType(loader.LoadAnimation((uint)anim.AnimId));
+ int high = anim.HighFrame == -1 ? loaded.PartFrames.Count : anim.HighFrame;
+ aceDuration += (high - anim.LowFrame) / Math.Abs(anim.Framerate);
+ }
+
+ const float updateStep = 1f / 60f;
+ double elapsed = 0.0;
+ bool hidden = false;
+ bool hiddenPacketReady = false;
+ var frame = new RetailLiveFrameCoordinator(
+ dt =>
+ {
+ if (!hidden)
+ sequencer.Advance(dt);
+ },
+ () =>
+ {
+ if (hiddenPacketReady)
+ hidden = true;
+ },
+ () => { },
+ () => { });
+
+ // The recall motion is dispatched after an object's UseTime phase.
+ // On the first later frame whose timestamp reaches ACE's scheduled
+ // teleport boundary, retail advances the PartArray and only then
+ // consumes the Hidden SetState from the inbound queue.
+ while (elapsed < aceDuration)
+ {
+ hiddenPacketReady = elapsed + updateStep >= aceDuration;
+ frame.Tick(updateStep);
+ elapsed += updateStep;
+ }
+
+ Assert.True(hidden);
+ Assert.True(
+ sequencer.CurrentNodeDiag.IsLooping,
+ "Recall must reach the Ready cyclic tail before Hidden freezes PartArray playback.");
+ }
+}