fix(world): complete recall before teleport hide

Match retail's update ordering so object animation, particles, and scripts advance before inbound teleport state is applied. Separate input-originated movement from post-network autonomous position output, and reconcile presentation without a second physics tick so recall cannot resume after arrival.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-16 06:37:29 +02:00
parent dded9e6b17
commit 75acae02d6
13 changed files with 1068 additions and 332 deletions

View file

@ -206,6 +206,7 @@ src/
LiveEntityRuntime.cs -> canonical identity/state/body/projectile/spatial ownership LiveEntityRuntime.cs -> canonical identity/state/body/projectile/spatial ownership
LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation
LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain
RetailLiveFrameCoordinator.cs -> object/physics-before-network frame barrier
Rendering/ Rendering/
GameWindow.cs -> still owns too much runtime wiring GameWindow.cs -> still owns too much runtime wiring
TerrainModernRenderer.cs -> mandatory bindless+MDI terrain path 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 The destination residency gate supplies retail's `EndTeleportAnimation` edge
once asynchronous streaming is ready. Player placement remains authoritative once asynchronous streaming is ready. Player placement remains authoritative
and separate from presentation; arrival resets camera viewer state but does 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`. `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
## Roadmap Model ## Roadmap Model

View file

@ -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.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. - **✓ 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.** - **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. **Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.

View file

@ -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 chat, panels, toolbar, cursor, and input remain live throughout travel. See
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`. `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) #### (historical M1.5 working notes below)
@ -513,9 +523,9 @@ include dungeons.
zero-time recursive chains fail diagnostically while valid timed weather loops zero-time recursive chains fail diagnostically while valid timed weather loops
continue. The continue. The
live-pose step now publishes exact root/indexed rigid-part transforms after live-pose step now publishes exact root/indexed rigid-part transforms after
animation and equipped-child composition, then drains animation hooks, animation and equipped-child composition, advances existing FP hooks, drains
PhysicsScripts, attached emitters/lights, and particle simulation in fixed animation hooks, refreshes attached emitters/lights, advances particles, then
frame order. World-released particles remain at their birth positions while 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 parent-local particles follow; normal/blocking/anonymous logical emitter
semantics are exact; pending projections skip particle updates/draw while semantics are exact; pending projections skip particle updates/draw while
retaining absolute creation times, the same emitter, and its logical ID, so retaining absolute creation times, the same emitter, and its logical ID, so

View file

@ -844,8 +844,17 @@ Repository links:
4. No client-side impact, explosion, damage, or deletion is synthesized. 4. No client-side impact, explosion, damage, or deletion is synthesized.
5. Presentation frame ordering is fixed: advance animation/root motion, 5. Presentation frame ordering is fixed: advance animation/root motion,
publish indexed parts, compose equipped children, drain animation hooks, publish indexed parts, compose equipped children, drain animation hooks,
tick owner PhysicsScripts, refresh attached emitters/lights, then advance refresh attached emitters/lights, advance particle simulation, tick owner
particle simulation and draw. 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 Retail stages animation hooks inside `CSequence::update`, then executes them
from `CPhysicsObj::process_hooks` before the new root is committed. The same from `CPhysicsObj::process_hooks` before the new root is committed. The same

View file

@ -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 boom outward through the same damped and swept path used after camera
collision. Reusing the source-world viewer is not retail behavior. 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 retail teleport hook does not clear the character's animation sequence.
The arrival presentation is instead hidden by the portal scene and its final Recall completion is instead guaranteed by the enclosing frame order:
world view-plane transition while the normal motion/update stream settles. Therefore acdream
must not add an arrival-only animation reset. ```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 ## 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. strand the transit, while canonical physics retains its own retail gates.
- Destination placement resets the retail chase camera's published and sought - Destination placement resets the retail chase camera's published and sought
positions to the player before the normal update path resumes. 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 - Destination residency remains acdream's asynchronous adaptation. It supplies
the same `EndTeleportAnimation` edge retail receives when its blocking cell the same `EndTeleportAnimation` edge retail receives when its blocking cell
load completes; it does not alter presentation ordering. load completes; it does not alter presentation ordering.

View file

@ -0,0 +1,209 @@
using System.Numerics;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.App.Input;
/// <summary>
/// Serializes input-originated output from the object-phase result and the
/// periodic position report from post-inbound controller state.
/// </summary>
public sealed class LocalPlayerOutboundController
{
private readonly Func<float, Quaternion> _toWireRotation;
private readonly Action<string, uint, MovementResult, Vector3, uint, byte> _diagnostic;
public LocalPlayerOutboundController(
Func<float, Quaternion> toWireRotation,
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
{
_toWireRotation = toWireRotation
?? throw new ArgumentNullException(nameof(toWireRotation));
_diagnostic = diagnostic
?? throw new ArgumentNullException(nameof(diagnostic));
}
/// <summary>
/// Sends input-originated movement and jump packets before inbound
/// dispatch. Retail emits these from input/object processing rather than
/// from <c>CommandInterpreter::UseTime</c>.
/// </summary>
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);
}
}
/// <summary>
/// Ports the position-send slot in <c>CommandInterpreter::UseTime</c>
/// (<c>0x006B3BF0</c>), which SmartBox calls after draining inbound
/// events. The predicate and serialized frame therefore observe any
/// accepted ForcePosition or teleport state from this update.
/// </summary>
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,
};
}
}

View file

@ -0,0 +1,68 @@
using AcDream.Core.World;
namespace AcDream.App.Input;
/// <summary>
/// Projects the canonical local physics body into world rendering and spatial
/// buckets without advancing it.
/// </summary>
public sealed class LocalPlayerProjectionController
{
private readonly Func<WorldEntity?> _resolveEntity;
private readonly Func<int> _liveCenterX;
private readonly Func<int> _liveCenterY;
private readonly Action<WorldEntity, uint> _syncShadow;
private readonly Action<uint, uint> _rebucket;
public LocalPlayerProjectionController(
Func<WorldEntity?> resolveEntity,
Func<int> liveCenterX,
Func<int> liveCenterY,
Action<WorldEntity, uint> syncShadow,
Action<uint, uint> 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);
}
}

View file

@ -916,6 +916,21 @@ public sealed class PlayerMovementController
IsMouseLookMovementEvent: mouseLookEvent); IsMouseLookMovementEvent: mouseLookEvent);
} }
/// <summary>
/// Captures the current controller state for presentation without
/// advancing physics or requesting an outbound movement event.
/// </summary>
public MovementResult CapturePresentationResult()
{
MovementResult current = CaptureMovementResult(mouseLookEvent: false);
return current with
{
MotionStateChanged = false,
ShouldSendMovementEvent = false,
IsMouseLookMovementEvent = false,
};
}
/// <summary> /// <summary>
/// Retail <c>CommandInterpreter::SendPositionEvent</c> (0x006B4770) /// Retail <c>CommandInterpreter::SendPositionEvent</c> (0x006B4770)
/// stamps the send time, complete cell-local frame (origin and /// stamps the send time, complete cell-local frame (origin and

View file

@ -0,0 +1,155 @@
namespace AcDream.App.Input;
/// <summary>
/// Owns the local player's once-per-frame retail object phase.
/// </summary>
/// <remarks>
/// Retail advances the player and sends input-originated movement/jump output
/// before <c>SmartBox::UseTime</c> drains inbound events. Its later
/// <c>CommandInterpreter::UseTime</c> 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.
/// </remarks>
public sealed class RetailLocalPlayerFrameController
{
private readonly Func<bool> _canPresentPlayer;
private readonly Func<PlayerMovementController?> _getController;
private readonly Func<MovementInput> _captureInput;
private readonly Func<uint> _resolveLocalEntityId;
private readonly Action _handleTargeting;
private readonly Func<bool> _isHidden;
private readonly Action<PlayerMovementController, MovementResult, bool> _project;
private readonly Action<PlayerMovementController, MovementResult, bool> _sendPreNetwork;
private readonly Action<PlayerMovementController, bool> _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<bool> canPresentPlayer,
Func<PlayerMovementController?> getController,
Func<MovementInput> captureInput,
Func<uint> resolveLocalEntityId,
Action handleTargeting,
Func<bool> isHidden,
Action<PlayerMovementController, MovementResult, bool> project,
Action<PlayerMovementController, MovementResult, bool> sendPreNetwork,
Action<PlayerMovementController, bool> 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));
}
/// <summary>
/// Advances the existing local object exactly once on the object side of
/// the inbound-network barrier.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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,
};
}

View file

@ -163,6 +163,10 @@ public sealed class GameWindow : IDisposable
// once the injected shared helpers exist as method groups (GetSetupCylinder, // once the injected shared helpers exist as method groups (GetSetupCylinder,
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater. // ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
private readonly AcDream.App.Physics.RemotePhysicsUpdater _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; private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map; // Step 7 projectile presentation. The controller owns no identity map;
// each runtime component is stored on the canonical LiveEntityRecord. // 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. // handler) stay on GameWindow and are injected as delegates.
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater( _remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
_physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle); _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() public void Run()
@ -7066,6 +7115,7 @@ public sealed class GameWindow : IDisposable
// camera path re-extends the boom. Updating directly from the stale // camera path re-extends the boom. Updating directly from the stale
// source-world viewer made the destination bend across the reveal. // source-world viewer made the destination bend across the reveal.
_retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw); _retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw);
ReconcileLiveObjectSpatialPresentation();
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}"); "PLACED", resolved.CellId, $"forced={forced}");
@ -8764,11 +8814,15 @@ public sealed class GameWindow : IDisposable
{ {
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update); 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 // Retail ScriptManager::AddScriptInternal (0x0051B310) stamps
// Timer::cur_time at the packet/default-script call site. Publish this // Timer::cur_time at the packet/default-script call site. Publish this
// update frame's clock before streaming and network dispatch, then // update frame's clock before streaming and network dispatch, then
// reuse the same timestamp for animation hooks and the later drain. // reuse the same timestamp for animation hooks and the later drain.
_physicsScriptGameTime += Math.Max(0.0, dt); _physicsScriptGameTime += frameSeconds;
_scriptRunner?.PublishTime(_physicsScriptGameTime); _scriptRunner?.PublishTime(_physicsScriptGameTime);
// [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick; // [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 // Drain pending live-session traffic AFTER streaming so any incoming
// CreateObject events find their landblock already loaded in // CreateObject events find their landblock already loaded in
// GpuWorldState. Non-blocking — returns immediately if no datagrams // GpuWorldState. Non-blocking — returns immediately if no datagrams
// are in the kernel buffer. Fires EntitySpawned events synchronously. // are in the kernel buffer. Fires EntitySpawned events synchronously.
// Step 2: routed through the controller; functionally identical. // 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 // Usually F751 activates immediately. This no-op convergence check
// covers the session edge where the canonical player exists before // covers the session edge where the canonical player exists before
@ -8980,7 +9086,7 @@ public sealed class GameWindow : IDisposable
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell); bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
if (haveDest && !ready) if (haveDest && !ready)
{ {
_teleportHoldSeconds += (float)dt; _teleportHoldSeconds += frameDelta;
if (_teleportHoldSeconds >= TeleportMaxHoldSeconds) if (_teleportHoldSeconds >= TeleportMaxHoldSeconds)
{ {
ready = true; ready = true;
@ -8989,7 +9095,7 @@ public sealed class GameWindow : IDisposable
} }
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0; 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); _teleportViewPlane.Update(snap);
foreach (var e in evts) 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 // Phase K.1a — tick the input dispatcher so Hold-type bindings
// re-fire while their chord is held. K.1b adds the subscribers // re-fire while their chord is held. K.1b adds the subscribers
// that actually consume the events. // that actually consume the events.
_inputDispatcher?.Tick();
_combatAttackController?.Tick();
// Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse // Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse
// on selection, then revert). No-op when nothing is flashing. // on selection, then revert). No-op when nothing is flashing.
// Retained panel-local timers advance through RetailUiRuntime.Tick on the draw seam. // 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 // Phase K.2 — auto-enter player mode at login. The guard
// returns true on the firing tick (one-shot); subsequent ticks // returns true on the firing tick (one-shot); subsequent ticks
// are no-ops. Skipped offline (no _liveSession → IsLiveInWorld // are no-ops. Skipped offline (no _liveSession → IsLiveInWorld
@ -9116,7 +9176,7 @@ public sealed class GameWindow : IDisposable
// delta drives fly heading instead). // delta drives fly heading instead).
if (_inputDispatcher is null) return; if (_inputDispatcher is null) return;
_cameraController.Fly.Update( _cameraController.Fly.Update(
dt, frameSeconds,
w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward), w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward),
a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft), a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft),
s: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup), 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. // retail chase is selected; legacy camera ignores these.
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null) 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)) if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomIn))
_retailChaseCamera.AdjustDistance(-adj); _retailChaseCamera.AdjustDistance(-adj);
if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomOut)) 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 // until Q is pressed again. Handled in OnInputAction; here
// we just OR _autoRunActive into the Forward flag. // we just OR _autoRunActive into the Forward flag.
// * Mouse never drives character yaw (K.1b regression-prevention). // * 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 AcDream.App.Input.MovementResult result = playerFrame.Movement;
// LocalEntityId in sync with the live local player entity so bool localPlayerHidden = playerFrame.Hidden;
// FindObjCollisions skips its own ShadowEntry. Re-fetched per if (!playerFrame.AdvancedBeforeNetwork)
// 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))
{ {
pe.SetPosition(result.RenderPosition); // A.5 T18: SetPosition propagates AabbDirty // The player was materialized by this frame's inbound pass.
pe.ParentCellId = result.CellId; // Its non-advancing initial projection must publish child and
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( // effect anchors before the first draw too.
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f); ReconcileLiveObjectSpatialPresentation();
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);
} }
// Update chase camera(s). The CameraController exposes whichever // Update chase camera(s). The CameraController exposes whichever
@ -9249,7 +9244,7 @@ public sealed class GameWindow : IDisposable
// low-stiffness damping). // low-stiffness damping).
_chaseCamera.Update(result.RenderPosition, _playerController.Yaw, _chaseCamera.Update(result.RenderPosition, _playerController.Yaw,
isOnGround: result.IsOnGround, isOnGround: result.IsOnGround,
dt: (float)dt); dt: frameDelta);
// RetailChaseCamera: heading is the player's facing direction // RetailChaseCamera: heading is the player's facing direction
// projected onto the contact plane when grounded, or the // projected onto the contact plane when grounded, or the
// world XY plane when airborne. The contact plane normal // world XY plane when airborne. The contact plane normal
@ -9263,98 +9258,26 @@ public sealed class GameWindow : IDisposable
playerVelocity: _playerController.BodyVelocity, playerVelocity: _playerController.BodyVelocity,
isOnGround: result.IsOnGround, isOnGround: result.IsOnGround,
contactPlaneNormal: _playerController.ContactPlane.Normal, contactPlaneNormal: _playerController.ContactPlane.Normal,
dt: (float)dt, dt: frameDelta,
cellId: _playerController.CellId, cellId: _playerController.CellId,
selfEntityId: _playerController.LocalEntityId, selfEntityId: _playerController.LocalEntityId,
trackedTargetPoint: trackedCombatTarget); 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. // 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( private void DumpMovementTruthOutbound(
string kind, string kind,
uint sequence, uint sequence,
@ -9589,40 +9512,6 @@ public sealed class GameWindow : IDisposable
if (DevToolsEnabled && _imguiBootstrap is not null) if (DevToolsEnabled && _imguiBootstrap is not null)
_imguiBootstrap.BeginFrame((float)deltaSeconds); _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 // Phase G.1: weather state machine — deterministic per-day roll
// + transitions + lightning flash. // + transitions + lightning flash.
var cal = WorldTime.CurrentCalendar; var cal = WorldTime.CurrentCalendar;
@ -9784,12 +9673,6 @@ public sealed class GameWindow : IDisposable
// debug-only and disabled for normal retail rendering. // debug-only and disabled for normal retail rendering.
if (_options.EnableSkyPesDebug) if (_options.EnableSkyPesDebug)
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell); 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 // Phase G.1/G.2: feed the sun, tick LightManager, build + upload
// the scene-lighting UBO once per frame. Every shader that // the scene-lighting UBO once per frame. Every shader that
// consumes binding=1 reads the same data for the rest of the // 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))); ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)));
} }
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary> /// <summary>
/// Phase 6.4: advance every animated entity's frame counter by /// Phase 6.4: advance every animated entity's frame counter by
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's /// <paramref name="dt"/> * Framerate, wrapping around the cycle's
@ -12921,79 +12869,14 @@ public sealed class GameWindow : IDisposable
} }
internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState( internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState(
AcDream.App.Input.MovementResult result) AcDream.App.Input.MovementResult result) =>
{ AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(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,
};
}
private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result) private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result)
{ => _localPlayerOutbound.TrySendMovement(
if (_liveSession is not { } session _liveSession,
|| _playerController is not { } controller) _playerController,
return false; result);
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;
}
private uint? GetSelectedOrClosestCombatTarget() private uint? GetSelectedOrClosestCombatTarget()
{ {

View file

@ -0,0 +1,53 @@
namespace AcDream.App.World;
/// <summary>
/// Owns retail's live-object, inbound-dispatch, and command-interpreter frame
/// phases.
/// </summary>
/// <remarks>
/// Retail <c>SmartBox::UseTime</c> (<c>0x00455410</c>) advances
/// <c>CObjectMaint::UseTime</c> and <c>CPhysics::UseTime</c> before draining
/// <c>SmartBox::in_queue</c>, then calls <c>CommandInterpreter::UseTime</c>.
/// 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.
/// </remarks>
public sealed class RetailLiveFrameCoordinator
{
private readonly Action<float> _advanceObjectRuntime;
private readonly Action _dispatchInboundNetwork;
private readonly Action _runPostNetworkCommands;
private readonly Action _reconcileSpatialPresentation;
public RetailLiveFrameCoordinator(
Action<float> 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;
}

View file

@ -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<string>();
var preNetwork = new List<(PlayerState State, MovementResult Movement)>();
var postNetwork = new List<PlayerState>();
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<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
return controller;
}
}

View file

@ -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<string>();
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<float>();
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<Setup>(dats.Get<Setup>(HumanSetup));
MotionTable table = Assert.IsType<MotionTable>(dats.Get<MotionTable>(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<Animation>(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.");
}
}