diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md
index 8388e1d8..4b2af346 100644
--- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md
+++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md
@@ -425,6 +425,20 @@ one named legacy owner only; D/F must remove the exception before final cutover.
inbound delete/recreate generation safety;
- preserve the completed portal visuals and world-reveal barrier unchanged.
+**Completed 2026-07-22.** `LocalPlayerTeleportController` now owns the full
+local transit lifetime and its immutable network handoff, while
+`PlayerModeController` is the sole writer of controller, physics-host,
+player-mode, and chase-camera slots. Local animation, shadow synchronization,
+sealed-dungeon classification, viewport aspect, and approach-completion
+handoff are focused collaborators rather than window callbacks. Portal entry
+retires the one-shot auto-entry guard, host publication is transactional, and
+teleport work revalidates the live generation after every reentrant edge.
+Focused App/Core tests cover destination readiness, forced holds, same-cell
+placement, LoginComplete order, reset/disposal, GUID reuse, reentrancy,
+host rollback, auto-entry idempotence, and completion-mailbox ordering. The
+full App suite is green at 2,814 passed / 3 skipped. `GameWindow.cs` is 7,351
+lines at this checkpoint, down from the slice baseline of 15,723.
+
### F/G — camera, cutover, and deletion
- extract player-mode enter/exit/toggle and current-owner state before camera
diff --git a/src/AcDream.App/Input/GameplayInputFrameController.cs b/src/AcDream.App/Input/GameplayInputFrameController.cs
index 35bf16c6..aad93671 100644
--- a/src/AcDream.App/Input/GameplayInputFrameController.cs
+++ b/src/AcDream.App/Input/GameplayInputFrameController.cs
@@ -31,7 +31,9 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
/// Owns the complete pre-object input frame: held semantic dispatch, one raw
/// mouse-look sample, and combat attack intent.
///
-internal sealed class GameplayInputFrameController : IGameplayInputFramePhase
+internal sealed class GameplayInputFrameController
+ : IGameplayInputFramePhase,
+ AcDream.App.Streaming.ILocalPlayerTeleportInputLifetime
{
private readonly InputDispatcher? _dispatcher;
private readonly DispatcherMovementInputSource _movement;
diff --git a/src/AcDream.App/Input/LocalPlayerAnimationController.cs b/src/AcDream.App/Input/LocalPlayerAnimationController.cs
new file mode 100644
index 00000000..c9a09d7a
--- /dev/null
+++ b/src/AcDream.App/Input/LocalPlayerAnimationController.cs
@@ -0,0 +1,75 @@
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Vfx;
+using AcDream.App.World;
+using AcDream.Core.Physics.Motion;
+using DatReaderWriter.Types;
+
+namespace AcDream.App.Input;
+
+///
+/// Owns the local player's sequencer root-motion and deferred-hook seams.
+/// The movement controller borrows these methods when player mode is entered;
+/// no animation lifetime is stored in the window host.
+///
+internal sealed class LocalPlayerAnimationController
+{
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly ILocalPlayerIdentitySource _identity;
+ private readonly LiveEntityAnimationRuntimeView _animations;
+ private readonly LiveEntityAnimationPresenter _presenter;
+ private readonly AnimationHookFrameQueue _hooks;
+
+ public LocalPlayerAnimationController(
+ LiveEntityRuntime liveEntities,
+ ILocalPlayerIdentitySource identity,
+ LiveEntityAnimationRuntimeView animations,
+ LiveEntityAnimationPresenter presenter,
+ AnimationHookFrameQueue hooks)
+ {
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _identity = identity ?? throw new ArgumentNullException(nameof(identity));
+ _animations = animations ?? throw new ArgumentNullException(nameof(animations));
+ _presenter = presenter ?? throw new ArgumentNullException(nameof(presenter));
+ _hooks = hooks ?? throw new ArgumentNullException(nameof(hooks));
+ }
+
+ public void AdvanceRoot(float deltaSeconds, MotionDeltaFrame output)
+ {
+ ArgumentNullException.ThrowIfNull(output);
+ output.Reset();
+
+ uint playerGuid = _identity.ServerGuid;
+ if (!_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity)
+ || !_animations.TryGetValue(entity.Id, out var animation)
+ || animation.Sequencer is not { } sequencer
+ || !_liveEntities.ShouldAdvanceRootRuntime(playerGuid)
+ || _liveEntities.IsHidden(playerGuid))
+ {
+ return;
+ }
+
+ if (_liveEntities.TryGetRecord(playerGuid, out LiveEntityRecord record))
+ _presenter.PrepareAnimation(record, animation);
+
+ Frame rootFrame = animation.RootMotionScratch;
+ rootFrame.Origin = System.Numerics.Vector3.Zero;
+ rootFrame.Orientation = System.Numerics.Quaternion.Identity;
+ animation.PreparedSequenceFrames = animation.CaptureSequenceFrames(
+ sequencer.Advance(deltaSeconds, rootFrame));
+ animation.SequenceAdvancedBeforeAnimationPass = true;
+
+ output.Origin = rootFrame.Origin;
+ output.Orientation = rootFrame.Orientation;
+ }
+
+ public void CaptureHooks()
+ {
+ uint playerGuid = _identity.ServerGuid;
+ if (_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity)
+ && _animations.TryGetValue(entity.Id, out var animation)
+ && animation.Sequencer is { } sequencer)
+ {
+ _hooks.Capture(animation.Entity.Id, sequencer);
+ }
+ }
+}
diff --git a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
index 57c4236c..14e10965 100644
--- a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
+++ b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
@@ -1,4 +1,5 @@
using AcDream.Core.Physics;
+using AcDream.App.Physics;
namespace AcDream.App.Input;
@@ -29,6 +30,20 @@ internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource
public PlayerMovementController? Controller { get; set; }
}
+internal interface ILocalPlayerPhysicsHostSource
+{
+ EntityPhysicsHost? Host { get; }
+}
+
+///
+/// The one mutable local physics-host slot. Player-mode lifecycle owns
+/// assignment; teleport and update owners receive the read-only seam.
+///
+internal sealed class LocalPlayerPhysicsHostSlot : ILocalPlayerPhysicsHostSource
+{
+ public EntityPhysicsHost? Host { get; set; }
+}
+
internal interface ILocalPlayerModeSource
{
bool IsPlayerMode { get; }
@@ -50,3 +65,59 @@ internal sealed class LocalPlayerModeState : ILocalPlayerModeSource
ChaseModeEverEntered = false;
}
}
+
+///
+/// Session-scoped server-authoritative movement skills. Player-description
+/// delivery and player-mode construction share this owner so rebuilding the
+/// local physics controller cannot fall back to stale defaults.
+///
+internal sealed class LocalPlayerSkillState
+{
+ public int RunSkill { get; private set; } = -1;
+ public int JumpSkill { get; private set; } = -1;
+ public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0;
+
+ public void Update(
+ int runSkill,
+ int jumpSkill,
+ PlayerMovementController? controller)
+ {
+ if (runSkill >= 0)
+ RunSkill = runSkill;
+ if (jumpSkill >= 0)
+ JumpSkill = jumpSkill;
+ ApplyTo(controller);
+ }
+
+ public bool ApplyTo(PlayerMovementController? controller)
+ {
+ if (controller is null || !IsComplete)
+ return false;
+
+ controller.SetCharacterSkills(RunSkill, JumpSkill);
+ return true;
+ }
+
+ public void ResetSession()
+ {
+ RunSkill = -1;
+ JumpSkill = -1;
+ }
+}
+
+internal interface IViewportAspectSource
+{
+ float Aspect { get; }
+}
+
+/// Framebuffer aspect published by the window host.
+internal sealed class ViewportAspectState : IViewportAspectSource
+{
+ public float Aspect { get; private set; } = 16f / 9f;
+
+ public void Update(int width, int height)
+ {
+ if (width > 0 && height > 0)
+ Aspect = width / (float)height;
+ }
+}
diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs
index 2895bee2..a23b20cc 100644
--- a/src/AcDream.App/Input/PlayerModeAutoEntry.cs
+++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs
@@ -45,6 +45,7 @@ public sealed class PlayerModeAutoEntry
private readonly Func _isPlayerEntityPresent;
private readonly Func _isPlayerControllerReady;
private readonly Func _isWorldReady;
+ private readonly Func _isPlayerModeActive;
private readonly Action _enterPlayerMode;
private bool _armed;
@@ -76,13 +77,15 @@ public sealed class PlayerModeAutoEntry
Func isPlayerEntityPresent,
Func isPlayerControllerReady,
Func isWorldReady,
- Action enterPlayerMode)
+ Action enterPlayerMode,
+ Func? isPlayerModeActive = null)
{
_isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld));
_isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent));
_isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady));
_isWorldReady = isWorldReady ?? throw new ArgumentNullException(nameof(isWorldReady));
_enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode));
+ _isPlayerModeActive = isPlayerModeActive ?? (() => false);
}
/// True iff would still fire if the
@@ -112,6 +115,11 @@ public sealed class PlayerModeAutoEntry
public bool TryEnter()
{
if (!_armed) return false;
+ if (_isPlayerModeActive())
+ {
+ _armed = false;
+ return false;
+ }
if (!_isLiveInWorld()) return false;
if (!_isPlayerEntityPresent()) return false;
if (!_isPlayerControllerReady()) return false;
diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs
new file mode 100644
index 00000000..126f29da
--- /dev/null
+++ b/src/AcDream.App/Input/PlayerModeController.cs
@@ -0,0 +1,572 @@
+using System.Numerics;
+using AcDream.App.Interaction;
+using AcDream.App.Net;
+using AcDream.App.Physics;
+using AcDream.App.Rendering;
+using AcDream.App.Streaming;
+using AcDream.App.World;
+using AcDream.Content;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using AcDream.Core.World;
+
+namespace AcDream.App.Input;
+
+///
+/// Sole writer for local player-mode, movement-controller, physics-host, and
+/// chase-camera lifetimes. The window host wires this owner but never mutates
+/// those slots directly.
+///
+internal sealed class PlayerModeController : ILocalPlayerTeleportModeOperations
+{
+ private readonly LocalPlayerModeState _mode;
+ private readonly LocalPlayerControllerSlot _controllerSlot;
+ private readonly LocalPlayerPhysicsHostSlot _hostSlot;
+ private readonly ChaseCameraInputState _chase;
+ private readonly CameraController _camera;
+ private readonly PhysicsEngine _physics;
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly ILocalPlayerIdentitySource _identity;
+ private readonly LiveWorldOriginState _origin;
+ private readonly ILiveEntityMotionRuntimeBindings _motionBindings;
+ private readonly IDatReaderWriter _dats;
+ private readonly object _datLock;
+ private readonly PhysicsDataCache _physicsDataCache;
+ private readonly LiveEntityAnimationRuntimeView _animations;
+ private readonly LocalPlayerAnimationController _animation;
+ private readonly LocalPlayerShadowSynchronizer _shadow;
+ private readonly IPlayerApproachCompletionLifetimeOwner _approachCompletions;
+ private readonly ILocalPlayerTeleportInputLifetime _input;
+ private readonly ILiveInWorldSource _session;
+ private readonly MovementTruthDiagnosticController _movementDiagnostics;
+ private readonly LocalPlayerSkillState _skills;
+ private readonly IViewportAspectSource _viewport;
+ private PlayerModeAutoEntry? _autoEntry;
+ private IPlayerApproachCompletionSink? _approachLifetime;
+
+ public PlayerModeController(
+ LocalPlayerModeState mode,
+ LocalPlayerControllerSlot controllerSlot,
+ LocalPlayerPhysicsHostSlot hostSlot,
+ ChaseCameraInputState chase,
+ CameraController camera,
+ PhysicsEngine physics,
+ LiveEntityRuntime liveEntities,
+ ILocalPlayerIdentitySource identity,
+ LiveWorldOriginState origin,
+ ILiveEntityMotionRuntimeBindings motionBindings,
+ IDatReaderWriter dats,
+ object datLock,
+ PhysicsDataCache physicsDataCache,
+ LiveEntityAnimationRuntimeView animations,
+ LocalPlayerAnimationController animation,
+ LocalPlayerShadowSynchronizer shadow,
+ IPlayerApproachCompletionLifetimeOwner approachCompletions,
+ ILocalPlayerTeleportInputLifetime input,
+ ILiveInWorldSource session,
+ MovementTruthDiagnosticController movementDiagnostics,
+ LocalPlayerSkillState skills,
+ IViewportAspectSource viewport)
+ {
+ _mode = mode ?? throw new ArgumentNullException(nameof(mode));
+ _controllerSlot = controllerSlot ?? throw new ArgumentNullException(nameof(controllerSlot));
+ _hostSlot = hostSlot ?? throw new ArgumentNullException(nameof(hostSlot));
+ _chase = chase ?? throw new ArgumentNullException(nameof(chase));
+ _camera = camera ?? throw new ArgumentNullException(nameof(camera));
+ _physics = physics ?? throw new ArgumentNullException(nameof(physics));
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _identity = identity ?? throw new ArgumentNullException(nameof(identity));
+ _origin = origin ?? throw new ArgumentNullException(nameof(origin));
+ _motionBindings = motionBindings ?? throw new ArgumentNullException(nameof(motionBindings));
+ _dats = dats ?? throw new ArgumentNullException(nameof(dats));
+ _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
+ _physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache));
+ _animations = animations ?? throw new ArgumentNullException(nameof(animations));
+ _animation = animation ?? throw new ArgumentNullException(nameof(animation));
+ _shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
+ _approachCompletions = approachCompletions
+ ?? throw new ArgumentNullException(nameof(approachCompletions));
+ _input = input ?? throw new ArgumentNullException(nameof(input));
+ _session = session ?? throw new ArgumentNullException(nameof(session));
+ _movementDiagnostics = movementDiagnostics
+ ?? throw new ArgumentNullException(nameof(movementDiagnostics));
+ _skills = skills ?? throw new ArgumentNullException(nameof(skills));
+ _viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
+ }
+
+ public PlayerMovementController? Controller => _controllerSlot.Controller;
+ public Matrix4x4 Projection => _camera.Active.Projection;
+
+ public void BindAutoEntry(PlayerModeAutoEntry autoEntry)
+ {
+ ArgumentNullException.ThrowIfNull(autoEntry);
+ if (_autoEntry is not null)
+ throw new InvalidOperationException("Player-mode auto-entry is already bound.");
+ _autoEntry = autoEntry;
+ }
+
+ public void Toggle()
+ {
+ if (!_session.IsInWorld)
+ return;
+
+ _autoEntry?.Cancel();
+ if (_mode.IsPlayerMode)
+ Exit();
+ else
+ _ = TryEnter("Tab");
+ }
+
+ public void EnterFromAutoEntry()
+ {
+ if (TryEnter("auto-entry"))
+ {
+ Console.WriteLine(
+ $"live: auto-entered player mode for 0x{_identity.ServerGuid:X8}");
+ }
+ }
+
+ public bool TryEnterPortalSpace()
+ {
+ // Portal activation supersedes the login-only guard. Otherwise the
+ // later auto-entry tick can rebuild this just-created controller and
+ // replace PortalSpace with the default InWorld state.
+ _autoEntry?.Cancel();
+ if (Controller is null && !TryEnter("teleport"))
+ return false;
+
+ if (Controller is not { } controller)
+ return false;
+
+ controller.State = PlayerState.PortalSpace;
+ return true;
+ }
+
+ public void EnterWorld()
+ {
+ if (Controller is { } controller)
+ controller.State = PlayerState.InWorld;
+ }
+
+ public void Exit()
+ {
+ var failures = new List();
+ try { _input.EndMouseLook(); }
+ catch (Exception error) { failures.Add(error); }
+ try { _camera.ExitChaseMode(); }
+ catch (Exception error) { failures.Add(error); }
+ try { RetireApproachLifetime(); }
+ catch (Exception error) { failures.Add(error); }
+ _mode.IsPlayerMode = false;
+ _controllerSlot.Controller = null;
+ _hostSlot.Host = null;
+ _chase.Legacy = null;
+ _chase.Retail = null;
+
+ if (failures.Count != 0)
+ throw new AggregateException("Player-mode exit was incomplete.", failures);
+ }
+
+ public void ToggleFlyOrChase()
+ {
+ _autoEntry?.Cancel();
+ if (_camera.IsFlyMode
+ && _mode.IsPlayerMode
+ && _chase.Legacy is { } legacy)
+ {
+ _chase.Retail ??= new RetailChaseCamera
+ {
+ Aspect = legacy.Aspect,
+ CollisionProbe = new PhysicsCameraCollisionProbe(_physics),
+ };
+ _camera.EnterChaseMode(legacy, _chase.Retail);
+ return;
+ }
+
+ _camera.ToggleFly();
+ }
+
+ public void ResetSession()
+ {
+ _autoEntry?.Cancel();
+ var failures = new List();
+ try { _camera.ExitChaseMode(); }
+ catch (Exception error) { failures.Add(error); }
+ try { RetireApproachLifetime(); }
+ catch (Exception error) { failures.Add(error); }
+ _mode.ResetSession();
+ _controllerSlot.Controller = null;
+ _hostSlot.Host = null;
+ _chase.Legacy = null;
+ _chase.Retail = null;
+ try { _movementDiagnostics.ResetSession(); }
+ catch (Exception error) { failures.Add(error); }
+ try { _shadow.ResetSession(); }
+ catch (Exception error) { failures.Add(error); }
+
+ if (failures.Count != 0)
+ throw new AggregateException("Player-mode session reset was incomplete.", failures);
+ }
+
+ private bool TryEnter(string loggingTag)
+ {
+ uint playerGuid = _identity.ServerGuid;
+ if (!_liveEntities.MaterializedWorldEntities.TryGetValue(
+ playerGuid,
+ out WorldEntity? playerEntity))
+ {
+ Console.WriteLine(
+ $"live: {loggingTag} — player entity 0x{playerGuid:X8} not found yet");
+ return false;
+ }
+
+ if (!_liveEntities.TryGetRecord(playerGuid, out LiveEntityRecord playerRecord))
+ {
+ Console.WriteLine(
+ $"live: {loggingTag} — player record 0x{playerGuid:X8} not found yet");
+ return false;
+ }
+
+ BuildControllerAndCamera(
+ loggingTag,
+ playerGuid,
+ playerEntity,
+ playerRecord);
+ return true;
+ }
+
+ private void BuildControllerAndCamera(
+ string loggingTag,
+ uint playerGuid,
+ WorldEntity playerEntity,
+ LiveEntityRecord playerRecord)
+ {
+ IPlayerApproachCompletionSink approachLifetime =
+ _approachCompletions.BeginControllerLifetime();
+ bool lifetimeCommitted = false;
+ bool cameraAttempted = false;
+ bool shadowAttempted = false;
+ CameraController.CameraState priorCamera = _camera.CaptureState();
+ LocalPlayerShadowState.Snapshot? priorShadow = _shadow.Capture();
+ try
+ {
+ var controller = new PlayerMovementController(
+ _physics,
+ playerRecord.ObjectClock);
+ controller.ApplyPhysicsState(playerRecord.FinalPhysicsState);
+
+ // Retail MovementManager::MakeMoveToManager @ 0x00524000 creates one
+ // MoveToManager facade over the local CPhysicsObj seams.
+ PlayerMovementController capturedController = controller;
+ EntityPhysicsHost playerHost = null!;
+ controller.Movement.MoveToFactory = () =>
+ {
+ var moveTo = new MoveToManager(
+ capturedController.Motion,
+ stopCompletely: () =>
+ capturedController.StopCompletelyAtPhysicsObjectBoundary(),
+ getPosition: () => new Position(
+ capturedController.CellId,
+ capturedController.Position,
+ capturedController.BodyOrientation),
+ getHeading: () => MoveToMath.HeadingFromYaw(capturedController.Yaw),
+ setHeading: (heading, _) => capturedController.Yaw =
+ MoveToMath.YawFromHeading(heading),
+ getOwnRadius: () => _motionBindings.GetSetupCylinder(
+ playerGuid,
+ playerEntity).Radius,
+ getOwnHeight: () => _motionBindings.GetSetupCylinder(
+ playerGuid,
+ playerEntity).Height,
+ contact: () => capturedController.BodyInContact,
+ isInterpolating: () => false,
+ getVelocity: () => capturedController.BodyVelocity,
+ getSelfId: () => playerGuid,
+ setTarget: (context, target, radius, quantum) =>
+ playerHost.SetTarget(context, target, radius, quantum),
+ clearTarget: playerHost.ClearTarget,
+ getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(),
+ setTargetQuantum: playerHost.TargetManager.SetTargetQuantum,
+ curTime: () => capturedController.SimTimeSeconds);
+
+ moveTo.MoveToComplete = error =>
+ {
+ if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ Console.WriteLine($"[autowalk-end] reason=complete err={error}");
+ if (error == WeenieError.None)
+ approachLifetime.PublishNaturalCompletion();
+ else
+ approachLifetime.PublishCancellation(error);
+ };
+ moveTo.MoveToCancelled = error =>
+ approachLifetime.PublishCancellation(error);
+ moveTo.StickTo = (target, radius, height) =>
+ playerHost.PositionManager.StickTo(target, radius, height);
+ moveTo.Unstick = () => playerHost.PositionManager.UnStick();
+ return moveTo;
+ };
+
+ MovementManager exactMovement = controller.Movement;
+ var configuredHost = new EntityPhysicsHost(
+ playerGuid,
+ getPosition: () => new Position(
+ playerRecord.FullCellId,
+ playerRecord.WorldEntity?.Position ?? capturedController.Position,
+ capturedController.BodyOrientation),
+ getVelocity: () => capturedController.BodyVelocity,
+ getRadius: () => _motionBindings.GetSetupCylinder(
+ playerGuid,
+ playerEntity).Radius,
+ inContact: () => capturedController.BodyInContact,
+ minterpMaxSpeed: () => capturedController.Motion.GetMaxSpeed(),
+ curTime: () => capturedController.SimTimeSeconds,
+ physicsTimerTime: () => capturedController.SimTimeSeconds,
+ getObjectA: _motionBindings.ResolvePhysicsHost,
+ handleUpdateTarget: info =>
+ {
+ if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ {
+ Console.WriteLine(
+ $"[autowalk-target] object=0x{info.ObjectId:X8} "
+ + $"status={info.Status} context={info.ContextId} "
+ + $"target=({info.TargetPosition.Frame.Origin.X:F2},"
+ + $"{info.TargetPosition.Frame.Origin.Y:F2},"
+ + $"{info.TargetPosition.Frame.Origin.Z:F2})");
+ }
+ exactMovement.HandleUpdateTarget(info);
+ },
+ interruptCurrentMovement: () => exactMovement.CancelMoveTo(
+ WeenieError.ActionCancelled));
+ playerHost = EntityPhysicsHost.SelectStableHostWithoutRebind(
+ _liveEntities,
+ playerRecord,
+ configuredHost);
+
+ exactMovement.MakeMoveToManager();
+ controller.Motion.UnstickFromObject = () =>
+ playerHost.PositionManager.UnStick();
+ controller.PositionManager = playerHost.PositionManager;
+ controller.Motion.InterruptCurrentMovement = () =>
+ {
+ if (PhysicsDiagnostics.ProbeAutoWalkEnabled
+ && exactMovement.IsMovingTo())
+ {
+ Console.WriteLine("[autowalk-end] reason=interrupt");
+ }
+ exactMovement.CancelMoveTo(WeenieError.ActionCancelled);
+ };
+
+ if (_skills.ApplyTo(controller))
+ {
+ Console.WriteLine(
+ $"live: {loggingTag} — applied server skills "
+ + $"run={_skills.RunSkill} jump={_skills.JumpSkill}");
+ }
+
+ ApplyStepHeights(controller, playerEntity);
+ uint initialCellId = ResolveInitialCell(playerGuid, playerEntity);
+
+ if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation)
+ && animation.Sequencer is { } sequencer)
+ {
+ controller.AttachCycleVelocityAccessor(() => sequencer.CurrentVelocity);
+ controller.ObjectScale = animation.Scale;
+ controller.AttachAnimationRootMotionSource(
+ _animation.AdvanceRoot,
+ _animation.CaptureHooks);
+ controller.Motion.RemoveLinkAnimations =
+ sequencer.Manager.HandleEnterWorld;
+ controller.Motion.InitializeMotionTables =
+ sequencer.Manager.InitializeState;
+ controller.Motion.CheckForCompletedMotions =
+ sequencer.Manager.CheckForCompletedMotions;
+ controller.Motion.DefaultSink =
+ new MotionTableDispatchSink(sequencer);
+ }
+
+ ResolveResult initial = _physics.Resolve(
+ playerEntity.Position,
+ initialCellId,
+ Vector3.Zero,
+ 100f);
+ var (placementRadius, placementHeight) =
+ _motionBindings.GetSetupCylinder(playerGuid, playerEntity);
+ if (placementRadius < 0.05f)
+ {
+ placementRadius = 0.48f;
+ placementHeight = 1.835f;
+ }
+
+ ResolveResult placement = _physics.ResolvePlacement(
+ initial.Position,
+ initial.CellId,
+ placementRadius,
+ placementHeight,
+ controller.StepUpHeight,
+ controller.StepDownHeight,
+ ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ playerEntity.Id);
+ if (placement.Ok)
+ initial = placement;
+
+ controller.PreparePositionForCommit(
+ initial.Position,
+ initial.CellId,
+ CellLocalForSeed(initial.Position, initial.CellId));
+ controller.SetBodyOrientation(playerEntity.Rotation);
+
+ var legacyCamera = new ChaseCamera { Aspect = _viewport.Aspect };
+ var retailCamera = new RetailChaseCamera
+ {
+ Aspect = _viewport.Aspect,
+ CollisionProbe = new PhysicsCameraCollisionProbe(_physics),
+ };
+ cameraAttempted = true;
+ _camera.EnterChaseMode(legacyCamera, retailCamera);
+
+ EntityPhysicsHost stableAfterCamera =
+ EntityPhysicsHost.SelectStableHostWithoutRebind(
+ _liveEntities,
+ playerRecord,
+ configuredHost);
+ if (!ReferenceEquals(stableAfterCamera, playerHost))
+ {
+ throw new InvalidOperationException(
+ "The local physics host changed during chase-camera activation.");
+ }
+
+ shadowAttempted = true;
+ _shadow.SyncPose(
+ playerEntity,
+ initial.Position,
+ playerEntity.Rotation,
+ initial.CellId,
+ force: true);
+
+ // Publish the incarnation-stable CPhysicsObj delegates only after all
+ // DAT, placement, shadow, and camera preparation has succeeded. A
+ // late preparation failure therefore cannot expose an abandoned
+ // controller through LiveEntityRecord.PhysicsHost.
+ EntityPhysicsHost publishedHost = EntityPhysicsHost.InstallOrRebind(
+ _liveEntities,
+ playerRecord,
+ configuredHost);
+ if (!ReferenceEquals(publishedHost, playerHost))
+ {
+ throw new InvalidOperationException(
+ "The local physics host changed between preparation and commit.");
+ }
+
+ playerEntity.SetPosition(initial.Position);
+ playerEntity.ParentCellId = initial.CellId;
+ controller.CommitPreparedPosition();
+ _hostSlot.Host = publishedHost;
+ _controllerSlot.Controller = controller;
+ _chase.Legacy = legacyCamera;
+ _chase.Retail = retailCamera;
+ _mode.IsPlayerMode = true;
+ _mode.ChaseModeEverEntered = true;
+ _approachLifetime = approachLifetime;
+ lifetimeCommitted = true;
+ }
+ catch (Exception error)
+ {
+ var failures = new List { error };
+ if (shadowAttempted)
+ {
+ try { _shadow.Restore(playerEntity, priorShadow); }
+ catch (Exception cleanupError) { failures.Add(cleanupError); }
+ }
+ if (cameraAttempted)
+ {
+ try { _camera.RestoreState(priorCamera); }
+ catch (Exception cleanupError) { failures.Add(cleanupError); }
+ }
+
+ _mode.IsPlayerMode = false;
+ _controllerSlot.Controller = null;
+ _hostSlot.Host = null;
+ _chase.Legacy = null;
+ _chase.Retail = null;
+
+ if (failures.Count != 1)
+ throw new AggregateException(
+ "Player-mode entry failed and rollback was incomplete.",
+ failures);
+ throw;
+ }
+ finally
+ {
+ if (!lifetimeCommitted)
+ _approachCompletions.RetireControllerLifetime(approachLifetime);
+ }
+ }
+
+ private void RetireApproachLifetime()
+ {
+ if (_approachLifetime is not { } lifetime)
+ return;
+ _approachLifetime = null;
+ _approachCompletions.RetireControllerLifetime(lifetime);
+ }
+
+ private void ApplyStepHeights(
+ PlayerMovementController controller,
+ WorldEntity playerEntity)
+ {
+ if ((playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
+ {
+ DatReaderWriter.DBObjs.Setup? setup;
+ lock (_datLock)
+ setup = _dats.Get(
+ playerEntity.SourceGfxObjOrSetupId);
+ if (setup is not null)
+ _physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, setup);
+ controller.StepUpHeight = setup is { StepUpHeight: > 0f }
+ ? setup.StepUpHeight
+ : 0.4f;
+ controller.StepDownHeight = setup is { StepDownHeight: > 0f }
+ ? setup.StepDownHeight
+ : 0.4f;
+ Console.WriteLine(
+ $"physics: player step heights — StepUp={controller.StepUpHeight:F3} m "
+ + $"(Setup.StepUpHeight={(setup?.StepUpHeight ?? 0f):F3}), "
+ + $"StepDown={controller.StepDownHeight:F3} m "
+ + $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3})");
+ return;
+ }
+
+ controller.StepUpHeight = 0.4f;
+ controller.StepDownHeight = 0.4f;
+ Console.WriteLine(
+ "physics: player step heights — defaulting to 0.4 m (no setup dat)");
+ }
+
+ private uint ResolveInitialCell(uint playerGuid, WorldEntity playerEntity)
+ {
+ if (_liveEntities.Snapshots.TryGetValue(playerGuid, out var spawn)
+ && spawn.Position is { LandblockId: not 0u } position)
+ {
+ return position.LandblockId;
+ }
+
+ int landblockX = _origin.CenterX
+ + (int)MathF.Floor(playerEntity.Position.X / 192f);
+ int landblockY = _origin.CenterY
+ + (int)MathF.Floor(playerEntity.Position.Y / 192f);
+ return ((uint)landblockX << 24)
+ | ((uint)landblockY << 16)
+ | 0x0001u;
+ }
+
+ private Vector3 CellLocalForSeed(Vector3 worldPosition, uint cellId)
+ {
+ int landblockX = (int)((cellId >> 24) & 0xFFu);
+ int landblockY = (int)((cellId >> 16) & 0xFFu);
+ var origin = new Vector3(
+ (landblockX - _origin.CenterX) * 192f,
+ (landblockY - _origin.CenterY) * 192f,
+ 0f);
+ return worldPosition - origin;
+ }
+}
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 7f6cbf00..0450a319 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -1092,7 +1092,10 @@ public sealed class PlayerMovementController
// route through here. When PhysicsDiagnostics.ProbeCellEnabled is
// off this collapses to a single bool-compare + assignment — zero
// logging cost.
- private void UpdateCellId(uint newCellId, string reason)
+ private void UpdateCellId(
+ uint newCellId,
+ string reason,
+ bool publishRenderRoot = true)
{
if (newCellId != CellId && PhysicsDiagnostics.ProbeCellEnabled)
{
@@ -1109,7 +1112,8 @@ public sealed class PlayerMovementController
// jump-looping near the cottage doorway clobbered the render root every tick → the render
// rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear
// color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]).
- _physics.UpdatePlayerCurrCell(newCellId);
+ if (publishRenderRoot)
+ _physics.UpdatePlayerCurrCell(newCellId);
}
public void SetPosition(Vector3 pos, uint cellId)
@@ -1127,11 +1131,47 @@ public sealed class PlayerMovementController
/// directly via SnapToCell rather than delta-syncing through the setter.
///
public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
+ => SetPositionCore(
+ pos,
+ cellId,
+ cellLocal,
+ publishSharedState: true);
+
+ ///
+ /// Builds a new local controller's canonical body pose without publishing
+ /// the renderer's global current cell or mutating the incarnation-stable
+ /// PositionManager. Player-mode entry commits those shared edges only
+ /// after camera, shadow, and host preparation succeeds.
+ ///
+ internal void PreparePositionForCommit(
+ Vector3 pos,
+ uint cellId,
+ Vector3 cellLocal) =>
+ SetPositionCore(
+ pos,
+ cellId,
+ cellLocal,
+ publishSharedState: false);
+
+ internal void CommitPreparedPosition()
+ {
+ _physics.UpdatePlayerCurrCell(CellId);
+ PositionManager?.UnStick();
+ }
+
+ private void SetPositionCore(
+ Vector3 pos,
+ uint cellId,
+ Vector3 cellLocal,
+ bool publishSharedState)
{
_body.SnapToCell(cellId, pos, cellLocal);
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
- UpdateCellId(_body.CellPosition.ObjCellId, "teleport");
+ UpdateCellId(
+ _body.CellPosition.ObjCellId,
+ "teleport",
+ publishSharedState);
// Treat as grounded after a server-side position snap.
_body.TransientState = TransientStateFlags.Contact
@@ -1164,7 +1204,8 @@ public sealed class PlayerMovementController
// tears down any active stick. (StopInterpolating/UnConstrain have no
// armed acdream counterparts — no local-player InterpolationManager,
// constraint leash unarmed per #167.)
- PositionManager?.UnStick();
+ if (publishSharedState)
+ PositionManager?.UnStick();
// Reset the edge tracker: the stop wiped the motion state, so keys
// still physically held must re-fire as press edges on the next
// Update (matches the pre-W6 level-triggered behavior of walking
diff --git a/src/AcDream.App/Interaction/PlayerApproachCompletionState.cs b/src/AcDream.App/Interaction/PlayerApproachCompletionState.cs
new file mode 100644
index 00000000..404a0e81
--- /dev/null
+++ b/src/AcDream.App/Interaction/PlayerApproachCompletionState.cs
@@ -0,0 +1,141 @@
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Interaction;
+
+internal interface IPlayerApproachCompletionSink
+{
+ void PublishNaturalCompletion();
+ void PublishCancellation(WeenieError error);
+}
+
+internal interface IPlayerApproachCompletionLifetimeOwner
+{
+ IPlayerApproachCompletionSink BeginControllerLifetime();
+ void RetireControllerLifetime(IPlayerApproachCompletionSink lifetime);
+}
+
+internal interface IPlayerApproachTokenSource
+{
+ bool TryBeginApproach(out PlayerApproachToken token);
+}
+
+internal readonly record struct PlayerApproachToken(
+ ulong ControllerLifetime,
+ ulong ApproachGeneration);
+
+internal readonly record struct PlayerApproachCompletion(
+ PlayerApproachToken Token,
+ bool IsNatural,
+ WeenieError Error);
+
+///
+/// Same-thread mailbox between local MoveTo completion and the selection/
+/// item interaction phase. It deliberately owns no query, item, transport,
+/// window, or callback reference.
+///
+internal sealed class PlayerApproachCompletionState
+ : IPlayerApproachCompletionLifetimeOwner,
+ IPlayerApproachTokenSource
+{
+ private readonly Queue _pending = new();
+ private ControllerLifetime? _active;
+ private ulong _nextLifetime;
+
+ public IPlayerApproachCompletionSink BeginControllerLifetime()
+ {
+ if (_active is not null)
+ throw new InvalidOperationException(
+ "A player approach-completion lifetime is already active.");
+
+ var lifetime = new ControllerLifetime(this, ++_nextLifetime);
+ _active = lifetime;
+ return lifetime;
+ }
+
+ public void RetireControllerLifetime(IPlayerApproachCompletionSink lifetime)
+ {
+ ArgumentNullException.ThrowIfNull(lifetime);
+ if (!ReferenceEquals(_active, lifetime)
+ || lifetime is not ControllerLifetime retiring)
+ return;
+
+ PurgeLifetime(retiring.LifetimeId);
+ if (retiring.TryGetCurrentToken(out PlayerApproachToken token))
+ {
+ _pending.Enqueue(new PlayerApproachCompletion(
+ token,
+ IsNatural: false,
+ WeenieError.ActionCancelled));
+ }
+ _active = null;
+ }
+
+ public bool TryBeginApproach(out PlayerApproachToken token)
+ {
+ if (_active is null)
+ {
+ token = default;
+ return false;
+ }
+
+ token = _active.BeginApproach();
+ return true;
+ }
+
+ public bool TryTake(out PlayerApproachCompletion completion) =>
+ _pending.TryDequeue(out completion);
+
+ public void Clear()
+ {
+ _active = null;
+ _pending.Clear();
+ }
+
+ private void Publish(
+ ControllerLifetime lifetime,
+ bool isNatural,
+ WeenieError error)
+ {
+ if (!ReferenceEquals(_active, lifetime)
+ || !lifetime.TryGetCurrentToken(out PlayerApproachToken token))
+ {
+ return;
+ }
+
+ _pending.Enqueue(new PlayerApproachCompletion(token, isNatural, error));
+ }
+
+ private void PurgeLifetime(ulong lifetimeId)
+ {
+ int retained = _pending.Count;
+ for (int i = 0; i < retained; i++)
+ {
+ PlayerApproachCompletion completion = _pending.Dequeue();
+ if (completion.Token.ControllerLifetime != lifetimeId)
+ _pending.Enqueue(completion);
+ }
+ }
+
+ private sealed class ControllerLifetime(
+ PlayerApproachCompletionState owner,
+ ulong lifetimeId) : IPlayerApproachCompletionSink
+ {
+ private ulong _approachGeneration;
+ public ulong LifetimeId => lifetimeId;
+
+ public PlayerApproachToken BeginApproach() =>
+ new(lifetimeId, ++_approachGeneration);
+
+ public bool TryGetCurrentToken(out PlayerApproachToken token)
+ {
+ token = new PlayerApproachToken(lifetimeId, _approachGeneration);
+ return _approachGeneration != 0;
+ }
+
+ public void PublishNaturalCompletion() =>
+ owner.Publish(this, isNatural: true, WeenieError.None);
+
+ public void PublishCancellation(WeenieError error) =>
+ owner.Publish(this, isNatural: false, error);
+ }
+}
diff --git a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs
index 7c8cfb50..f958010d 100644
--- a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs
+++ b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs
@@ -12,7 +12,9 @@ internal interface IPlayerInteractionMovementSink
/// owner arm completion state before an already-facing TurnTo can finish
/// synchronously, without letting the preceding cancellation clear it.
///
- bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null);
+ bool BeginApproach(
+ InteractionApproach approach,
+ Action? armAfterCancel = null);
}
///
@@ -20,13 +22,18 @@ internal interface IPlayerInteractionMovementSink
/// the same MovementManager used by authoritative movement packets.
///
internal sealed class PlayerInteractionMovementSink(
- Func player)
+ Func player,
+ IPlayerApproachTokenSource approachTokens)
: IPlayerInteractionMovementSink
{
private readonly Func _player = player
?? throw new ArgumentNullException(nameof(player));
+ private readonly IPlayerApproachTokenSource _approachTokens = approachTokens
+ ?? throw new ArgumentNullException(nameof(approachTokens));
- public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null)
+ public bool BeginApproach(
+ InteractionApproach approach,
+ Action? armAfterCancel = null)
{
PlayerMovementController? controller = _player();
if (controller?.MoveTo is null)
@@ -57,7 +64,9 @@ internal sealed class PlayerInteractionMovementSink(
// intent is armed so cancellation of the preceding move cannot clear
// the new request; the internal second call is then a retail no-op.
controller.Movement.CancelMoveTo(WeenieError.ActionCancelled);
- armAfterCancel?.Invoke();
+ if (!_approachTokens.TryBeginApproach(out PlayerApproachToken token))
+ return false;
+ armAfterCancel?.Invoke(token);
// P1's wire MoveToObject store marks the command non-autonomous.
// The speculative local install must do the same or the next raw-input
diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs
index 47def6ee..794cf8b8 100644
--- a/src/AcDream.App/Interaction/SelectionInteractionController.cs
+++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs
@@ -22,6 +22,7 @@ internal sealed class SelectionInteractionController
private readonly ISelectionInteractionTransport _transport;
private readonly IPlayerInteractionMovementSink _movement;
private readonly OutboundInteractionQueue _outbound = new();
+ private readonly PlayerApproachCompletionState _approachCompletions;
private readonly Action? _toast;
private PendingPostArrivalAction? _pendingPostArrival;
@@ -36,7 +37,8 @@ internal sealed class SelectionInteractionController
bool IsPickup,
uint DestinationContainerId,
int Placement,
- ulong PendingPlacementToken);
+ ulong PendingPlacementToken,
+ PlayerApproachToken ApproachToken);
public SelectionInteractionController(
SelectionState selection,
@@ -44,7 +46,8 @@ internal sealed class SelectionInteractionController
ItemInteractionController items,
ISelectionInteractionTransport transport,
IPlayerInteractionMovementSink movement,
- Action? toast = null)
+ Action? toast = null,
+ PlayerApproachCompletionState? approachCompletions = null)
{
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_query = query ?? throw new ArgumentNullException(nameof(query));
@@ -52,6 +55,8 @@ internal sealed class SelectionInteractionController
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
_toast = toast;
+ _approachCompletions = approachCompletions
+ ?? new PlayerApproachCompletionState();
}
public bool HandleInputAction(InputAction action)
@@ -213,11 +218,12 @@ internal sealed class SelectionInteractionController
IsPickup: false,
DestinationContainerId: 0u,
Placement: 0,
- PendingPlacementToken: 0u);
+ PendingPlacementToken: 0u,
+ ApproachToken: default);
bool started = _movement.BeginApproach(
approach,
- () => _pendingPostArrival = pending);
- if (!started && _pendingPostArrival == pending)
+ token => _pendingPostArrival = pending with { ApproachToken = token });
+ if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid)
CancelPendingApproach();
return;
}
@@ -265,11 +271,12 @@ internal sealed class SelectionInteractionController
IsPickup: true,
destinationContainerId,
placement,
- pendingPlacementToken);
+ pendingPlacementToken,
+ ApproachToken: default);
bool started = _movement.BeginApproach(
approach,
- () => _pendingPostArrival = pending);
- if (!started && _pendingPostArrival == pending)
+ token => _pendingPostArrival = pending with { ApproachToken = token });
+ if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid)
CancelPendingApproach();
else if (!started)
CancelPickupPresentation(itemGuid, pendingPlacementToken);
@@ -307,6 +314,9 @@ internal sealed class SelectionInteractionController
/// Fires only after natural MoveToComplete(None), never cancellation.
public void OnNaturalMoveToComplete()
+ => HandleNaturalMoveToComplete();
+
+ private void HandleNaturalMoveToComplete()
{
if (_pendingPostArrival is not { } pending)
return;
@@ -351,7 +361,19 @@ internal sealed class SelectionInteractionController
}
}
- public void DrainOutbound() => _outbound.Drain();
+ public void DrainOutbound()
+ {
+ while (_approachCompletions.TryTake(out PlayerApproachCompletion completion))
+ {
+ if (_pendingPostArrival?.ApproachToken != completion.Token)
+ continue;
+ if (completion.IsNatural)
+ HandleNaturalMoveToComplete();
+ else
+ CancelPendingApproach();
+ }
+ _outbound.Drain();
+ }
public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach();
@@ -395,6 +417,8 @@ internal sealed class SelectionInteractionController
catch (Exception error) { failures.Add(error); }
try { _outbound.Clear(); }
catch (Exception error) { failures.Add(error); }
+ try { _approachCompletions.Clear(); }
+ catch (Exception error) { failures.Add(error); }
_pendingPostArrival = null;
if (failures.Count != 0)
diff --git a/src/AcDream.App/Physics/EntityPhysicsHost.cs b/src/AcDream.App/Physics/EntityPhysicsHost.cs
index 3b8a89e9..0f138f25 100644
--- a/src/AcDream.App/Physics/EntityPhysicsHost.cs
+++ b/src/AcDream.App/Physics/EntityPhysicsHost.cs
@@ -193,6 +193,36 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
return existing;
}
+ ///
+ /// Validates an exact incarnation and returns the manager-stable host that
+ /// a later will publish, without changing any
+ /// live delegates. Player-mode construction uses this preparation edge so
+ /// DAT/physics/camera failures cannot expose a half-rebound host.
+ ///
+ internal static EntityPhysicsHost SelectStableHostWithoutRebind(
+ LiveEntityRuntime runtime,
+ LiveEntityRecord expectedRecord,
+ EntityPhysicsHost configuration)
+ {
+ ArgumentNullException.ThrowIfNull(runtime);
+ ArgumentNullException.ThrowIfNull(expectedRecord);
+ ArgumentNullException.ThrowIfNull(configuration);
+ if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current)
+ || !ReferenceEquals(current, expectedRecord))
+ {
+ throw new InvalidOperationException(
+ $"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host preparation.");
+ }
+
+ return current.PhysicsHost switch
+ {
+ null => configuration,
+ EntityPhysicsHost existing => existing,
+ _ => throw new InvalidOperationException(
+ $"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation."),
+ };
+ }
+
///
/// Builds the position-only CPhysicsObj facade used before an animated or
/// player movement owner exists. The delegates capture the exact live
diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
index 69d10dfd..41d65415 100644
--- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
+++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
@@ -48,8 +48,8 @@ internal sealed class LiveEntityNetworkUpdateController
private readonly IAnimationLoader _animLoader;
private readonly CombatTargetController? _combatTargetController;
private readonly LiveWorldOriginState _origin;
- private readonly AcDream.App.Streaming.TeleportTransitCoordinator<
- WorldSession.EntityPositionUpdate> _teleportTransit;
+ private readonly AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink
+ _localPlayerTeleport;
private readonly Func _playerControllerSource;
private readonly LocalPlayerOutboundController _localPlayerOutbound;
private readonly Func _playerHostSource;
@@ -57,7 +57,6 @@ internal sealed class LiveEntityNetworkUpdateController
private readonly IPhysicsScriptTimeSource _gameTime;
private readonly Func _session;
private readonly LiveEntityInboundAuthorityGate _authorityGate;
- private readonly Action _aimTeleportDestination;
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
private PlayerMovementController? _playerController => _playerControllerSource();
@@ -96,7 +95,7 @@ internal sealed class LiveEntityNetworkUpdateController
IAnimationLoader animLoader,
CombatTargetController? combatTargetController,
LiveWorldOriginState origin,
- AcDream.App.Streaming.TeleportTransitCoordinator teleportTransit,
+ AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink localPlayerTeleport,
Func playerControllerSource,
LocalPlayerOutboundController localPlayerOutbound,
Func playerHostSource,
@@ -104,7 +103,6 @@ internal sealed class LiveEntityNetworkUpdateController
IPhysicsScriptTimeSource gameTime,
Func session,
Action publishTimestamps,
- Action aimTeleportDestination,
IMovementTruthDiagnosticSink movementTruthDiagnostics)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
@@ -127,7 +125,8 @@ internal sealed class LiveEntityNetworkUpdateController
_animLoader = animLoader ?? throw new ArgumentNullException(nameof(animLoader));
_combatTargetController = combatTargetController;
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
- _teleportTransit = teleportTransit ?? throw new ArgumentNullException(nameof(teleportTransit));
+ _localPlayerTeleport = localPlayerTeleport
+ ?? throw new ArgumentNullException(nameof(localPlayerTeleport));
_playerControllerSource = playerControllerSource ?? throw new ArgumentNullException(nameof(playerControllerSource));
_localPlayerOutbound = localPlayerOutbound
?? throw new ArgumentNullException(nameof(localPlayerOutbound));
@@ -138,7 +137,6 @@ internal sealed class LiveEntityNetworkUpdateController
_authorityGate = new LiveEntityInboundAuthorityGate(
liveEntities,
publishTimestamps);
- _aimTeleportDestination = aimTeleportDestination ?? throw new ArgumentNullException(nameof(aimTeleportDestination));
_movementTruthDiagnostics = movementTruthDiagnostics
?? throw new ArgumentNullException(nameof(movementTruthDiagnostics));
}
@@ -1776,14 +1774,11 @@ internal sealed class LiveEntityNetworkUpdateController
// packet first. The presentation coordinator exposes exactly one
// sequence-correlated destination without reordering that state.
if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Apply
- && update.Guid == _playerServerGuid
- && _teleportTransit.OfferDestination(
- update.TeleportSequence,
- timestamps.TeleportAdvanced,
- update,
- out var teleportDestination))
+ && update.Guid == _playerServerGuid)
{
- _aimTeleportDestination(teleportDestination);
+ _localPlayerTeleport.OfferDestination(
+ update,
+ timestamps.TeleportAdvanced);
}
}
diff --git a/src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs b/src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs
new file mode 100644
index 00000000..8b146ce8
--- /dev/null
+++ b/src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs
@@ -0,0 +1,117 @@
+using AcDream.App.Input;
+using AcDream.App.World;
+using AcDream.Core.Physics;
+using AcDream.Core.World;
+
+namespace AcDream.App.Physics;
+
+///
+/// Owns publication and suspension of the local player's collision shadow.
+/// Identity/incarnation checks stay beside the cached shadow pose so player
+/// mode, projection, and teleport placement cannot publish different owners.
+///
+internal sealed class LocalPlayerShadowSynchronizer
+{
+ private readonly PhysicsEngine _physics;
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly ILocalPlayerIdentitySource _identity;
+ private readonly LiveWorldOriginState _origin;
+ private readonly LocalPlayerShadowState _state;
+
+ public LocalPlayerShadowSynchronizer(
+ PhysicsEngine physics,
+ LiveEntityRuntime liveEntities,
+ ILocalPlayerIdentitySource identity,
+ LiveWorldOriginState origin,
+ LocalPlayerShadowState state)
+ {
+ _physics = physics ?? throw new ArgumentNullException(nameof(physics));
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _identity = identity ?? throw new ArgumentNullException(nameof(identity));
+ _origin = origin ?? throw new ArgumentNullException(nameof(origin));
+ _state = state ?? throw new ArgumentNullException(nameof(state));
+ }
+
+ public void Sync(WorldEntity playerEntity, uint cellId, bool force = false)
+ => SyncPose(
+ playerEntity,
+ playerEntity.Position,
+ playerEntity.Rotation,
+ cellId,
+ force);
+
+ public LocalPlayerShadowState.Snapshot? Capture() => _state.Current;
+
+ public void SyncPose(
+ WorldEntity playerEntity,
+ System.Numerics.Vector3 position,
+ System.Numerics.Quaternion orientation,
+ uint cellId,
+ bool force = false)
+ {
+ ArgumentNullException.ThrowIfNull(playerEntity);
+ if (_liveEntities.IsHidden(_identity.ServerGuid)
+ || cellId == 0
+ || !IsCurrentVisibleProjection(playerEntity))
+ {
+ Suspend(playerEntity);
+ return;
+ }
+
+ if (!force
+ && _state.Current is { } last
+ && last.CellId == cellId
+ && System.Numerics.Vector3.DistanceSquared(
+ last.Position,
+ position) <= 1e-4f
+ && MathF.Abs(System.Numerics.Quaternion.Dot(
+ last.Orientation,
+ orientation)) >= 0.99999f)
+ {
+ return;
+ }
+
+ ShadowPositionSynchronizer.Sync(
+ _physics.ShadowObjects,
+ playerEntity.Id,
+ position,
+ orientation,
+ cellId,
+ _origin.CenterX,
+ _origin.CenterY);
+ _state.Set(position, orientation, cellId);
+ }
+
+ public void Restore(
+ WorldEntity playerEntity,
+ LocalPlayerShadowState.Snapshot? snapshot)
+ {
+ ArgumentNullException.ThrowIfNull(playerEntity);
+ if (snapshot is not { } prior || !IsCurrentVisibleProjection(playerEntity))
+ {
+ Suspend(playerEntity);
+ return;
+ }
+
+ SyncPose(
+ playerEntity,
+ prior.Position,
+ prior.Orientation,
+ prior.CellId,
+ force: true);
+ }
+
+ public bool IsCurrentVisibleProjection(WorldEntity playerEntity) =>
+ _liveEntities.TryGetRecord(playerEntity.ServerGuid, out LiveEntityRecord record)
+ && ReferenceEquals(record.WorldEntity, playerEntity)
+ && _liveEntities.IsCurrentSpatialRootObject(record);
+
+ public void Suspend(WorldEntity playerEntity)
+ {
+ ArgumentNullException.ThrowIfNull(playerEntity);
+ _physics.ShadowObjects.Suspend(playerEntity.Id);
+ _state.Clear();
+ }
+
+ public void ResetSession() => _state.Clear();
+}
diff --git a/src/AcDream.App/Rendering/CameraController.cs b/src/AcDream.App/Rendering/CameraController.cs
index 5961f358..60b84ac5 100644
--- a/src/AcDream.App/Rendering/CameraController.cs
+++ b/src/AcDream.App/Rendering/CameraController.cs
@@ -5,6 +5,11 @@ namespace AcDream.App.Rendering;
public sealed class CameraController
{
+ internal readonly record struct CameraState(
+ int ModeCode,
+ ChaseCamera? Chase,
+ RetailChaseCamera? RetailChase);
+
public OrbitCamera Orbit { get; }
public FlyCamera Fly { get; }
public ChaseCamera? Chase { get; private set; }
@@ -81,4 +86,22 @@ public sealed class CameraController
Orbit.Aspect = aspect;
Fly.Aspect = aspect;
}
+
+ internal CameraState CaptureState() =>
+ new((int)_mode, Chase, RetailChase);
+
+ ///
+ /// Restores a previously captured camera lifetime before notifying
+ /// subscribers. State remains restored even when a subscriber throws.
+ ///
+ internal void RestoreState(CameraState state)
+ {
+ if (state.ModeCode < (int)Mode.Orbit || state.ModeCode > (int)Mode.Chase)
+ throw new ArgumentOutOfRangeException(nameof(state));
+
+ Chase = state.Chase;
+ RetailChase = state.RetailChase;
+ _mode = (Mode)state.ModeCode;
+ ModeChanged?.Invoke(IsFlyMode || IsChaseMode);
+ }
}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 018c1b79..7d85fe39 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -132,8 +132,14 @@ public sealed class GameWindow : IDisposable
_landblockPresentationPipeline;
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
private AcDream.App.Streaming.StreamingController? _streamingController;
+ private AcDream.App.Streaming.StreamingOriginRecenterCoordinator?
+ _streamingOriginRecenter;
private AcDream.App.Update.IStreamingFramePhase _streamingFrame = null!;
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
+ private readonly AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink
+ _localPlayerTeleportSink = new();
+ private AcDream.App.Streaming.LocalPlayerTeleportController?
+ _localPlayerTeleport;
private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback)
private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9)
private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25)
@@ -538,27 +544,15 @@ public sealed class GameWindow : IDisposable
// Phase B.2: player movement mode.
private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new();
private AcDream.App.Input.PlayerMovementController? _playerController
- {
- get => _playerControllerSlot.Controller;
- set => _playerControllerSlot.Controller = value;
- }
+ => _playerControllerSlot.Controller;
private readonly AcDream.App.Input.ChaseCameraInputState _chaseCameraInput = new();
private AcDream.App.Rendering.ChaseCamera? _chaseCamera
- {
- get => _chaseCameraInput.Legacy;
- set => _chaseCameraInput.Legacy = value;
- }
+ => _chaseCameraInput.Legacy;
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera
- {
- get => _chaseCameraInput.Retail;
- set => _chaseCameraInput.Retail = value;
- }
+ => _chaseCameraInput.Retail;
private readonly AcDream.App.Input.LocalPlayerModeState _localPlayerMode = new();
private bool _playerMode
- {
- get => _localPlayerMode.IsPlayerMode;
- set => _localPlayerMode.IsPlayerMode = value;
- }
+ => _localPlayerMode.IsPlayerMode;
private readonly AcDream.App.Input.LocalPlayerIdentityState _localPlayerIdentity = new();
private uint _playerServerGuid
{
@@ -572,16 +566,15 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new();
- // K-fix7 (2026-04-26): server-authoritative Run + Jump skill values
- // received from PlayerDescription. -1 = "not yet received, fall back
- // to the controller's default (env-var or hardcoded 200/300)".
- // Captured by the GameEventWiring.WireAll callback the moment PD
- // arrives; pushed into _playerController via SetCharacterSkills both
- // immediately (if the controller already exists from auto-entry) and
- // again at every EnterPlayerModeNow so a player who Tab-toggles in
- // and out keeps the right skills.
- private int _lastSeenRunSkill = -1;
- private int _lastSeenJumpSkill = -1;
+ private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
+ private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new();
+ private AcDream.App.Input.PlayerModeController? _playerModeController;
+ private readonly AcDream.App.Interaction.PlayerApproachCompletionState
+ _playerApproachCompletions = new();
+ private AcDream.App.Input.LocalPlayerAnimationController?
+ _localPlayerAnimation;
+ private AcDream.App.Physics.LocalPlayerShadowSynchronizer?
+ _localPlayerShadowSynchronizer;
// Phase D.2b-C — live character-sheet assembly + raise flow (extracted
// feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider;
@@ -666,7 +659,7 @@ public sealed class GameWindow : IDisposable
/// streaming + scene rendering so the user never sees Holtburg flash
/// before login completes — the screen stays at the sky/fog clear
/// color until the player entity has spawned + the auto-entry guard
- /// has triggered .
+ /// has triggered the player-mode controller's auto-entry edge.
/// Offline (LiveModeEnabled false) returns false → unchanged
/// orbit-camera Holtburg view stays the foreground. Once chase mode
/// is active the property latches false for the rest of the
@@ -681,10 +674,7 @@ public sealed class GameWindow : IDisposable
// IsLiveModeWaitingForLogin to suppress the pre-login render gate
// for subsequent fly-mode toggles.
private bool _chaseModeEverEntered
- {
- get => _localPlayerMode.ChaseModeEverEntered;
- set => _localPlayerMode.ChaseModeEverEntered = value;
- }
+ => _localPlayerMode.ChaseModeEverEntered;
///
/// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
@@ -717,12 +707,15 @@ public sealed class GameWindow : IDisposable
// TargetManager voyeur system, stored on the exact live record so remote
// entities chasing the player resolve it. Replaces the AP-79
// _playerMoveToTarget* poll fields.
- private EntityPhysicsHost? _playerHost;
+ private readonly AcDream.App.Input.LocalPlayerPhysicsHostSlot
+ _playerHostSlot = new();
+ private EntityPhysicsHost? _playerHost
+ => _playerHostSlot.Host;
// R5-V2: guid → per-entity IPhysicsObjHost registry (retail's
// CObjectMaint::GetObjectA lookup). Backs every host's GetObjectA seam,
// giving the TargetManager voyeur round-trip its cross-entity delivery
- // path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow
+ // path. Populated for remotes plus the PlayerModeController local entry
// (player); pruned only by logical LiveEntityRuntime teardown.
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
@@ -908,22 +901,6 @@ public sealed class GameWindow : IDisposable
Combat.CombatModeChanged += SetInputCombatScope;
SetInputCombatScope(Combat.CurrentMode);
- // Phase K.2 — auto-enter player mode after EnterWorld
- // succeeds. Predicates close over GameWindow state; the
- // entry callback flips into player mode via the same code
- // path TogglePlayerMode uses, just without the early-return
- // when the entity isn't ready (the third predicate
- // guarantees readiness before this fires).
- _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
- isLiveInWorld: () => _liveSessionController?.IsInWorld == true,
- isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid),
- isPlayerControllerReady: () => true,
- // Retail SmartBox::UseTime (0x00455410) completes the player
- // position only after CellManager's blocking load clears. The
- // shared acdream barrier joins collision, static-mesh upload,
- // and composite-texture readiness for both login and portals.
- isWorldReady: LoginWorldReady,
- enterPlayerMode: EnterPlayerModeFromAutoEntry);
}
// Mouse delta handler — kept direct because Silk.NET delivers mouse
@@ -1248,7 +1225,8 @@ public sealed class GameWindow : IDisposable
// auto-entry if the user opts out of player mode before
// it fires, so the chase camera doesn't snap on top of
// the fly camera mid-inspection.
- _debugVm.ToggleFlyMode = ToggleFlyOrChase;
+ _debugVm.ToggleFlyMode = () =>
+ _playerModeController?.ToggleFlyOrChase();
_combatFeedback.ViewModel = _debugVm;
_debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
_panelHost.Register(_debugPanel);
@@ -2313,8 +2291,10 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport(
() => LiveSession),
new AcDream.App.Interaction.PlayerInteractionMovementSink(
- () => _playerController),
- text => _debugVm?.AddToast(text));
+ () => _playerController,
+ _playerApproachCompletions),
+ text => _debugVm?.AddToast(text),
+ _playerApproachCompletions);
}
// A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
@@ -2511,6 +2491,10 @@ public sealed class GameWindow : IDisposable
var networkUpdateBridge =
new AcDream.App.World.DeferredLiveEntityNetworkUpdateSink();
+ var sealedDungeonCells =
+ new AcDream.App.Streaming.DatSealedDungeonCellClassifier(
+ _dats!,
+ _datLock);
var projectionMaterializer = new DatLiveEntityProjectionMaterializer(
_options,
_dats!,
@@ -2538,7 +2522,7 @@ public sealed class GameWindow : IDisposable
_worldState,
_worldReveal,
() => _playerServerGuid,
- IsSealedDungeonCell,
+ sealedDungeonCells,
Console.WriteLine);
var liveEntityTeardown =
new AcDream.App.World.LiveEntityRuntimeTeardownController(
@@ -2598,7 +2582,7 @@ public sealed class GameWindow : IDisposable
_animLoader!,
_combatTargetController,
_liveWorldOrigin,
- _teleportTransit,
+ _localPlayerTeleportSink,
() => _playerController,
_localPlayerOutbound,
() => _playerHost,
@@ -2606,7 +2590,6 @@ public sealed class GameWindow : IDisposable
_updateFrameClock,
() => LiveSession,
PublishLocalPhysicsTimestamps,
- AimTeleportDestination,
_movementTruthDiagnostics);
_liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
_liveEntities,
@@ -2711,6 +2694,91 @@ public sealed class GameWindow : IDisposable
_equippedChildRenderer!,
_particleSink!,
_liveEntityLights!);
+ _localPlayerAnimation =
+ new AcDream.App.Input.LocalPlayerAnimationController(
+ _liveEntities,
+ _localPlayerIdentity,
+ _animatedEntities,
+ _animationPresenter,
+ _animationHookFrames!);
+ _localPlayerShadowSynchronizer =
+ new AcDream.App.Physics.LocalPlayerShadowSynchronizer(
+ _physicsEngine,
+ _liveEntities,
+ _localPlayerIdentity,
+ _liveWorldOrigin,
+ _localPlayerShadow);
+ _viewportAspect.Update(_window!.Size.X, _window.Size.Y);
+ _playerModeController = new AcDream.App.Input.PlayerModeController(
+ _localPlayerMode,
+ _playerControllerSlot,
+ _playerHostSlot,
+ _chaseCameraInput,
+ _cameraController!,
+ _physicsEngine,
+ _liveEntities,
+ _localPlayerIdentity,
+ _liveWorldOrigin,
+ _liveEntityMotionBindings,
+ _dats!,
+ _datLock,
+ _physicsDataCache,
+ _animatedEntities,
+ _localPlayerAnimation,
+ _localPlayerShadowSynchronizer,
+ _playerApproachCompletions,
+ _gameplayInputFrame,
+ _liveSessionController,
+ _movementTruthDiagnostics,
+ _localPlayerSkills,
+ _viewportAspect);
+ _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
+ isLiveInWorld: () => _liveSessionController.IsInWorld,
+ isPlayerEntityPresent: () =>
+ _liveEntities.MaterializedWorldEntities.ContainsKey(
+ _playerServerGuid),
+ isPlayerControllerReady: () => true,
+ // Retail SmartBox::UseTime @ 0x00455410 completes player
+ // position only after the destination cell load clears.
+ isWorldReady: LoginWorldReady,
+ enterPlayerMode: _playerModeController.EnterFromAutoEntry,
+ isPlayerModeActive: () => _localPlayerMode.IsPlayerMode);
+ _playerModeController.BindAutoEntry(_playerModeAutoEntry);
+ var localTeleportPresentation =
+ new AcDream.App.Streaming.LocalPlayerTeleportPresentation(
+ _portalTunnel
+ ?? throw new InvalidOperationException(
+ "Portal presentation was not composed before local teleport."));
+ _localPlayerTeleport =
+ new AcDream.App.Streaming.LocalPlayerTeleportController(
+ new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority(
+ _liveEntities,
+ _localPlayerIdentity),
+ _gameplayInputFrame,
+ _playerModeController,
+ new AcDream.App.Streaming.LocalPlayerTeleportStreamingOperations(
+ _liveWorldOrigin,
+ _streamingOriginRecenter!,
+ _streamingController!,
+ sealedDungeonCells),
+ _worldReveal,
+ new AcDream.App.Streaming.LocalPlayerTeleportPlacement(
+ _physicsEngine,
+ _liveEntities,
+ _localPlayerIdentity,
+ _playerControllerSlot,
+ _playerHostSlot,
+ _chaseCameraInput,
+ _liveWorldOrigin,
+ _liveSpatialReconciler),
+ new AcDream.App.Streaming.LocalPlayerTeleportSession(
+ _liveSessionController),
+ localTeleportPresentation);
+ _localPlayerTeleportSink.Bind(_localPlayerTeleport);
+ // Ownership transferred to LocalPlayerTeleportController. This field
+ // remains only as the staged-startup fallback used by shutdown when
+ // composition fails before the transfer.
+ _portalTunnel = null;
_liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame,
_worldState,
@@ -2780,7 +2848,7 @@ public sealed class GameWindow : IDisposable
{
MouseCapture = ResetSessionMouseCapture,
PlayerPresentation = ResetSessionPlayerPresentation,
- TeleportTransit = () => ResetTeleportTransitState(clearSession: true),
+ TeleportTransit = _localPlayerTeleportSink.ResetSession,
SessionDialogs = () => _retailUiRuntime?.ResetSessionDialogs(),
ChatCommandTargets = () => _retailChatVm?.ResetSessionTargets(),
SettingsCharacterContext = () =>
@@ -2816,22 +2884,8 @@ public sealed class GameWindow : IDisposable
private void ResetSessionPlayerPresentation()
{
- _playerModeAutoEntry?.Cancel();
- try
- {
- _cameraController?.ExitChaseMode();
- }
- finally
- {
- _localPlayerMode.ResetSession();
- _playerController = null;
- _playerHost = null;
- _chaseCamera = null;
- _retailChaseCamera = null;
- _movementTruthDiagnostics.ResetSession();
- _localPlayerShadow.Clear();
- _spawnClaimRangeMemo = null;
- }
+ _playerModeController?.ResetSession();
+ _spawnClaimRangeMemo = null;
}
private void ResetSessionIdentity()
@@ -2849,8 +2903,7 @@ public sealed class GameWindow : IDisposable
_activeToonKey = "default";
_characterOptions1 =
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
- _lastSeenRunSkill = -1;
- _lastSeenJumpSkill = -1;
+ _localPlayerSkills.ResetSession();
_liveEntityNetworkUpdates?.ResetSessionState();
_liveEntityHydration?.ResetSessionState();
Shortcuts = Array.Empty();
@@ -2960,7 +3013,9 @@ public sealed class GameWindow : IDisposable
_liveEntityHydration!, parent,
static (hydration, value) => hydration.OnParent(value)),
TeleportStarted: teleport => _inboundEntityEvents.Run(
- this, teleport, static (window, value) => window.OnTeleportStarted(value)),
+ _localPlayerTeleportSink,
+ teleport,
+ static (sink, value) => sink.OnTeleportStarted(value)),
AppearanceUpdated: appearance => _inboundEntityEvents.Run(
_liveEntityHydration!, appearance,
static (hydration, value) => hydration.OnAppearance(value)),
@@ -3019,20 +3074,12 @@ public sealed class GameWindow : IDisposable
},
OnSkillsUpdated: (runSkill, jumpSkill) =>
{
- if (runSkill >= 0)
- _lastSeenRunSkill = runSkill;
- if (jumpSkill >= 0)
- _lastSeenJumpSkill = jumpSkill;
- if (_playerController is not null
- && _lastSeenRunSkill >= 0
- && _lastSeenJumpSkill >= 0)
+ _localPlayerSkills.Update(runSkill, jumpSkill, _playerController);
+ if (_localPlayerSkills.IsComplete && _playerController is not null)
{
- _playerController.SetCharacterSkills(
- _lastSeenRunSkill,
- _lastSeenJumpSkill);
Console.WriteLine(
- $"player: applied server skills run={_lastSeenRunSkill} " +
- $"jump={_lastSeenJumpSkill}");
+ $"player: applied server skills run={_localPlayerSkills.RunSkill} " +
+ $"jump={_localPlayerSkills.JumpSkill}");
}
},
OnConfirmationRequest: request =>
@@ -3293,174 +3340,6 @@ public sealed class GameWindow : IDisposable
_localPlayerShadow.Clear();
}
- ///
- /// R3-W4: one-time per-remote wiring of the animation-dispatch stack —
- /// the persistent ,
- /// Motion.DefaultSink (so
- /// apply_current_movement's interpreted branch dispatches cycles
- /// — the retail mechanism behind the deleted K-fix18 forced-Falling),
- /// and the RemoveLinkAnimations/InitializeMotionTables
- /// seams (retail CPhysicsObj::RemoveLinkAnimations 0x0050fe20 /
- /// InitializeMotionTables). Idempotent; safe from both the UM path and
- /// the VectorUpdate path regardless of arrival order.
- ///
- private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new();
- private readonly TeleportViewPlaneController _teleportViewPlane = new();
- private readonly AcDream.App.Streaming.TeleportTransitCoordinator<
- AcDream.Core.Net.WorldSession.EntityPositionUpdate> _teleportTransit = new();
- private AcDream.App.Streaming.StreamingOriginRecenterCoordinator?
- _streamingOriginRecenter;
- private System.Numerics.Vector3 _pendingTeleportPos;
- private uint _pendingTeleportCell;
- private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout)
- private bool _teleportForced; // true when the safety-net timeout force-places
- private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
-
- // Loud safety net for a destination that never streams (worker crash / corrupt dat /
- // OOB coords). WALL-CLOCK, not a frame count: during a dungeon-exit hold the source is
- // unloaded and the destination not yet loaded → empty world → ~1000 fps, so a 600-FRAME
- // ceiling fired in ~0.6s and force-placed into the skybox before the expand finished.
- // Now rarely fires because priority-apply makes residency fast.
- private const float TeleportMaxHoldSeconds = 10f;
-
- // 2026-06-22: how many landblocks of the player's IMMEDIATE SURROUNDINGS to eager-apply
- // (and to hold portal space for) on a teleport. radius 1 = the 3×3 around the destination —
- // the player's own landblock + its 8 neighbours, ~576 m across — enough that they arrive
- // standing on loaded ground with wall collision and a non-empty immediate view. The far
- // ring (out to the streaming window) still drains at the per-frame budget after portal
- // space exits. Bigger = a more complete arrival but a longer portal-space hold.
- private const int TeleportNearRingRadius =
- AcDream.App.Streaming.WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius;
-
- ///
- /// Bind a sequence-correlated teleport Position to presentation and
- /// asynchronous streaming. Canonical physics has already accepted this
- /// packet; this method only establishes the destination viewport lifetime.
- ///
- private void AimTeleportDestination(
- AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
- {
- var playerController = _playerController
- ?? throw new InvalidOperationException(
- "A teleport destination was accepted before the local player controller existed.");
- var p = update.Position;
- int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
- int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
- uint streamingOriginLandblockId =
- AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
- _liveCenterX, _liveCenterY);
- var origin = new System.Numerics.Vector3(
- (lbX - _liveCenterX) * 192f,
- (lbY - _liveCenterY) * 192f,
- 0f);
- var worldPos = new System.Numerics.Vector3(
- p.PositionX, p.PositionY, p.PositionZ) + origin;
-
- // Retail preserves the full Position (objcell_id + Frame) through
- // SmartBox::TeleportPlayer @ 0x00453910. Dungeon EnvCells can have
- // negative local frame origins, so XYZ cannot identify the source
- // landblock (#215). Player crossing and streaming recentering are
- // separate while a prior destination is aimed but not yet placed.
- var landblockTransition =
- AcDream.App.Streaming.TeleportLandblockTransition.Classify(
- playerController.CellId,
- p.LandblockId,
- streamingOriginLandblockId);
- int oldLbX = (int)((landblockTransition.SourceLandblockId >> 24) & 0xFFu);
- int oldLbY = (int)((landblockTransition.SourceLandblockId >> 16) & 0xFFu);
- bool playerCrossesLandblock = landblockTransition.CrossesLandblock;
- bool streamingCenterChanges = landblockTransition.ChangesStreamingCenter;
-
- Console.WriteLine(
- $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
- $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, playerController.Position):F1}");
-
- System.Numerics.Vector3 newWorldPos;
- if (streamingCenterChanges)
- {
- // #145: retire the complete old streaming window while its shared
- // origin is still current. Destination streaming remains blocked
- // until every retained presentation ticket converges. This prevents
- // old terrain and collision, which were baked in the prior frame,
- // from overlapping the destination after the origin changes.
- var recenter = _streamingOriginRecenter
- ?? throw new InvalidOperationException(
- "A teleport changed streaming center before the recenter coordinator was wired.");
- recenter.Begin(
- lbX,
- lbY,
- IsSealedDungeonCell(p.LandblockId));
- newWorldPos = new System.Numerics.Vector3(
- p.PositionX, p.PositionY, p.PositionZ);
-
- }
- else
- {
- newWorldPos = worldPos;
- }
-
- // Do not snap here. The TAS holds portal space until the destination
- // near ring is resident, then emits Place exactly once.
- _pendingTeleportRot = new System.Numerics.Quaternion(
- p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
- _pendingTeleportPos = newWorldPos;
- _pendingTeleportCell = p.LandblockId;
- _teleportHoldSeconds = 0f;
- _teleportForced = false;
- _worldReveal?.Begin(AcDream.App.Streaming.WorldRevealKind.Portal);
- if (_streamingController is not null)
- {
- _streamingController.PriorityLandblockId =
- AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
- _streamingController.PriorityRadius = TeleportNearRingRadius;
- }
-
- AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
- "AIM", p.LandblockId,
- $"seq={update.TeleportSequence} lb={lbX},{lbY} " +
- $"indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} " +
- $"playerCross={playerCrossesLandblock} centerChange={streamingCenterChanges}");
- }
-
- ///
- /// End one logical teleport lifetime. This is the single symmetry seam
- /// used by successful completion, replacement by a newer sequence, and
- /// session teardown, so presentation state and destination state cannot
- /// leak independently.
- ///
- private void ResetTeleportTransitState(bool clearSession = false)
- {
- bool originRecenterConverged =
- _streamingOriginRecenter?.Reset(sessionEnding: clearSession) ?? true;
- if (clearSession)
- {
- _teleportTransit.ClearSession();
- _worldReveal?.Cancel();
- }
- else
- _teleportTransit.EndActive();
- _pendingTeleportPos = default;
- _pendingTeleportCell = 0u;
- _pendingTeleportRot = System.Numerics.Quaternion.Identity;
- _teleportHoldSeconds = 0f;
- _teleportForced = false;
-
- _teleportAnim.Reset();
- _teleportViewPlane.Reset();
- _portalTunnel?.Exit();
-
- if (_streamingController is not null)
- {
- _streamingController.PriorityLandblockId = 0u;
- _streamingController.PriorityRadius = 0;
- }
-
- if (clearSession && !originRecenterConverged)
- {
- throw new InvalidOperationException(
- "The ending session's streaming-origin retirement has not converged.");
- }
- }
// #145: the LANDBLOCK-relative (cell-local) position used to SEED the player
// body's cell-relative CellPosition. This is the ONE place the streaming center
@@ -3478,19 +3357,6 @@ public sealed class GameWindow : IDisposable
return worldPos - origin;
}
- ///
- /// worldReady for the TAS transit: is the player's teleport destination BOTH rendered
- /// and collidable so we can materialize? Retail crosses this edge after its blocking cell
- /// load. acdream's async equivalent must join the render publication barrier
- /// () with physics
- /// residency. Indoor destinations require the center render landblock + EnvCell; outdoor
- /// destinations require both domains for the priority near ring. An impossible claim
- /// returns true so the TAS stops holding and the forced placement surfaces the failure
- /// loudly rather than holding forever.
- ///
- private bool TeleportWorldReady(uint destCell)
- => EvaluateWorldRevealReadiness(destCell).IsReady;
-
private bool LoginWorldReady()
{
return TryGetLoginWorldCell(out uint cell)
@@ -3515,131 +3381,6 @@ public sealed class GameWindow : IDisposable
return cell != 0;
}
- // The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
- // once the destination is ready (or force-run on impossible/timeout, logged loud).
- private void PlaceTeleportArrival(
- System.Numerics.Vector3 destPos, uint destCell, bool forced)
- {
- var resolved = _physicsEngine.Resolve(
- destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight);
- var snappedPos = new System.Numerics.Vector3(
- resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
-
- if (forced)
- Console.WriteLine(
- $"live: teleport HOLD gave up (impossible/timeout) — force-snapping " +
- $"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}");
-
- if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
- {
- pe.SetPosition(snappedPos);
- pe.ParentCellId = resolved.CellId;
- pe.Rotation = _pendingTeleportRot;
- }
- _playerController.SetPosition(snappedPos, resolved.CellId,
- CellLocalForSeed(snappedPos, resolved.CellId));
- // R5-V3 (#171, diff-review find): retail teleport_hook's TAIL
- // (0x00514ed0 @0x00514f1b-0x00514f28) — after the manager teardown
- // SetPosition just ran (moveto cancel + own UnStick), the teleporting
- // object clears its OWN target subscription and tells every entity
- // WATCHING IT that it teleported (NotifyVoyeurOfEvent(Teleported)).
- // That notify is what tears down the mobs' sticks/target-tracking ON
- // the player — without it, a melee pack stuck to the player keeps
- // steering toward the post-teleport position at the 5× sticky follow
- // speed for up to the 1 s lease (non-retail lurch on every recall).
- _playerHost?.NotifyTeleported();
- // Face the server-specified destination heading (retail drops you facing a fixed
- // direction). The render entity already got _pendingTeleportRot above; sync the
- // controller yaw so the camera + movement frame match it instead of the stale
- // pre-teleport facing.
- _playerController.SetBodyOrientation(_pendingTeleportRot);
-
- _chaseCamera?.Update(snappedPos, _playerController.Yaw);
- // SmartBox::PlayerPositionUpdated (0x00453870) calls
- // set_viewer(player_pos, reset_sought=1): the viewer and sought
- // position both restart at the player, then the ordinary per-frame
- // 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);
- _liveSpatialReconciler.Reconcile();
-
- AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
- "PLACED", resolved.CellId, $"forced={forced}");
- // Do NOT flip to InWorld or send LoginComplete here — the player materializes while
- // the portal viewport is active and stays input-frozen (PortalSpace) until the TAS
- // restores the destination projection and fires FireLoginComplete. This is the
- // retail "pop out the other side" sequence.
- Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
- }
-
- ///
- /// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). Freeze movement
- /// input (PortalSpace) and begin retail portal transit. The per-frame TAS tick holds
- /// the player behind the portal viewport until the destination is resident, then materializes
- /// them (Place) and, after the destination view-plane restores, regains control + acks the server
- /// (FireLoginComplete).
- ///
- private void OnTeleportStarted(uint sequence)
- {
- ushort teleportSequence = (ushort)sequence;
- if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, teleportSequence)
- || !_teleportTransit.CanBegin(teleportSequence))
- return;
-
- _gameplayInputFrame?.EndMouseLook();
- // A fresh sequence is a new logical transit. Discard the prior
- // destination and presentation before this sequence can observe them;
- // its destination PositionUpdate may arrive on a later network tick.
- ResetTeleportTransitState();
- _teleportTransit.QueueStart(teleportSequence);
- TryActivatePendingTeleportPresentation();
- Console.WriteLine($"live: teleport queued (seq={sequence})");
- }
-
- ///
- /// Converge a queued F751 notification with the ordinary live-player
- /// projection lifecycle. Usually this activates synchronously. If a
- /// session transition has not materialized the player projection yet, the
- /// notification and any correlated Position remain queued until it does.
- ///
- private void TryActivatePendingTeleportPresentation()
- {
- if (!_teleportTransit.HasPendingStart)
- return;
-
- // Retail has no detached fly/orbit gameplay mode. If acdream's live
- // developer camera currently owns the view, restore the existing
- // player-mode lifecycle before transit so placement still has the
- // canonical local physics controller and chase-camera handoff.
- var playerController = _playerController;
- if (playerController is null)
- {
- if (!_entitiesByServerGuid.ContainsKey(_playerServerGuid))
- return;
-
- _playerMode = true;
- if (!EnterPlayerModeNow(loggingTag: "teleport"))
- {
- _playerMode = false;
- return;
- }
- playerController = _playerController;
- }
- if (playerController is null)
- return;
- playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
- _teleportHoldSeconds = 0f;
- _teleportForced = false;
- _teleportViewPlane.Begin(
- _cameraController?.Active.Projection ?? System.Numerics.Matrix4x4.Identity);
- _teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal);
- bool hasBufferedDestination = _teleportTransit.Activate(
- out var bufferedDestination);
- if (hasBufferedDestination)
- AimTeleportDestination(bufferedDestination);
- Console.WriteLine($"live: teleport presentation started (seq={_teleportTransit.Sequence})");
- }
-
///
/// Server-sent direct PhysicsScript (F754). EntityEffectController owns
/// GUID translation and pre-materialization queueing.
@@ -3874,79 +3615,11 @@ public sealed class GameWindow : IDisposable
_liveFrameCoordinator.Tick(frameDelta);
_liveEntityLiveness?.Tick(ClientTimerNow());
- // Usually F751 activates immediately. This no-op convergence check
- // covers the session edge where the canonical player exists before
- // its normal live projection/controller has materialized.
- TryActivatePendingTeleportPresentation();
-
- // Retail teleport transit. Runs AFTER streaming (which priority-applies the
- // destination landblock this frame) and the live-session drain, so a destination
- // that became resident this frame materializes the same frame. The TAS holds at
- // Tunnel until worldReady, fires Place (materialize, hidden), then after the min-
- // continue + view-plane transitions fires FireLoginComplete (regain control + ack).
- // PortalTunnelPresentation replaces the world viewport through the tunnel-family states;
- // retail switches the two 3-D viewports directly, below the retained UI.
- if (_teleportTransit.IsActive)
- {
- bool haveDest = _pendingTeleportCell != 0u;
- bool originReady = _streamingOriginRecenter?.IsPending != true;
- bool ready = haveDest
- && originReady
- && TeleportWorldReady(_pendingTeleportCell);
- if (haveDest && originReady && !ready)
- {
- _teleportHoldSeconds += frameDelta;
- if (_teleportHoldSeconds >= TeleportMaxHoldSeconds)
- {
- ready = true;
- _teleportForced = true;
- }
- }
-
- int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
- var (snap, evts) = _teleportAnim.Tick(frameDelta, ready, tunnelFrame);
- _teleportViewPlane.Update(snap);
-
- foreach (var e in evts)
- {
- switch (e)
- {
- case AcDream.Core.World.TeleportAnimEvent.Place:
- PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced);
- _worldReveal?.ObserveMaterialized();
- if (_streamingController is not null)
- {
- _streamingController.PriorityLandblockId = 0u;
- _streamingController.PriorityRadius = 0;
- }
- break;
- case AcDream.Core.World.TeleportAnimEvent.EnterTunnel:
- _portalTunnel?.Enter();
- break;
- case AcDream.Core.World.TeleportAnimEvent.PlayExitSound:
- _portalTunnel?.Exit();
- break;
- case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete:
- if (_playerController is not null)
- _playerController.State = AcDream.App.Input.PlayerState.InWorld;
- // holtburger client/messages.rs:434 — re-send LoginComplete after
- // each portal transition.
- LiveSession?.SendGameAction(
- AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
- _worldReveal?.Complete();
- ResetTeleportTransitState();
- break;
- default:
- // The retained audio engine does not yet own the
- // ClientUISystem sound-table enum path. Enter/exit
- // visual edges are handled above.
- break;
- }
- }
-
- _portalTunnel?.Tick(frameDelta);
- }
-
+ // Retail teleport transit runs after streaming and inbound state so a
+ // newly resident destination can materialize in this same frame. The
+ // focused owner also converges an F751 that arrived before the local
+ // player projection/controller became available.
+ _localPlayerTeleport!.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.
@@ -3958,7 +3631,7 @@ public sealed class GameWindow : IDisposable
// returns true on the firing tick (one-shot); subsequent ticks
// are no-ops. Skipped offline (no active session → IsLiveInWorld
// predicate stays false). Cancelled by manual fly-toggle in
- // OnInputAction (Ctrl+Tab → TogglePlayerMode) or DebugPanel.
+ // OnInputAction (Ctrl+Tab) or DebugPanel.
_playerModeAutoEntry?.TryEnter();
if (_cameraController is null || _input is null) return;
@@ -4138,7 +3811,8 @@ public sealed class GameWindow : IDisposable
// gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the
// normal SmartBox viewport and UIElement_Viewport's portal
// CreatureMode; it does not redraw the world behind portal space.
- bool portalViewportVisible = _portalTunnel?.IsVisible == true;
+ bool portalViewportVisible =
+ _localPlayerTeleport?.IsPortalViewportVisible == true;
if (portalViewportVisible)
_particleVisibility.Reset();
@@ -4211,8 +3885,9 @@ public sealed class GameWindow : IDisposable
using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload))
{
_wbMeshAdapter?.Tick();
- uint revealCell = _teleportTransit.IsActive
- ? _pendingTeleportCell
+ uint revealCell = _localPlayerTeleport?.ActiveDestinationCell is uint teleportCell
+ && teleportCell != 0u
+ ? teleportCell
: IsLiveModeWaitingForLogin && TryGetLoginWorldCell(out uint loginCell)
? loginCell
: 0u;
@@ -4264,7 +3939,8 @@ public sealed class GameWindow : IDisposable
{
_retailAlphaQueue.BeginFrame();
var activeCamera = _cameraController.Active;
- var camera = _teleportViewPlane.ApplyTo(activeCamera);
+ var camera = _localPlayerTeleport?.ApplyViewPlane(activeCamera)
+ ?? activeCamera;
var worldProjection = camera.Projection;
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
_retailSelectionScene?.SetViewFrustum(frustum);
@@ -5195,12 +4871,10 @@ public sealed class GameWindow : IDisposable
// while this replacement scene is visible. This scene and
// its projection transition draws BEFORE retained UI, so chat, windows,
// cursor, and toolbar remain visible and interactive in transit.
- var portalProjection = _teleportViewPlane.Apply(
- _cameraController!.Active.Projection);
- _portalTunnel?.Draw(
+ _localPlayerTeleport?.DrawPortalViewport(
_window!.Size.X,
_window.Size.Y,
- portalProjection);
+ _cameraController!.Active.Projection);
// Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the
// viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window
@@ -5289,7 +4963,7 @@ public sealed class GameWindow : IDisposable
string flyLabel = _cameraController.IsFlyMode
? "Exit Free-Fly Mode" : "Enter Free-Fly Mode";
if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F"))
- ToggleFlyOrChase();
+ _playerModeController?.ToggleFlyOrChase();
}
// A8.F: spring-arm camera collision (live A/B toggle).
if (ImGuiNET.ImGui.MenuItem("Collide Camera (spring arm)", "",
@@ -5480,54 +5154,6 @@ public sealed class GameWindow : IDisposable
LastFrameProfile: _frameProfiler.LastReport);
}
- ///
- /// Phase 6.4: advance every animated entity's frame counter by
- /// * Framerate, wrapping around the cycle's
- /// [LowFrame..HighFrame] interval, then rebuild that entity's
- /// MeshRefs from the new frame's per-part transforms. Static
- /// entities (no LiveEntityAnimationState record) are untouched. The static
- /// renderer reads the new MeshRefs on the next Draw call.
- ///
- private void AdvanceLocalPlayerAnimationRoot(
- float dt,
- AcDream.Core.Physics.Motion.MotionDeltaFrame output)
- {
- ArgumentNullException.ThrowIfNull(output);
- output.Reset();
-
- if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
- || !_animatedEntities.TryGetValue(entity.Id, out var ae)
- || ae.Sequencer is not { } sequencer
- || _liveEntities?.ShouldAdvanceRootRuntime(_playerServerGuid) == false
- || _liveEntities?.IsHidden(_playerServerGuid) == true)
- {
- return;
- }
-
- if (_liveEntities?.TryGetRecord(_playerServerGuid, out LiveEntityRecord bindingRecord) == true)
- _animationPresenter.PrepareAnimation(bindingRecord, ae);
-
- DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch;
- rootFrame.Origin = System.Numerics.Vector3.Zero;
- rootFrame.Orientation = System.Numerics.Quaternion.Identity;
- ae.PreparedSequenceFrames = ae.CaptureSequenceFrames(
- sequencer.Advance(dt, rootFrame));
- ae.SequenceAdvancedBeforeAnimationPass = true;
-
- output.Origin = rootFrame.Origin;
- output.Orientation = rootFrame.Orientation;
- }
-
- private void CaptureLocalPlayerAnimationHooks()
- {
- if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
- && _animatedEntities.TryGetValue(entity.Id, out var ae)
- && ae.Sequencer is { } sequencer)
- {
- _animationHookFrames?.Capture(ae.Entity.Id, sequencer);
- }
- }
-
// IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass
// narrowing that dropped settled-open doors / faded-out walls back onto the
// stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the
@@ -6829,6 +6455,7 @@ public sealed class GameWindow : IDisposable
{
if (newSize.X <= 0 || newSize.Y <= 0) return;
_gl?.Viewport(0, 0, (uint)newSize.X, (uint)newSize.Y);
+ _viewportAspect.Update(newSize.X, newSize.Y);
_cameraController?.SetAspect(newSize.X / (float)newSize.Y);
// Resize is always a force-reset — the alternative ("clamp
// existing positions") would require tracking each panel's
@@ -7106,11 +6733,11 @@ public sealed class GameWindow : IDisposable
// to land in Holtburg-orbit on toggle-back. With a chase
// camera available, prefer Fly→Chase / Chase→Fly so the
// user round-trips back to the same player view.
- ToggleFlyOrChase();
+ _playerModeController?.ToggleFlyOrChase();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode:
- TogglePlayerMode();
+ _playerModeController?.Toggle();
break;
case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry:
@@ -7141,14 +6768,7 @@ public sealed class GameWindow : IDisposable
else if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor
else if (_playerMode)
- {
- _gameplayInputFrame?.EndMouseLook();
- _playerMode = false;
- _cameraController?.ExitChaseMode();
- _playerController = null;
- _chaseCamera = null;
- _retailChaseCamera = null;
- }
+ _playerModeController?.Exit();
else
_window!.Close();
break;
@@ -7229,9 +6849,6 @@ public sealed class GameWindow : IDisposable
private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement)
=> _selectionInteractions?.SendPickup(itemGuid, destinationContainerId, placement);
- private void OnAutoWalkArrivedSendDeferredAction()
- => _selectionInteractions?.OnNaturalMoveToComplete();
-
private uint? SelectClosestCombatTarget(bool showToast)
=> _selectionInteractions?.SelectClosestCombatTarget(showToast);
@@ -7246,7 +6863,8 @@ public sealed class GameWindow : IDisposable
return (System.Numerics.Matrix4x4.Identity,
System.Numerics.Matrix4x4.Identity,
System.Numerics.Vector2.Zero);
- var camera = _teleportViewPlane.ApplyTo(_cameraController.Active);
+ var camera = _localPlayerTeleport?.ApplyViewPlane(_cameraController.Active)
+ ?? _cameraController.Active;
return (camera.View, camera.Projection,
new System.Numerics.Vector2(_window.Size.X, _window.Size.Y));
}
@@ -7254,41 +6872,6 @@ public sealed class GameWindow : IDisposable
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
=> _worldSelectionQuery?.ResolveVividTargetInfo(guid);
- private void TogglePlayerMode()
- {
- // Phase B.2 guard: only active when a live session is in-world.
- if (_liveSessionController?.IsInWorld != true)
- return;
-
- // Manual toggle pre-empts the K.2 auto-entry trigger regardless
- // of direction — entering means "I'm in player mode now"; exiting
- // means "I want fly, don't snap me back".
- _playerModeAutoEntry?.Cancel();
-
- _playerMode = !_playerMode;
- if (_playerMode)
- {
- if (!EnterPlayerModeNow(loggingTag: "Tab"))
- _playerMode = false;
- }
- else
- {
- _gameplayInputFrame?.EndMouseLook();
- _cameraController?.ExitChaseMode();
- _playerController = null;
- _chaseCamera = null;
- _retailChaseCamera = null;
- }
- }
-
- ///
- /// K.2: callback the
- /// guard invokes once login + entity stream + controller readiness
- /// have all converged. Sets _playerMode = true and runs the
- /// same construction path the manual Tab handler uses. Predicates on
- /// the guard already guarantee _entitiesByServerGuid contains
- /// the player guid, so the inner TryGetValue is a fast-path success.
- ///
// #107 (2026-06-10): memoized "this indoor spawn claim can never hydrate"
// check against the dat's LandBlockInfo.NumCells. Used by the auto-entry
// hold so a garbage claim doesn't stall login forever; the Resolve-head
@@ -7317,449 +6900,6 @@ public sealed class GameWindow : IDisposable
return unhydratable;
}
- // #135: is this server-sent cell id a SEALED dungeon EnvCell — an indoor cell
- // (low 16 bits >= 0x0100) whose EnvCell dat flags lack SeenOutside? Distinguishes
- // a real dungeon (collapse streaming to its single landblock) from a building
- // interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from
- // an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as
- // the hydration path (EnvCellLandblockBuildBuilder) and as the physics
- // CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse
- // decision matches the eventual gate decision exactly. Returns false when the dat
- // lacks the cell (out-of-range index / missing record) so we never collapse on a
- // guess. The dat read is reentrant-safe under _datLock (Monitor) — callers may
- // already hold it (the login spawn handler does).
- private bool IsSealedDungeonCell(uint cellId)
- {
- // Not an EnvCell: the sub-0x0100 outdoor sub-cells AND the 0xFFFE/0xFFFF
- // structural shell ids (LandBlockInfo / LandBlock heightmap). A naive
- // `< 0x0100` test MISSES 0xFFFF (65535 is not < 256), and Get on
- // 0xXXYYFFFF would then type-confuse the LandBlock record living at that id as
- // an EnvCell (its bytes unpack to a bogus Flags value). A real spawn/teleport
- // position never carries a shell id, but exclude them so the read is sound.
- uint low = cellId & 0xFFFFu;
- if (low < 0x0100u || low >= 0xFFFEu) return false;
- if (_dats is null) return false;
- DatReaderWriter.DBObjs.EnvCell? envCell;
- lock (_datLock)
- envCell = _dats.Get(cellId);
- return envCell is not null
- && !envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside);
- }
-
- private void EnterPlayerModeFromAutoEntry()
- {
- _playerMode = true;
- if (!EnterPlayerModeNow(loggingTag: "auto-entry"))
- {
- // Defense in depth: if construction failed (e.g. entity
- // disappeared between predicate eval and here) drop back
- // out cleanly. Re-arm so a later Tab still works.
- _playerMode = false;
- }
- else
- {
- Console.WriteLine($"live: auto-entered player mode for 0x{_playerServerGuid:X8}");
- }
- }
-
- ///
- /// K-fix3 (2026-04-26): the right "toggle free-fly mode" routine
- /// when a chase camera is in play.
- /// only knows Fly↔Orbit and would strand a player-mode user in the
- /// orbit camera (Holtburg view) when they exit fly. This wrapper
- /// gives the round-trip the user actually wants:
- ///
- /// - Chase → Fly: cancel auto-entry (user's choice wins) and
- /// switch to fly camera while keeping _playerMode = true +
- /// the chase camera alive so we can return.
- /// - Fly → Chase: when _playerMode is still true and the
- /// chase camera survived, re-enter chase via
- /// .
- /// - Otherwise (no chase available): the original Fly↔Orbit
- /// toggle for offline / pre-login flows.
- ///
- ///
- private void ToggleFlyOrChase()
- {
- if (_cameraController is null) return;
- _playerModeAutoEntry?.Cancel();
-
- if (_cameraController.IsFlyMode
- && _playerMode
- && _chaseCamera is not null)
- {
- if (_retailChaseCamera is null)
- {
- _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
- {
- Aspect = _chaseCamera.Aspect,
- CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine),
- };
- }
- _cameraController.EnterChaseMode(_chaseCamera, _retailChaseCamera);
- return;
- }
- _cameraController.ToggleFly();
- }
-
- ///
- /// K.2: shared "construct controller + chase camera + enter chase
- /// mode" body extracted from the on-enter branch of
- /// . Returns false when the player
- /// entity isn't in _entitiesByServerGuid yet — caller must
- /// reset _playerMode in that case.
- ///
- private bool EnterPlayerModeNow(string loggingTag)
- {
- if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
- {
- Console.WriteLine($"live: {loggingTag} — player entity 0x{_playerServerGuid:X8} not found yet");
- return false;
- }
-
- if (_liveEntities is not { } playerLiveEntities
- || !playerLiveEntities.TryGetRecord(
- _playerServerGuid,
- out LiveEntityRecord playerRecord))
- {
- Console.WriteLine(
- $"live: {loggingTag} — player record 0x{_playerServerGuid:X8} not found yet");
- return false;
- }
- _playerController = new AcDream.App.Input.PlayerMovementController(
- _physicsEngine,
- playerRecord.ObjectClock);
- _playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState);
-
- // R4-V5: the local player's verbatim MoveToManager — same seam
- // wiring shape as EnsureRemoteMotionBindings, with three
- // player-specific differences: (a) heading reads/writes use the
- // controller's Yaw projection as the explicit Frame::get_heading /
- // set_heading seam, while ordinary object ticks retain the complete
- // authoritative body quaternion; (b) the contact seam reads the REAL Contact
- // transient bit (retail UseTime gates transient_state & 1 —
- // remotes force-assert Contact+OnWalkable every grounded tick, so
- // OnWalkable was equivalent there); (c) isInterpolating is false —
- // the local player has no InterpolationManager. Own radius/height
- // are the real setup cylsphere values (R5-V3 — lazy reads because
- // this method caches the player Setup a few paragraphs further
- // down). The setHeading `send` argument remains unused because retail
- // CPhysicsObj::set_heading (0x00514160) also ignores that parameter.
- // Stationary heading changes reach the wire through the shared
- // ShouldSendPositionEvent full-Frame comparison (0x006B45E0), including
- // orientation, rather than through a MoveTo-specific send side channel.
- var pcMoveTo = _playerController;
- // R5-V2: forward-declared so the player MoveToManager's target seams
- // route into the player's TargetManager (retail CPhysicsObj::set_target).
- EntityPhysicsHost playerHost = null!;
- // R5-V5: the construction is the player MovementManager's
- // MoveToFactory (same facade shape as EnsureRemoteMotionBindings);
- // MakeMoveToManager() below invokes it once, after playerHost exists
- // for the sticky binds.
- _playerController.Movement.MoveToFactory = () =>
- {
- var playerMoveTo = new AcDream.Core.Physics.Motion.MoveToManager(
- pcMoveTo.Motion,
- stopCompletely: () => pcMoveTo.StopCompletelyAtPhysicsObjectBoundary(),
- getPosition: () => new AcDream.Core.Physics.Position(
- pcMoveTo.CellId, pcMoveTo.Position, pcMoveTo.BodyOrientation),
- getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw),
- setHeading: (h, _) => pcMoveTo.Yaw =
- AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h),
- getOwnRadius: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
- getOwnHeight: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Height,
- contact: () => pcMoveTo.BodyInContact,
- isInterpolating: () => false,
- getVelocity: () => pcMoveTo.BodyVelocity,
- getSelfId: () => _playerServerGuid,
- // R5-V2: retail CPhysicsObj::set_target/clear_target/quantum → the
- // player's TargetManager (replaces the AP-79 _playerMoveToTarget* poll).
- setTarget: (ctx, tlid, radius, q) => playerHost.SetTarget(ctx, tlid, radius, q),
- clearTarget: () => playerHost.ClearTarget(),
- getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(),
- setTargetQuantum: q => playerHost.TargetManager.SetTargetQuantum(q),
- curTime: () => pcMoveTo.SimTimeSeconds);
-
- // AD-27 re-anchored (was the deleted AutoWalkArrived event): fire
- // the deferred close-range Use/PickUp action when the moveto
- // completes NATURALLY (MoveToComplete is the documented client
- // seam; it never fires on CancelMoveTo, so a user-input cancel
- // doesn't send the action — same contract the old "arrived"-only
- // event had).
- playerMoveTo.MoveToComplete = err =>
- {
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
- Console.WriteLine($"[autowalk-end] reason=complete err={err}");
- if (err == AcDream.Core.Physics.WeenieError.None)
- OnAutoWalkArrivedSendDeferredAction();
- else
- _selectionInteractions?.OnMoveToCancelled(err);
- };
- playerMoveTo.MoveToCancelled = err =>
- _selectionInteractions?.OnMoveToCancelled(err);
-
- // R5-V3 (#171, retires TS-39 — player side): bind the sticky
- // seams to the player host's PositionManager (same trio as the
- // remote bind: BeginNextNode arrival StickTo @0x00529d3a,
- // PerformMovement-head Unstick).
- playerMoveTo.StickTo = (tlid, radius, height) =>
- playerHost.PositionManager.StickTo(tlid, radius, height);
- playerMoveTo.Unstick = playerHost.PositionManager.UnStick;
- return playerMoveTo;
- };
-
- // R5-V2: the player's CPhysicsObj stand-in + TargetManager. Position is
- // the player's WORLD position (WorldEntity.Position — what the AP-79
- // poll used), so an NPC watching the player receives the identical
- // position. Stored on the exact live record so those NPCs' GetObjectA
- // resolves the player; HandleTargetting is ticked in the player
- // pre-Update block.
- var exactPlayerMovement = pcMoveTo.Movement;
- var configuredPlayerHost = new EntityPhysicsHost(
- _playerServerGuid,
- getPosition: () => new AcDream.Core.Physics.Position(
- playerRecord.FullCellId,
- playerRecord.WorldEntity?.Position ?? pcMoveTo.Position,
- pcMoveTo.BodyOrientation),
- getVelocity: () => pcMoveTo.BodyVelocity,
- getRadius: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
- inContact: () => pcMoveTo.BodyInContact,
- minterpMaxSpeed: () => pcMoveTo.Motion.GetMaxSpeed(),
- curTime: () => pcMoveTo.SimTimeSeconds,
- physicsTimerTime: () => pcMoveTo.SimTimeSeconds,
- getObjectA: _liveEntityMotionBindings.ResolvePhysicsHost,
- // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head:
- // MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade
- // relay); the host chains the PositionManager leg after it.
- handleUpdateTarget: info =>
- {
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
- {
- Console.WriteLine(
- $"[autowalk-target] object=0x{info.ObjectId:X8} "
- + $"status={info.Status} context={info.ContextId} "
- + $"target=({info.TargetPosition.Frame.Origin.X:F2},"
- + $"{info.TargetPosition.Frame.Origin.Y:F2},"
- + $"{info.TargetPosition.Frame.Origin.Z:F2})");
- }
- exactPlayerMovement.HandleUpdateTarget(info);
- },
- interruptCurrentMovement: () => exactPlayerMovement.CancelMoveTo(
- AcDream.Core.Physics.WeenieError.ActionCancelled));
- playerHost = EntityPhysicsHost.InstallOrRebind(
- playerLiveEntities,
- playerRecord,
- configuredPlayerHost);
- _playerHost = playerHost;
-
- // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
- // player MoveToManager via the factory above (playerHost now exists
- // for the sticky binds inside it). The UM-funnel-head unstick
- // (CPhysicsObj::unstick_from_object 0x0050eaea) binds on the interp
- // beside it, and the controller takes the PositionManager handoff it
- // drives at the retail UpdatePositionInternal/UpdateObjectInternal
- // points (AdjustOffset/UseTime).
- var playerMovement = _playerController.Movement;
- playerMovement.MakeMoveToManager();
- _playerController.Motion.UnstickFromObject = playerHost.PositionManager.UnStick;
- _playerController.PositionManager = playerHost.PositionManager;
-
- // TS-36 RETIRED: the interp's interrupt seam is retail's
- // interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
- // chain (raw 278189-278200) — since R5-V5 the literal facade relay
- // (0x005241b0). Every DoMotion/StopMotion/StopCompletely/jump/
- // set_hold_run cancel site now genuinely cancels a running moveto
- // (V2's reentrancy tests prove the chain is inert-safe). Captures
- // THIS controller's facade (not the _playerController field) so a
- // Tab-toggle's stale interp keeps cancelling its own manager.
- _playerController.Motion.InterruptCurrentMovement = () =>
- {
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled
- && playerMovement.IsMovingTo())
- Console.WriteLine("[autowalk-end] reason=interrupt");
- playerMovement.CancelMoveTo(AcDream.Core.Physics.WeenieError.ActionCancelled);
- };
-
- // K-fix7 (2026-04-26): if PlayerDescription already arrived, the
- // server's Run / Jump skill values are cached here — push them
- // into the freshly-constructed controller so the runRate /
- // jump-arc formulas use real character data instead of the
- // hardcoded ACDREAM_*_SKILL defaults. PD always arrives at
- // login before auto-entry fires, so this branch normally hits.
- if (_lastSeenRunSkill >= 0 && _lastSeenJumpSkill >= 0)
- {
- _playerController.SetCharacterSkills(_lastSeenRunSkill, _lastSeenJumpSkill);
- Console.WriteLine($"live: {loggingTag} — applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}");
- }
- // Read the real step heights from the player's Setup dat.
- // L.2.3a (2026-04-29): retail's Setup.StepUpHeight for humans is
- // ~0.4 m, NOT 2 m. With 2 m fallback the step-up scan reached
- // small-building roofs and teleported the player onto them. Same
- // for StepDownHeight — was hardcoded 0.04 m, causing stair-top
- // contact-plane gaps. Both now come from Setup with retail-realistic
- // 0.4 m fallbacks.
- if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
- {
- // A.5 T10: lock around _dats.Get — worker thread may be
- // building a landblock mesh concurrently.
- DatReaderWriter.DBObjs.Setup? playerSetup;
- lock (_datLock) { playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId); }
- if (playerSetup is not null)
- _physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup);
- _playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f)
- ? playerSetup.StepUpHeight
- : 0.4f;
- _playerController.StepDownHeight = (playerSetup is not null && playerSetup.StepDownHeight > 0f)
- ? playerSetup.StepDownHeight
- : 0.4f;
- // L.2.3f (2026-04-29): diagnostic — confirm what the actual
- // values from the player's Setup dat are. Retail's spec says ~0.4 m
- // for humans, but we want to verify rather than guess. If the
- // dat-derived value is large (e.g. 1.5 m+) it explains why the
- // player can mount steep roofs via the step-up scan reach.
- Console.WriteLine(
- $"physics: player step heights — StepUp={_playerController.StepUpHeight:F3} m " +
- $"(Setup.StepUpHeight={(playerSetup?.StepUpHeight ?? 0f):F3}), " +
- $"StepDown={_playerController.StepDownHeight:F3} m " +
- $"(Setup.StepDownHeight={(playerSetup?.StepDownHeight ?? 0f):F3})");
- }
- else
- {
- _playerController.StepUpHeight = 0.4f;
- _playerController.StepDownHeight = 0.4f;
- Console.WriteLine($"physics: player step heights — defaulting to 0.4 m (no setup dat)");
- }
- // Issue #92 (2026-05-20): seed the resolver with the SERVER's
- // authoritative cell id from the spawn message instead of a
- // hardcoded outdoor sentinel (`landblockPrefix | 0x0001`). When
- // the player logs in INSIDE a building (server reports an indoor
- // cell like `0xA9B4015A`), the old sentinel forced the resolver
- // into the outdoor seed branch — for the first several ticks
- // CheckBuildingTransit hadn't yet picked up the interior cell,
- // so the player was classified outdoor, indoor BSP queries
- // didn't run, and exterior walls were passable until the player
- // moved far enough INWARD that the sphere overlap eventually
- // promoted them. Visible symptom: "logged in inside the inn,
- // ran out through the exterior wall."
- //
- // Fall back to the old sentinel when no spawn record is cached
- // (defensive — should never fire in live play because the
- // The inbound-state snapshot was accepted before render hydration.
- // before EnterPlayerModeNow could possibly be reached).
- uint pinitCellId;
- if (LastSpawns.TryGetValue(_playerServerGuid, out var playerSpawn)
- && playerSpawn.Position is { } spawnPos
- && spawnPos.LandblockId != 0)
- {
- pinitCellId = spawnPos.LandblockId;
- }
- else
- {
- int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f);
- int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f);
- pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u;
- }
- // R4-V5 moveto-stall fix #2 (2026-07-03, the [autowalk-gate] probe's
- // one-immortal-node finding): the sequencer/sink bind block MUST run
- // BEFORE the initial SetPosition. SetPosition → StopCompletely
- // enqueues the A9 pending_motions node whose completable partner is
- // the DefaultSink's type-5 motion-table entry — with the sink still
- // null at login, the node was ORPHANED, and pending_motions never
- // reached empty again (head-pop-any just relabels a backlog), so
- // the MoveToManager's wait-for-anims gate never opened: every
- // server MoveTo armed but the body never moved.
- if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE)
- && playerAE.Sequencer is { } playerSeq)
- {
- _playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
- _playerController.ObjectScale = playerAE.Scale;
- _playerController.AttachAnimationRootMotionSource(
- AdvanceLocalPlayerAnimationRoot,
- CaptureLocalPlayerAnimationHooks);
- // R3-W4: bind the player interp's retail seams to the player
- // sequencer — LeaveGround/HitGround strip transition links here
- // (the K-fix18 replacement).
- // #174 (2026-07-05): the seam is HandleEnterWorld (strip + FULL
- // queue drain), not the bare sequence strip — see the remote
- // bind site's comment (retail 0x0050fe20 → 0x00517d70 →
- // 0x0051bdd0). The bare strip orphaned every pending manager
- // node on each jump's LeaveGround; the local player's
- // pending_motions then never drained and every armed moveto
- // starved (#174: doors unusable after the first jump/run).
- _playerController.Motion.RemoveLinkAnimations = () => playerSeq.Manager.HandleEnterWorld();
- _playerController.Motion.InitializeMotionTables =
- () => playerSeq.Manager.InitializeState();
- _playerController.Motion.CheckForCompletedMotions =
- playerSeq.Manager.CheckForCompletedMotions;
- // R3-W6: the player's cycles are driven through the same dispatch
- // sink as remotes; both consume CSequence's complete root Frame.
- _playerController.Motion.DefaultSink =
- new AcDream.Core.Physics.Motion.MotionTableDispatchSink(playerSeq);
- }
-
- var initResult = _physicsEngine.Resolve(
- playerEntity.Position, pinitCellId,
- System.Numerics.Vector3.Zero, 100f);
-
- // Retail enter_world -> SetPosition(flags 0x11) runs
- // CTransition::find_placement_pos after the initial cell/environment
- // placement. That second phase is what seats a relogging character
- // beside a monster that moved onto the saved logout position. The
- // legacy Resolve above supplies the already-ported AdjustPosition +
- // floor snap; this call supplies the missing object-aware ring search.
- var (placementRadius, placementHeight) =
- _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity);
- if (placementRadius < 0.05f)
- {
- placementRadius = 0.48f;
- placementHeight = 1.835f;
- }
- var placementResult = _physicsEngine.ResolvePlacement(
- initResult.Position,
- initResult.CellId,
- placementRadius,
- placementHeight,
- _playerController.StepUpHeight,
- _playerController.StepDownHeight,
- AcDream.Core.Physics.ObjectInfoState.IsPlayer
- | AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
- playerEntity.Id);
- if (placementResult.Ok)
- initResult = placementResult;
-
- _playerController.SetPosition(initResult.Position, initResult.CellId,
- CellLocalForSeed(initResult.Position, initResult.CellId));
- // #111 (2026-06-10): snap the ENTITY too — parity with the
- // teleport-arrival path (entity.SetPosition + ParentCellId at
- // GameWindow.cs:4914). Without this, the renderer keeps drawing the
- // character at the server-restored position (ACE restored z=99.475;
- // physics grounded to the 94.0 floor; the user saw the char floating
- // 2 m up against the window while physics stood on the floor).
- playerEntity.SetPosition(initResult.Position);
- playerEntity.ParentCellId = initResult.CellId;
- SyncLocalPlayerShadow(playerEntity, initResult.CellId, force: true);
-
- _playerController.SetBodyOrientation(playerEntity.Rotation);
-
- _chaseCamera = new AcDream.App.Rendering.ChaseCamera
- {
- Aspect = _window!.Size.X / (float)_window.Size.Y,
- };
- _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
- {
- Aspect = _window!.Size.X / (float)_window.Size.Y,
- CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine),
- };
- _cameraController?.EnterChaseMode(_chaseCamera, _retailChaseCamera);
- // K-fix1 (2026-04-26): latch the "we have entered chase at least
- // once" flag so the live-mode pre-login render gate stops
- // suppressing the scene. From here on, the orbit camera (if the
- // user ever returns to it via Escape) shows whatever's loaded —
- // we don't re-blank the world.
- _chaseModeEverEntered = true;
- return true;
- }
-
///
/// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies
/// the currently-active mode's sensitivity (chase / fly / orbit) by the
@@ -8098,6 +7238,8 @@ public sealed class GameWindow : IDisposable
[
new("portal tunnel", () =>
{
+ _localPlayerTeleport?.Dispose();
+ _localPlayerTeleport = null;
_portalTunnel?.Dispose();
_portalTunnel = null;
}),
diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
new file mode 100644
index 00000000..e287f5dc
--- /dev/null
+++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
@@ -0,0 +1,761 @@
+using System.Numerics;
+using AcDream.App.Input;
+using AcDream.App.Net;
+using AcDream.App.Physics;
+using AcDream.App.Rendering;
+using AcDream.App.Update;
+using AcDream.App.World;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Core.Rendering;
+using AcDream.Core.World;
+
+namespace AcDream.App.Streaming;
+
+internal interface ILocalPlayerTeleportNetworkSink
+{
+ void OnTeleportStarted(uint sequence);
+
+ void OfferDestination(
+ WorldSession.EntityPositionUpdate update,
+ bool teleportTimestampAdvanced);
+
+ void ResetSession();
+}
+
+///
+/// Construction-order bridge for the inbound session. It carries no teleport
+/// state: after binding, every packet and reset is forwarded to the one
+/// owner.
+///
+internal sealed class DeferredLocalPlayerTeleportNetworkSink
+ : ILocalPlayerTeleportNetworkSink
+{
+ private ILocalPlayerTeleportNetworkSink? _inner;
+
+ public void Bind(ILocalPlayerTeleportNetworkSink inner)
+ {
+ ArgumentNullException.ThrowIfNull(inner);
+ if (Interlocked.CompareExchange(ref _inner, inner, null) is not null)
+ throw new InvalidOperationException("The local teleport sink is already bound.");
+ }
+
+ public void OnTeleportStarted(uint sequence) => Required().OnTeleportStarted(sequence);
+
+ public void OfferDestination(
+ WorldSession.EntityPositionUpdate update,
+ bool teleportTimestampAdvanced) =>
+ Required().OfferDestination(update, teleportTimestampAdvanced);
+
+ public void ResetSession() => Required().ResetSession();
+
+ private ILocalPlayerTeleportNetworkSink Required() =>
+ _inner ?? throw new InvalidOperationException(
+ "The local teleport sink was used before composition completed.");
+}
+
+internal interface ILocalPlayerTeleportInputLifetime
+{
+ void EndMouseLook();
+}
+
+internal interface ILocalPlayerTeleportModeOperations
+{
+ PlayerMovementController? Controller { get; }
+ Matrix4x4 Projection { get; }
+ bool TryEnterPortalSpace();
+ void EnterWorld();
+}
+
+internal interface ILocalPlayerTeleportAuthority
+{
+ bool IsFreshStart(ushort sequence);
+}
+
+internal sealed class LiveLocalPlayerTeleportAuthority
+ : ILocalPlayerTeleportAuthority
+{
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly ILocalPlayerIdentitySource _identity;
+
+ public LiveLocalPlayerTeleportAuthority(
+ LiveEntityRuntime liveEntities,
+ ILocalPlayerIdentitySource identity)
+ {
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _identity = identity ?? throw new ArgumentNullException(nameof(identity));
+ }
+
+ public bool IsFreshStart(ushort sequence) =>
+ _liveEntities.IsFreshTeleportStart(_identity.ServerGuid, sequence);
+}
+
+internal interface ILocalPlayerTeleportStreamingOperations
+{
+ int CenterX { get; }
+ int CenterY { get; }
+ bool IsRecenterPending { get; }
+ bool BeginRecenter(int x, int y, bool isSealedDungeon);
+ bool ResetRecenter(bool sessionEnding);
+ bool IsSealedDungeon(uint cellId);
+ void SetPriority(uint landblockId, int radius);
+ void ClearPriority();
+}
+
+internal sealed class LocalPlayerTeleportStreamingOperations
+ : ILocalPlayerTeleportStreamingOperations
+{
+ private readonly LiveWorldOriginState _origin;
+ private readonly StreamingOriginRecenterCoordinator _recenter;
+ private readonly StreamingController _streaming;
+ private readonly ISealedDungeonCellClassifier _sealedDungeonCells;
+
+ public LocalPlayerTeleportStreamingOperations(
+ LiveWorldOriginState origin,
+ StreamingOriginRecenterCoordinator recenter,
+ StreamingController streaming,
+ ISealedDungeonCellClassifier sealedDungeonCells)
+ {
+ _origin = origin ?? throw new ArgumentNullException(nameof(origin));
+ _recenter = recenter ?? throw new ArgumentNullException(nameof(recenter));
+ _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
+ _sealedDungeonCells = sealedDungeonCells
+ ?? throw new ArgumentNullException(nameof(sealedDungeonCells));
+ }
+
+ public int CenterX => _origin.CenterX;
+ public int CenterY => _origin.CenterY;
+ public bool IsRecenterPending => _recenter.IsPending;
+
+ public bool BeginRecenter(int x, int y, bool isSealedDungeon) =>
+ _recenter.Begin(x, y, isSealedDungeon);
+
+ public bool ResetRecenter(bool sessionEnding) =>
+ _recenter.Reset(sessionEnding);
+
+ public bool IsSealedDungeon(uint cellId) =>
+ _sealedDungeonCells.IsSealedDungeon(cellId);
+
+ public void SetPriority(uint landblockId, int radius)
+ {
+ _streaming.PriorityLandblockId = landblockId;
+ _streaming.PriorityRadius = radius;
+ }
+
+ public void ClearPriority()
+ {
+ _streaming.PriorityLandblockId = 0u;
+ _streaming.PriorityRadius = 0;
+ }
+}
+
+internal interface ILocalPlayerTeleportPlacement
+{
+ void Place(Vector3 position, uint cellId, Quaternion rotation, bool forced);
+}
+
+///
+/// Commits the local player's deferred portal arrival. It owns the exact
+/// Place -> root/controller/camera mutation -> spatial reconcile edge.
+///
+internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlacement
+{
+ private readonly PhysicsEngine _physics;
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly ILocalPlayerIdentitySource _identity;
+ private readonly ILocalPlayerControllerSource _controller;
+ private readonly ILocalPlayerPhysicsHostSource _host;
+ private readonly ChaseCameraInputState _cameras;
+ private readonly LiveWorldOriginState _origin;
+ private readonly ILiveSpatialReconcilePhase _spatial;
+
+ public LocalPlayerTeleportPlacement(
+ PhysicsEngine physics,
+ LiveEntityRuntime liveEntities,
+ ILocalPlayerIdentitySource identity,
+ ILocalPlayerControllerSource controller,
+ ILocalPlayerPhysicsHostSource host,
+ ChaseCameraInputState cameras,
+ LiveWorldOriginState origin,
+ ILiveSpatialReconcilePhase spatial)
+ {
+ _physics = physics ?? throw new ArgumentNullException(nameof(physics));
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _identity = identity ?? throw new ArgumentNullException(nameof(identity));
+ _controller = controller ?? throw new ArgumentNullException(nameof(controller));
+ _host = host ?? throw new ArgumentNullException(nameof(host));
+ _cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
+ _origin = origin ?? throw new ArgumentNullException(nameof(origin));
+ _spatial = spatial ?? throw new ArgumentNullException(nameof(spatial));
+ }
+
+ public void Place(Vector3 position, uint cellId, Quaternion rotation, bool forced)
+ {
+ PlayerMovementController controller = _controller.Controller
+ ?? throw new InvalidOperationException(
+ "Teleport Place ran without the local player controller.");
+ var resolved = _physics.Resolve(
+ position,
+ cellId,
+ Vector3.Zero,
+ controller.StepUpHeight);
+ var snapped = new Vector3(
+ resolved.Position.X,
+ resolved.Position.Y,
+ resolved.Position.Z);
+
+ if (forced)
+ {
+ Console.WriteLine(
+ $"live: teleport HOLD gave up (impossible/timeout) - force-snapping "
+ + $"cell=0x{cellId:X8} pos={position} -> 0x{resolved.CellId:X8} {snapped}");
+ }
+
+ uint playerGuid = _identity.ServerGuid;
+ controller.SetPosition(
+ snapped,
+ resolved.CellId,
+ CellLocalForSeed(snapped, resolved.CellId));
+
+ // SnapToCell owns the retail Position frame and may normalize an
+ // outdoor land-cell index from the cell-local origin. Publish that
+ // canonical result, not the pre-snap resolver hint, to rendering.
+ if (_liveEntities.MaterializedWorldEntities.TryGetValue(
+ playerGuid,
+ out WorldEntity? entity))
+ {
+ entity.SetPosition(controller.Position);
+ entity.ParentCellId = controller.CellId;
+ entity.Rotation = rotation;
+ }
+
+ // Retail teleport_hook tail @ 0x00514ED0 clears the local target and
+ // notifies every watcher that this object teleported.
+ _host.Host?.NotifyTeleported();
+ controller.SetBodyOrientation(rotation);
+
+ _cameras.Legacy?.Update(controller.Position, controller.Yaw);
+ _cameras.Retail?.ResetViewerToPlayer(controller.Position, controller.Yaw);
+ _spatial.Reconcile();
+
+ PhysicsDiagnostics.LogTeleport(
+ "PLACED",
+ controller.CellId,
+ $"forced={forced}");
+ Console.WriteLine(
+ $"live: teleport materialized - snapped to {controller.Position} "
+ + $"cell=0x{controller.CellId:X8}");
+ }
+
+ private Vector3 CellLocalForSeed(Vector3 worldPosition, uint cellId)
+ {
+ int landblockX = (int)((cellId >> 24) & 0xFFu);
+ int landblockY = (int)((cellId >> 16) & 0xFFu);
+ var origin = new Vector3(
+ (landblockX - _origin.CenterX) * 192f,
+ (landblockY - _origin.CenterY) * 192f,
+ 0f);
+ return worldPosition - origin;
+ }
+}
+
+internal interface ILocalPlayerTeleportSession
+{
+ void SendLoginComplete();
+}
+
+internal sealed class LocalPlayerTeleportSession : ILocalPlayerTeleportSession
+{
+ private readonly LiveSessionController _session;
+
+ public LocalPlayerTeleportSession(LiveSessionController session) =>
+ _session = session ?? throw new ArgumentNullException(nameof(session));
+
+ public void SendLoginComplete() =>
+ _session.CurrentSession?.SendGameAction(
+ GameActionLoginComplete.Build());
+}
+
+internal interface ILocalPlayerTeleportPresentation : IDisposable
+{
+ bool IsPortalViewportVisible { get; }
+ int CurrentTunnelFrame { get; }
+ void Begin(Matrix4x4 projection);
+ (TeleportAnimSnapshot Snapshot, IReadOnlyList Events)
+ Tick(float deltaSeconds, bool worldReady);
+ void TickTunnel(float deltaSeconds);
+ void EnterTunnel();
+ void ExitTunnel();
+ void Reset();
+ Matrix4x4 ApplyViewPlane(Matrix4x4 projection);
+ ICamera ApplyViewPlane(ICamera camera);
+ void DrawPortalViewport(int width, int height, Matrix4x4 projection);
+}
+
+internal sealed class LocalPlayerTeleportPresentation
+ : ILocalPlayerTeleportPresentation
+{
+ private readonly TeleportAnimSequencer _animation = new();
+ private readonly TeleportViewPlaneController _viewPlane = new();
+ private readonly PortalTunnelPresentation _tunnel;
+
+ public LocalPlayerTeleportPresentation(PortalTunnelPresentation tunnel) =>
+ _tunnel = tunnel ?? throw new ArgumentNullException(nameof(tunnel));
+
+ public bool IsPortalViewportVisible => _tunnel.IsVisible;
+ public int CurrentTunnelFrame => _tunnel.CurrentAnimationFrame;
+
+ public void Begin(Matrix4x4 projection)
+ {
+ _viewPlane.Begin(projection);
+ _animation.Begin(TeleportEntryKind.Portal);
+ }
+
+ public (TeleportAnimSnapshot Snapshot, IReadOnlyList Events)
+ Tick(float deltaSeconds, bool worldReady)
+ {
+ var (snapshot, events) = _animation.Tick(
+ deltaSeconds,
+ worldReady,
+ CurrentTunnelFrame);
+ _viewPlane.Update(snapshot);
+ return (snapshot, events);
+ }
+
+ public void TickTunnel(float deltaSeconds) => _tunnel.Tick(deltaSeconds);
+ public void EnterTunnel() => _tunnel.Enter();
+ public void ExitTunnel() => _tunnel.Exit();
+
+ public void Reset()
+ {
+ _animation.Reset();
+ _viewPlane.Reset();
+ _tunnel.Exit();
+ }
+
+ public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) =>
+ _viewPlane.Apply(projection);
+
+ public ICamera ApplyViewPlane(ICamera camera) => _viewPlane.ApplyTo(camera);
+
+ public void DrawPortalViewport(int width, int height, Matrix4x4 projection) =>
+ _tunnel.Draw(width, height, ApplyViewPlane(projection));
+
+ public void Dispose() => _tunnel.Dispose();
+}
+
+///
+/// Single update-thread owner of the local F751/Position-correlated portal
+/// lifetime: activation, destination aim, readiness hold, Place, viewport
+/// transitions, LoginComplete, and session/reset symmetry.
+///
+internal sealed class LocalPlayerTeleportController
+ : ILocalPlayerTeleportFramePhase,
+ ILocalPlayerTeleportNetworkSink,
+ IDisposable
+{
+ internal const float MaximumWorldHoldSeconds = 10f;
+ internal const int NearRingRadius =
+ WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius;
+
+ private readonly TeleportTransitCoordinator _transit = new();
+ private readonly ILocalPlayerTeleportAuthority _authority;
+ private readonly ILocalPlayerTeleportInputLifetime _input;
+ private readonly ILocalPlayerTeleportModeOperations _mode;
+ private readonly ILocalPlayerTeleportStreamingOperations _streaming;
+ private readonly WorldRevealCoordinator _worldReveal;
+ private readonly ILocalPlayerTeleportPlacement _placement;
+ private readonly ILocalPlayerTeleportSession _session;
+ private readonly ILocalPlayerTeleportPresentation _presentation;
+
+ private Vector3 _pendingPosition;
+ private uint _pendingCell;
+ private Quaternion _pendingRotation = Quaternion.Identity;
+ private bool _hasAcceptedDestination;
+ private WorldSession.EntityPositionUpdate _acceptedDestination;
+ private float _holdSeconds;
+ private bool _forced;
+ private long _lifetimeGeneration;
+ private bool _disposed;
+
+ public LocalPlayerTeleportController(
+ ILocalPlayerTeleportAuthority authority,
+ ILocalPlayerTeleportInputLifetime input,
+ ILocalPlayerTeleportModeOperations mode,
+ ILocalPlayerTeleportStreamingOperations streaming,
+ WorldRevealCoordinator worldReveal,
+ ILocalPlayerTeleportPlacement placement,
+ ILocalPlayerTeleportSession session,
+ ILocalPlayerTeleportPresentation presentation)
+ {
+ _authority = authority ?? throw new ArgumentNullException(nameof(authority));
+ _input = input ?? throw new ArgumentNullException(nameof(input));
+ _mode = mode ?? throw new ArgumentNullException(nameof(mode));
+ _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
+ _worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal));
+ _placement = placement ?? throw new ArgumentNullException(nameof(placement));
+ _session = session ?? throw new ArgumentNullException(nameof(session));
+ _presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
+ }
+
+ public bool IsActive => _transit.IsActive;
+ public bool IsPortalViewportVisible => _presentation.IsPortalViewportVisible;
+ public uint ActiveDestinationCell => _transit.IsActive ? _pendingCell : 0u;
+
+ public void OnTeleportStarted(uint sequence)
+ {
+ ThrowIfDisposed();
+ ushort teleportSequence = (ushort)sequence;
+ if (!_authority.IsFreshStart(teleportSequence)
+ || !_transit.CanBegin(teleportSequence))
+ {
+ return;
+ }
+
+ long observedGeneration = _lifetimeGeneration;
+ _input.EndMouseLook();
+ if (_lifetimeGeneration != observedGeneration)
+ return;
+
+ long resetGeneration = ResetTransit(clearSession: false);
+ if (_lifetimeGeneration != resetGeneration)
+ return;
+
+ _transit.QueueStart(teleportSequence);
+ TryActivatePendingPresentation();
+ Console.WriteLine($"live: teleport queued (seq={sequence})");
+ }
+
+ public void OfferDestination(
+ WorldSession.EntityPositionUpdate update,
+ bool teleportTimestampAdvanced)
+ {
+ ThrowIfDisposed();
+ if (_transit.OfferDestination(
+ update.TeleportSequence,
+ teleportTimestampAdvanced,
+ update,
+ out WorldSession.EntityPositionUpdate destination))
+ {
+ AcceptDestination(destination);
+ }
+ }
+
+ public void Tick(float deltaSeconds)
+ {
+ ThrowIfDisposed();
+ TryActivatePendingPresentation();
+ TryAimAcceptedDestination();
+ if (!_transit.IsActive)
+ return;
+
+ long generation = _lifetimeGeneration;
+ ushort sequence = _transit.Sequence;
+ if (!_mode.TryEnterPortalSpace()
+ || !IsCurrentLifetime(generation, sequence)
+ || _mode.Controller is null)
+ {
+ return;
+ }
+
+ bool haveDestination = _pendingCell != 0u;
+ bool originReady = !_streaming.IsRecenterPending;
+ bool ready = haveDestination
+ && originReady
+ && _worldReveal.Evaluate(_pendingCell).IsReady;
+ if (!IsCurrentLifetime(generation, sequence))
+ return;
+
+ if (haveDestination && originReady && !ready)
+ {
+ _holdSeconds += deltaSeconds;
+ if (_holdSeconds >= MaximumWorldHoldSeconds)
+ {
+ ready = true;
+ _forced = true;
+ }
+ }
+
+ var (_, events) = _presentation.Tick(deltaSeconds, ready);
+ if (!IsCurrentLifetime(generation, sequence))
+ return;
+
+ foreach (TeleportAnimEvent teleportEvent in events)
+ {
+ switch (teleportEvent)
+ {
+ case TeleportAnimEvent.Place:
+ _placement.Place(
+ _pendingPosition,
+ _pendingCell,
+ _pendingRotation,
+ _forced);
+ if (!IsCurrentLifetime(generation, sequence))
+ return;
+ _worldReveal.ObserveMaterialized();
+ if (!IsCurrentLifetime(generation, sequence))
+ return;
+ _streaming.ClearPriority();
+ if (!IsCurrentLifetime(generation, sequence))
+ return;
+ break;
+ case TeleportAnimEvent.EnterTunnel:
+ _presentation.EnterTunnel();
+ if (!IsCurrentLifetime(generation, sequence))
+ return;
+ break;
+ case TeleportAnimEvent.PlayExitSound:
+ _presentation.ExitTunnel();
+ if (!IsCurrentLifetime(generation, sequence))
+ return;
+ break;
+ case TeleportAnimEvent.FireLoginComplete:
+ _mode.EnterWorld();
+ if (!IsCurrentLifetime(generation, sequence))
+ return;
+ _session.SendLoginComplete();
+ if (!IsCurrentLifetime(generation, sequence))
+ return;
+ _worldReveal.Complete();
+ if (!IsCurrentLifetime(generation, sequence))
+ return;
+ ResetTransit(clearSession: false);
+ return;
+ default:
+ // ClientUISystem sound-table enum playback remains outside
+ // this already-accepted presentation extraction.
+ break;
+ }
+ }
+
+ _presentation.TickTunnel(deltaSeconds);
+ }
+
+ public void ResetSession()
+ {
+ ThrowIfDisposed();
+ ResetTransit(clearSession: true);
+ }
+
+ public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) =>
+ _presentation.ApplyViewPlane(projection);
+
+ public ICamera ApplyViewPlane(ICamera camera) =>
+ _presentation.ApplyViewPlane(camera);
+
+ public void DrawPortalViewport(int width, int height, Matrix4x4 projection) =>
+ _presentation.DrawPortalViewport(width, height, projection);
+
+ private void TryActivatePendingPresentation()
+ {
+ if (!_transit.HasPendingStart)
+ return;
+
+ long generation = _lifetimeGeneration;
+ if (!_mode.TryEnterPortalSpace()
+ || _lifetimeGeneration != generation
+ || !_transit.HasPendingStart)
+ {
+ return;
+ }
+
+ _holdSeconds = 0f;
+ _forced = false;
+ _presentation.Begin(_mode.Projection);
+ if (_lifetimeGeneration != generation || !_transit.HasPendingStart)
+ return;
+
+ bool hasBufferedDestination = _transit.Activate(out var destination);
+ if (hasBufferedDestination)
+ AcceptDestination(destination);
+ Console.WriteLine(
+ $"live: teleport presentation started (seq={_transit.Sequence})");
+ }
+
+ private void AcceptDestination(WorldSession.EntityPositionUpdate destination)
+ {
+ _acceptedDestination = destination;
+ _hasAcceptedDestination = true;
+ TryAimAcceptedDestination();
+ }
+
+ private void TryAimAcceptedDestination()
+ {
+ if (!_hasAcceptedDestination || !_transit.IsActive)
+ return;
+
+ long generation = _lifetimeGeneration;
+ ushort sequence = _transit.Sequence;
+ PlayerMovementController? controller = _mode.Controller;
+ if (controller is null)
+ {
+ if (!_mode.TryEnterPortalSpace()
+ || !IsCurrentLifetime(generation, sequence))
+ {
+ return;
+ }
+
+ controller = _mode.Controller;
+ if (controller is null)
+ return;
+ }
+
+ WorldSession.EntityPositionUpdate destination = _acceptedDestination;
+ if (!AimDestination(destination, controller, generation, sequence))
+ return;
+
+ if (IsCurrentLifetime(generation, sequence))
+ {
+ _hasAcceptedDestination = false;
+ _acceptedDestination = default;
+ }
+ }
+
+ private bool AimDestination(
+ WorldSession.EntityPositionUpdate update,
+ PlayerMovementController controller,
+ long generation,
+ ushort sequence)
+ {
+ var position = update.Position;
+ int landblockX = (int)((position.LandblockId >> 24) & 0xFFu);
+ int landblockY = (int)((position.LandblockId >> 16) & 0xFFu);
+ uint streamingOriginLandblockId = StreamingRegion.EncodeLandblockId(
+ _streaming.CenterX,
+ _streaming.CenterY);
+ var origin = new Vector3(
+ (landblockX - _streaming.CenterX) * 192f,
+ (landblockY - _streaming.CenterY) * 192f,
+ 0f);
+ var translated = new Vector3(
+ position.PositionX,
+ position.PositionY,
+ position.PositionZ) + origin;
+
+ TeleportLandblockTransition transition = TeleportLandblockTransition.Classify(
+ controller.CellId,
+ position.LandblockId,
+ streamingOriginLandblockId);
+ int oldX = (int)((transition.SourceLandblockId >> 24) & 0xFFu);
+ int oldY = (int)((transition.SourceLandblockId >> 16) & 0xFFu);
+
+ Console.WriteLine(
+ $"live: teleport arrival - old lb=({oldX},{oldY}) "
+ + $"new lb=({landblockX},{landblockY}) "
+ + $"dist={Vector3.Distance(translated, controller.Position):F1}");
+
+ Vector3 worldPosition;
+ if (transition.ChangesStreamingCenter)
+ {
+ bool isSealedDungeon = _streaming.IsSealedDungeon(
+ position.LandblockId);
+ if (!IsCurrentLifetime(generation, sequence))
+ return false;
+
+ _streaming.BeginRecenter(
+ landblockX,
+ landblockY,
+ isSealedDungeon);
+ if (!IsCurrentLifetime(generation, sequence))
+ return false;
+ worldPosition = new Vector3(
+ position.PositionX,
+ position.PositionY,
+ position.PositionZ);
+ }
+ else
+ {
+ worldPosition = translated;
+ }
+
+ _pendingRotation = new Quaternion(
+ position.RotationX,
+ position.RotationY,
+ position.RotationZ,
+ position.RotationW);
+ _pendingPosition = worldPosition;
+ _pendingCell = position.LandblockId;
+ _holdSeconds = 0f;
+ _forced = false;
+ _worldReveal.Begin(WorldRevealKind.Portal);
+ if (!IsCurrentLifetime(generation, sequence))
+ return false;
+ _streaming.SetPriority(
+ StreamingRegion.EncodeLandblockId(landblockX, landblockY),
+ NearRingRadius);
+ if (!IsCurrentLifetime(generation, sequence))
+ return false;
+
+ PhysicsDiagnostics.LogTeleport(
+ "AIM",
+ position.LandblockId,
+ $"seq={update.TeleportSequence} lb={landblockX},{landblockY} "
+ + $"indoor={((position.LandblockId & 0xFFFFu) >= 0x0100u)} "
+ + $"playerCross={transition.CrossesLandblock} "
+ + $"centerChange={transition.ChangesStreamingCenter}");
+ return true;
+ }
+
+ private long ResetTransit(bool clearSession)
+ {
+ long generation = checked(++_lifetimeGeneration);
+ if (clearSession)
+ {
+ _transit.ClearSession();
+ }
+ else
+ {
+ _transit.EndActive();
+ }
+
+ _pendingPosition = default;
+ _pendingCell = 0u;
+ _pendingRotation = Quaternion.Identity;
+ _hasAcceptedDestination = false;
+ _acceptedDestination = default;
+ _holdSeconds = 0f;
+ _forced = false;
+
+ bool originConverged = _streaming.ResetRecenter(clearSession);
+ if (_lifetimeGeneration != generation)
+ return generation;
+
+ if (clearSession)
+ {
+ _worldReveal.Cancel();
+ if (_lifetimeGeneration != generation)
+ return generation;
+ }
+
+ _presentation.Reset();
+ if (_lifetimeGeneration != generation)
+ return generation;
+
+ _streaming.ClearPriority();
+
+ if (clearSession && !originConverged)
+ {
+ throw new InvalidOperationException(
+ "The ending session's streaming-origin retirement has not converged.");
+ }
+
+ return generation;
+ }
+
+ private bool IsCurrentLifetime(long generation, ushort sequence) =>
+ _lifetimeGeneration == generation
+ && _transit.IsActive
+ && _transit.Sequence == sequence;
+
+ private void ThrowIfDisposed() =>
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _presentation.Dispose();
+ _disposed = true;
+ }
+}
diff --git a/src/AcDream.App/Streaming/SealedDungeonCellClassifier.cs b/src/AcDream.App/Streaming/SealedDungeonCellClassifier.cs
new file mode 100644
index 00000000..f8b33215
--- /dev/null
+++ b/src/AcDream.App/Streaming/SealedDungeonCellClassifier.cs
@@ -0,0 +1,44 @@
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using AcDream.Content;
+
+namespace AcDream.App.Streaming;
+
+internal interface ISealedDungeonCellClassifier
+{
+ bool IsSealedDungeon(uint cellId);
+}
+
+///
+/// Classifies a server-supplied full cell ID using the EnvCell's retail
+/// bit. Missing, outdoor, and structural
+/// shell IDs are never guessed to be sealed.
+///
+internal sealed class DatSealedDungeonCellClassifier
+ : ISealedDungeonCellClassifier
+{
+ private readonly IDatReaderWriter _dats;
+ private readonly object _datLock;
+
+ public DatSealedDungeonCellClassifier(
+ IDatReaderWriter dats,
+ object datLock)
+ {
+ _dats = dats ?? throw new ArgumentNullException(nameof(dats));
+ _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
+ }
+
+ public bool IsSealedDungeon(uint cellId)
+ {
+ uint low = cellId & 0xFFFFu;
+ if (low < 0x0100u || low >= 0xFFFEu)
+ return false;
+
+ EnvCell? envCell;
+ lock (_datLock)
+ envCell = _dats.Get(cellId);
+ return envCell is not null
+ && !envCell.Flags.HasFlag(EnvCellFlags.SeenOutside);
+ }
+}
diff --git a/src/AcDream.App/World/LiveEntityHydrationPorts.cs b/src/AcDream.App/World/LiveEntityHydrationPorts.cs
index b6584cbc..b10dfcb6 100644
--- a/src/AcDream.App/World/LiveEntityHydrationPorts.cs
+++ b/src/AcDream.App/World/LiveEntityHydrationPorts.cs
@@ -114,7 +114,7 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC
private readonly GpuWorldState _worldState;
private readonly WorldRevealCoordinator _worldReveal;
private readonly Func _playerGuid;
- private readonly Func _isSealedDungeonCell;
+ private readonly ISealedDungeonCellClassifier _sealedDungeonCells;
private readonly Action? _diagnostic;
public LiveEntityWorldOriginCoordinator(
@@ -123,7 +123,7 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC
GpuWorldState worldState,
WorldRevealCoordinator worldReveal,
Func playerGuid,
- Func isSealedDungeonCell,
+ ISealedDungeonCellClassifier sealedDungeonCells,
Action? diagnostic = null)
{
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
@@ -131,8 +131,8 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
_worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
- _isSealedDungeonCell = isSealedDungeonCell
- ?? throw new ArgumentNullException(nameof(isSealedDungeonCell));
+ _sealedDungeonCells = sealedDungeonCells
+ ?? throw new ArgumentNullException(nameof(sealedDungeonCells));
_diagnostic = diagnostic;
}
@@ -164,7 +164,8 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC
_streaming.InitializeKnownLoginCenter(
lbX,
lbY,
- isSealedDungeon: _isSealedDungeonCell(position.LandblockId));
+ isSealedDungeon: _sealedDungeonCells.IsSealedDungeon(
+ position.LandblockId));
_worldReveal.Begin(WorldRevealKind.Login);
return new(
diff --git a/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs b/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs
new file mode 100644
index 00000000..e61c0e50
--- /dev/null
+++ b/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs
@@ -0,0 +1,93 @@
+using System.Numerics;
+using AcDream.App.Input;
+using AcDream.App.Physics;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using DatReaderWriter.Types;
+using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
+
+namespace AcDream.App.Tests.Input;
+
+public sealed class PlayerMovementPlacementTransactionTests
+{
+ private const uint PlayerGuid = 0x5000_0001u;
+ private const uint TargetGuid = 0x7000_0001u;
+ private const uint PriorCell = 0x0101_0101u;
+ private const uint DestinationCell = 0x0101_0102u;
+
+ [Fact]
+ public void PreparedPosition_DoesNotPublishRenderRootOrUnstickUntilCommit()
+ {
+ PhysicsEngine physics = PhysicsWithCells(PriorCell, DestinationCell);
+ physics.UpdatePlayerCurrCell(PriorCell);
+ var controller = new PlayerMovementController(physics);
+ var hosts = new Dictionary();
+ IPhysicsObjHost? Resolve(uint id) => hosts.GetValueOrDefault(id);
+ EntityPhysicsHost player = Host(PlayerGuid, Resolve);
+ EntityPhysicsHost target = Host(TargetGuid, Resolve);
+ hosts.Add(PlayerGuid, player);
+ hosts.Add(TargetGuid, target);
+ controller.PositionManager = player.PositionManager;
+ player.PositionManager.StickTo(TargetGuid, 0.5f, 1.8f);
+
+ controller.PreparePositionForCommit(
+ new Vector3(2f, 3f, 4f),
+ DestinationCell,
+ new Vector3(2f, 3f, 4f));
+
+ Assert.Equal(DestinationCell, controller.CellId);
+ Assert.Equal(PriorCell, physics.DataCache!.CellGraph.CurrCell!.Id);
+ Assert.Equal(TargetGuid, player.PositionManager.GetStickyObjectId());
+
+ controller.CommitPreparedPosition();
+
+ Assert.Equal(DestinationCell, physics.DataCache.CellGraph.CurrCell!.Id);
+ Assert.Equal(0u, player.PositionManager.GetStickyObjectId());
+ }
+
+ private static PhysicsEngine PhysicsWithCells(params uint[] cellIds)
+ {
+ var physics = new PhysicsEngine { DataCache = new PhysicsDataCache() };
+ foreach (uint cellId in cellIds)
+ {
+ var cellStruct = new CellStruct
+ {
+ VertexArray = new VertexArray
+ {
+ Vertices = new Dictionary(),
+ },
+ Polygons = new Dictionary(),
+ };
+ var envCell = new DatEnvCell
+ {
+ CellPortals = [],
+ VisibleCells = [],
+ };
+ physics.DataCache.CacheCellStruct(
+ cellId,
+ envCell,
+ cellStruct,
+ Matrix4x4.Identity);
+ }
+ return physics;
+ }
+
+ private static EntityPhysicsHost Host(
+ uint guid,
+ Func resolve) =>
+ new(
+ guid,
+ getPosition: () => new AcDream.Core.Physics.Position(
+ PriorCell,
+ Vector3.Zero,
+ Quaternion.Identity),
+ getVelocity: () => Vector3.Zero,
+ getRadius: () => 0.5f,
+ inContact: () => true,
+ minterpMaxSpeed: () => 1f,
+ curTime: () => 0d,
+ physicsTimerTime: () => 0d,
+ getObjectA: resolve,
+ handleUpdateTarget: _ => { },
+ interruptCurrentMovement: () => { });
+}
diff --git a/tests/AcDream.App.Tests/Interaction/PlayerApproachCompletionStateTests.cs b/tests/AcDream.App.Tests/Interaction/PlayerApproachCompletionStateTests.cs
new file mode 100644
index 00000000..5933e74e
--- /dev/null
+++ b/tests/AcDream.App.Tests/Interaction/PlayerApproachCompletionStateTests.cs
@@ -0,0 +1,79 @@
+using AcDream.App.Interaction;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Tests.Interaction;
+
+public sealed class PlayerApproachCompletionStateTests
+{
+ [Fact]
+ public void CompletionMailbox_PreservesPublicationOrder()
+ {
+ var state = new PlayerApproachCompletionState();
+ IPlayerApproachCompletionSink lifetime = state.BeginControllerLifetime();
+
+ Assert.True(state.TryBeginApproach(out PlayerApproachToken token));
+ lifetime.PublishNaturalCompletion();
+ lifetime.PublishCancellation(WeenieError.NoObject);
+
+ Assert.True(state.TryTake(out PlayerApproachCompletion natural));
+ Assert.Equal(token, natural.Token);
+ Assert.True(natural.IsNatural);
+ Assert.Equal(WeenieError.None, natural.Error);
+ Assert.True(state.TryTake(out PlayerApproachCompletion cancellation));
+ Assert.False(cancellation.IsNatural);
+ Assert.Equal(WeenieError.NoObject, cancellation.Error);
+ Assert.False(state.TryTake(out _));
+ }
+
+ [Fact]
+ public void Clear_DiscardsEveryPendingCompletion()
+ {
+ var state = new PlayerApproachCompletionState();
+ IPlayerApproachCompletionSink lifetime = state.BeginControllerLifetime();
+ Assert.True(state.TryBeginApproach(out _));
+ lifetime.PublishNaturalCompletion();
+ lifetime.PublishCancellation(WeenieError.ActionCancelled);
+
+ state.Clear();
+
+ Assert.False(state.TryTake(out _));
+ }
+
+ [Fact]
+ public void RetiredLifetime_CannotPublishIntoReplacementLifetime()
+ {
+ var state = new PlayerApproachCompletionState();
+ IPlayerApproachCompletionSink stale = state.BeginControllerLifetime();
+ Assert.True(state.TryBeginApproach(out PlayerApproachToken staleToken));
+ state.RetireControllerLifetime(stale);
+ Assert.True(state.TryTake(out PlayerApproachCompletion retirement));
+ Assert.Equal(staleToken, retirement.Token);
+ Assert.False(retirement.IsNatural);
+ IPlayerApproachCompletionSink current = state.BeginControllerLifetime();
+ Assert.True(state.TryBeginApproach(out PlayerApproachToken currentToken));
+
+ stale.PublishNaturalCompletion();
+ current.PublishNaturalCompletion();
+
+ Assert.True(state.TryTake(out PlayerApproachCompletion completion));
+ Assert.Equal(currentToken, completion.Token);
+ Assert.False(state.TryTake(out _));
+ }
+
+ [Fact]
+ public void Retirement_ReplacesQueuedSuccessWithMatchingCancellation()
+ {
+ var state = new PlayerApproachCompletionState();
+ IPlayerApproachCompletionSink lifetime = state.BeginControllerLifetime();
+ Assert.True(state.TryBeginApproach(out PlayerApproachToken token));
+ lifetime.PublishNaturalCompletion();
+
+ state.RetireControllerLifetime(lifetime);
+
+ Assert.True(state.TryTake(out PlayerApproachCompletion completion));
+ Assert.Equal(token, completion.Token);
+ Assert.False(completion.IsNatural);
+ Assert.Equal(WeenieError.ActionCancelled, completion.Error);
+ Assert.False(state.TryTake(out _));
+ }
+}
diff --git a/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs b/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs
index 8cc44107..8914ed91 100644
--- a/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs
+++ b/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs
@@ -16,10 +16,11 @@ public sealed class PlayerInteractionMovementSinkTests
[Fact]
public void MissingPlayerDoesNotArmTheIntent()
{
- var sink = new PlayerInteractionMovementSink(() => null);
+ var completions = new PlayerApproachCompletionState();
+ var sink = new PlayerInteractionMovementSink(() => null, completions);
bool armed = false;
- Assert.False(sink.BeginApproach(Approach(closeRange: true), () => armed = true));
+ Assert.False(sink.BeginApproach(Approach(closeRange: true), _ => armed = true));
Assert.False(armed);
}
@@ -66,11 +67,15 @@ public sealed class PlayerInteractionMovementSinkTests
new Position(Cell, new Vector3(2f, 0f, 0f), Quaternion.Identity),
new MovementParameters { UseSpheres = false });
bool armedAfterCancellation = false;
- var sink = new PlayerInteractionMovementSink(() => controller);
+ var completions = new PlayerApproachCompletionState();
+ _ = completions.BeginControllerLifetime();
+ var sink = new PlayerInteractionMovementSink(
+ () => controller,
+ completions);
Assert.True(sink.BeginApproach(
Approach(closeRange),
- () => armedAfterCancellation = cancellations == 1 && !moveTo.IsMovingTo()));
+ _ => armedAfterCancellation = cancellations == 1 && !moveTo.IsMovingTo()));
Assert.True(armedAfterCancellation);
Assert.True(nonAutonomousAtTargetInstall);
diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
index 7c64a8e8..75963f82 100644
--- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
+++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
@@ -104,17 +104,20 @@ public sealed class SelectionInteractionControllerTests
}
}
- private sealed class Movement : IPlayerInteractionMovementSink
+ private sealed class Movement(IPlayerApproachTokenSource approachTokens)
+ : IPlayerInteractionMovementSink
{
public List Approaches { get; } = new();
public bool Starts { get; set; } = true;
public Action? AfterArm { get; set; }
- public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null)
+ public bool BeginApproach(
+ InteractionApproach approach,
+ Action? armAfterCancel = null)
{
Approaches.Add(approach);
- if (!Starts)
+ if (!Starts || !approachTokens.TryBeginApproach(out PlayerApproachToken token))
return false;
- armAfterCancel?.Invoke();
+ armAfterCancel?.Invoke(token);
AfterArm?.Invoke();
return true;
}
@@ -124,7 +127,9 @@ public sealed class SelectionInteractionControllerTests
{
public readonly Query Query = new();
public readonly Transport Transport = new();
- public readonly Movement Movement = new();
+ public readonly PlayerApproachCompletionState Completions = new();
+ public readonly IPlayerApproachCompletionSink CompletionLifetime;
+ public readonly Movement Movement;
public readonly SelectionState Selection = new();
public readonly ClientObjectTable Objects = new();
public readonly List Toasts = new();
@@ -136,6 +141,8 @@ public sealed class SelectionInteractionControllerTests
public Harness()
{
+ CompletionLifetime = Completions.BeginControllerLifetime();
+ Movement = new Movement(Completions);
SelectionInteractionController? controller = null;
Objects.AddOrUpdate(new ClientObject
{
@@ -168,7 +175,8 @@ public sealed class SelectionInteractionControllerTests
Items,
Transport,
Movement,
- Toasts.Add);
+ Toasts.Add,
+ Completions);
Items.PendingBackpackPlacementRequested += PendingPlacements.Add;
Items.PendingBackpackPlacementCancelled += CancelledPlacements.Add;
}
@@ -386,6 +394,47 @@ public sealed class SelectionInteractionControllerTests
Assert.Empty(h.Transport.Uses);
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void DeferredCompletionFromPriorApproachCannotAffectReplacement(
+ bool priorCompletedNaturally)
+ {
+ var h = new Harness();
+ h.SetApproach(closeRange: true);
+ h.Controller.SendUse(Target);
+ if (priorCompletedNaturally)
+ h.CompletionLifetime.PublishNaturalCompletion();
+ else
+ h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
+
+ h.Controller.SendUse(Target);
+ h.Controller.DrainOutbound();
+
+ Assert.Empty(h.Transport.Uses);
+
+ h.CompletionLifetime.PublishNaturalCompletion();
+ h.Controller.DrainOutbound();
+
+ Assert.Equal(new[] { Target }, h.Transport.Uses);
+ }
+
+ [Fact]
+ public void RetiringControllerLifetimeInvalidatesQueuedArrival()
+ {
+ var h = new Harness();
+ h.SetApproach(closeRange: true);
+ h.Controller.SendUse(Target);
+ h.CompletionLifetime.PublishNaturalCompletion();
+
+ h.Completions.RetireControllerLifetime(h.CompletionLifetime);
+ h.Controller.DrainOutbound();
+
+ Assert.Empty(h.Transport.Uses);
+ h.Controller.OnNaturalMoveToComplete();
+ Assert.Empty(h.Transport.Uses);
+ }
+
[Fact]
public void FailedClosePickupStartWithdrawsPresentation()
{
diff --git a/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs
index 124a5375..e84b20be 100644
--- a/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs
@@ -121,4 +121,30 @@ public class CameraControllerTests
Assert.Null(ctl.Chase);
Assert.Null(ctl.RetailChase);
}
+
+ [Fact]
+ public void RestoreState_ReestablishesPriorCameraAfterNotificationFailure()
+ {
+ var orbit = new OrbitCamera();
+ var ctl = new CameraController(orbit, new FlyCamera());
+ CameraController.CameraState prior = ctl.CaptureState();
+ int notifications = 0;
+ ctl.ModeChanged += _ =>
+ {
+ if (++notifications == 1)
+ throw new InvalidOperationException("injected camera observer failure");
+ };
+
+ Assert.Throws(() =>
+ ctl.EnterChaseMode(new ChaseCamera(), new RetailChaseCamera()));
+
+ ctl.RestoreState(prior);
+
+ Assert.Same(orbit, ctl.Active);
+ Assert.False(ctl.IsFlyMode);
+ Assert.False(ctl.IsChaseMode);
+ Assert.Null(ctl.Chase);
+ Assert.Null(ctl.RetailChase);
+ Assert.Equal(2, notifications);
+ }
}
diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
new file mode 100644
index 00000000..59878bc4
--- /dev/null
+++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
@@ -0,0 +1,747 @@
+using System.Numerics;
+using AcDream.App.Input;
+using AcDream.App.Rendering;
+using AcDream.App.Streaming;
+using AcDream.App.Update;
+using AcDream.App.World;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Core.World;
+
+namespace AcDream.App.Tests.Streaming;
+
+public sealed class LocalPlayerTeleportControllerTests
+{
+ [Fact]
+ public void DestinationBeforeStart_IsReplayedOnceAfterPresentationActivates()
+ {
+ var harness = new Harness();
+ WorldSession.EntityPositionUpdate destination = Position(
+ cellId: 0x20210001u,
+ teleportSequence: 7,
+ x: 12f,
+ y: 34f,
+ z: 5f);
+
+ harness.Controller.OfferDestination(
+ destination,
+ teleportTimestampAdvanced: true);
+ Assert.Empty(harness.Streaming.PriorityWrites);
+
+ harness.Controller.OnTeleportStarted(7);
+
+ Assert.Equal(1, harness.Input.EndCount);
+ Assert.Equal(1, harness.Mode.EnterPortalCount);
+ Assert.Equal(Matrix4x4.Identity, harness.Presentation.BeginProjection);
+ Assert.Equal((0x2021FFFFu, LocalPlayerTeleportController.NearRingRadius),
+ Assert.Single(harness.Streaming.PriorityWrites));
+ Assert.True(harness.Reveal.Snapshot.IsActive);
+ Assert.Equal(WorldRevealKind.Portal, harness.Reveal.Snapshot.Kind);
+
+ harness.Controller.OfferDestination(
+ destination,
+ teleportTimestampAdvanced: false);
+ Assert.Single(harness.Streaming.PriorityWrites);
+ }
+
+ [Fact]
+ public void MissingPlayerController_KeepsStartAndDestinationPendingUntilProjectionExists()
+ {
+ var harness = new Harness();
+ harness.Mode.Controller = null;
+
+ harness.Controller.OnTeleportStarted(2);
+ harness.Controller.OfferDestination(
+ Position(0x20210001u, 2, 7f, 8f, 9f),
+ teleportTimestampAdvanced: true);
+
+ Assert.False(harness.Controller.IsActive);
+ Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
+ Assert.Null(harness.Presentation.BeginProjection);
+
+ harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine());
+ harness.Mode.Controller.SetPosition(
+ Vector3.Zero,
+ 0x20210001u,
+ Vector3.Zero);
+ harness.Controller.Tick(0.016f);
+
+ Assert.True(harness.Controller.IsActive);
+ Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell);
+ Assert.NotNull(harness.Presentation.BeginProjection);
+ Assert.Single(harness.Streaming.PriorityWrites);
+ }
+
+ [Fact]
+ public void ControllerWithdrawnAfterActivation_KeepsAcceptedDestinationUntilItReturns()
+ {
+ var harness = new Harness();
+ harness.Controller.OnTeleportStarted(14);
+ Assert.True(harness.Controller.IsActive);
+
+ harness.Mode.Controller = null;
+ harness.Controller.OfferDestination(
+ Position(0x20210001u, 14, 7f, 8f, 9f),
+ teleportTimestampAdvanced: true);
+
+ Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
+ Assert.Empty(harness.Streaming.PriorityWrites);
+
+ harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine());
+ harness.Mode.Controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero);
+ harness.Controller.Tick(0.016f);
+
+ Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell);
+ Assert.Single(harness.Streaming.PriorityWrites);
+ }
+
+ [Fact]
+ public void ControllerWithdrawnAfterAim_HoldsPresentationUntilModeRebuildsIt()
+ {
+ var harness = new Harness();
+ harness.Controller.OnTeleportStarted(15);
+ harness.Controller.OfferDestination(
+ Position(0x20210001u, 15, 7f, 8f, 9f),
+ teleportTimestampAdvanced: true);
+ harness.Mode.Controller = null;
+ harness.Presentation.Enqueue(TeleportAnimEvent.Place);
+
+ harness.Controller.Tick(0.016f);
+
+ Assert.Equal(default, harness.Placement.Position);
+ Assert.Empty(harness.Presentation.WorldReadyValues);
+
+ harness.Mode.RebuildOnEnter = () =>
+ {
+ var controller = new PlayerMovementController(new PhysicsEngine());
+ controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero);
+ return controller;
+ };
+ harness.Controller.Tick(0.016f);
+
+ Assert.NotNull(harness.Mode.Controller);
+ Assert.Equal(new Vector3(7f, 8f, 9f), harness.Placement.Position);
+ }
+
+ [Fact]
+ public void SameLandblockDestination_DoesNotRecenterAndKeepsTranslatedPosition()
+ {
+ var harness = new Harness(centerX: 0x20, centerY: 0x21);
+ harness.Controller.OnTeleportStarted(3);
+ harness.Controller.OfferDestination(
+ Position(0x20210123u, 3, 20f, 30f, 4f),
+ teleportTimestampAdvanced: true);
+ harness.Presentation.EmitPlaceWhenReady = true;
+
+ harness.Controller.Tick(0.016f);
+
+ Assert.Empty(harness.Streaming.Recenters);
+ Assert.Equal(new Vector3(20f, 30f, 4f), harness.Placement.Position);
+ Assert.Equal(0x20210123u, harness.Placement.CellId);
+ Assert.False(harness.Placement.Forced);
+ }
+
+ [Fact]
+ public void CrossLandblockDestination_RecentersBeforeItCanBecomeReady()
+ {
+ var harness = new Harness(centerX: 0x20, centerY: 0x21);
+ harness.Mode.Controller!.SetPosition(
+ Vector3.Zero,
+ 0x20210001u,
+ Vector3.Zero);
+ harness.Controller.OnTeleportStarted(4);
+ harness.Streaming.RecenterPending = true;
+ harness.Controller.OfferDestination(
+ Position(0x30310100u, 4, -2f, 8f, 9f),
+ teleportTimestampAdvanced: true);
+ harness.Presentation.EmitPlaceWhenReady = true;
+
+ harness.Controller.Tick(1f);
+
+ Assert.Equal((0x30, 0x31, true), Assert.Single(harness.Streaming.Recenters));
+ Assert.False(Assert.Single(harness.Presentation.WorldReadyValues));
+ Assert.Equal(default, harness.Placement.Position);
+
+ harness.Streaming.RecenterPending = false;
+ harness.Controller.Tick(0.016f);
+ Assert.Equal(new Vector3(-2f, 8f, 9f), harness.Placement.Position);
+ }
+
+ [Fact]
+ public void ReadinessHold_UsesWallClockAndForcesExactlyAtTenSeconds()
+ {
+ var harness = new Harness(worldReady: false);
+ harness.Controller.OnTeleportStarted(5);
+ harness.Controller.OfferDestination(
+ Position(0x20210001u, 5, 1f, 2f, 3f),
+ teleportTimestampAdvanced: true);
+ harness.Presentation.EmitPlaceWhenReady = true;
+
+ harness.Controller.Tick(9.5f);
+ Assert.False(Assert.Single(harness.Presentation.WorldReadyValues));
+ Assert.Equal(default, harness.Placement.Position);
+
+ harness.Controller.Tick(0.5f);
+
+ Assert.True(harness.Presentation.WorldReadyValues[^1]);
+ Assert.True(harness.Placement.Forced);
+ Assert.Equal(new Vector3(1f, 2f, 3f), harness.Placement.Position);
+ }
+
+ [Fact]
+ public void Place_ReconcilesInsidePlacementBeforeRevealMaterialized()
+ {
+ var order = new List();
+ var harness = new Harness(order: order);
+ harness.Controller.OnTeleportStarted(8);
+ harness.Controller.OfferDestination(
+ Position(0x20210001u, 8, 4f, 5f, 6f),
+ teleportTimestampAdvanced: true);
+ order.Clear();
+ harness.Presentation.Enqueue(TeleportAnimEvent.Place);
+
+ harness.Controller.Tick(0.016f);
+
+ Assert.True(Index(order, "placement") < Index(order, "[world-reveal] event=materialized"));
+ Assert.True(Index(order, "[world-reveal] event=materialized") < Index(order, "priority-clear"));
+ Assert.Equal(1, harness.Reveal.PortalMaterializationCount);
+ }
+
+ [Fact]
+ public void LoginComplete_EntersWorldThenSendsThenCompletesAndResets()
+ {
+ var order = new List();
+ var harness = new Harness(order: order);
+ harness.Controller.OnTeleportStarted(9);
+ harness.Controller.OfferDestination(
+ Position(0x20210001u, 9, 4f, 5f, 6f),
+ teleportTimestampAdvanced: true);
+ harness.Presentation.Enqueue(TeleportAnimEvent.Place);
+ harness.Controller.Tick(0.016f);
+ order.Clear();
+ harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete);
+
+ harness.Controller.Tick(0.016f);
+
+ Assert.True(Index(order, "enter-world") < Index(order, "login-complete"));
+ Assert.True(Index(order, "login-complete") < Index(order, "[world-reveal] event=complete"));
+ Assert.True(Index(order, "[world-reveal] event=complete") < Index(order, "presentation-reset"));
+ Assert.False(harness.Controller.IsActive);
+ Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
+ Assert.Equal(1, harness.Session.LoginCompleteCount);
+ }
+
+ [Fact]
+ public void NewerStart_ReplacesOldDestinationWithoutReusingIt()
+ {
+ var harness = new Harness();
+ harness.Controller.OnTeleportStarted(10);
+ harness.Controller.OfferDestination(
+ Position(0x20210001u, 10, 1f, 1f, 1f),
+ teleportTimestampAdvanced: true);
+
+ harness.Controller.OnTeleportStarted(11);
+ harness.Controller.Tick(LocalPlayerTeleportController.MaximumWorldHoldSeconds);
+
+ Assert.Equal(default, harness.Placement.Position);
+ Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
+
+ harness.Controller.OfferDestination(
+ Position(0x20210001u, 11, 2f, 2f, 2f),
+ teleportTimestampAdvanced: true);
+ harness.Presentation.Enqueue(TeleportAnimEvent.Place);
+ harness.Controller.Tick(0.016f);
+ Assert.Equal(new Vector3(2f), harness.Placement.Position);
+ }
+
+ [Fact]
+ public void SessionReset_ClearsTransitCancelsRevealAndRequiresRecenterConvergence()
+ {
+ var harness = new Harness();
+ harness.Controller.OnTeleportStarted(12);
+ harness.Controller.OfferDestination(
+ Position(0x20210001u, 12, 1f, 2f, 3f),
+ teleportTimestampAdvanced: true);
+
+ harness.Controller.ResetSession();
+
+ Assert.False(harness.Controller.IsActive);
+ Assert.True(harness.Reveal.Snapshot.Cancelled);
+ Assert.True(harness.Streaming.LastResetWasSessionEnding);
+
+ var failed = new Harness();
+ failed.Streaming.ResetResult = false;
+ Assert.Throws(() => failed.Controller.ResetSession());
+ }
+
+ [Fact]
+ public void StaleStart_DoesNotMutateInputModeOrPresentation()
+ {
+ var harness = new Harness();
+ harness.Authority.IsFresh = false;
+
+ harness.Controller.OnTeleportStarted(13);
+
+ Assert.Equal(0, harness.Input.EndCount);
+ Assert.Equal(0, harness.Mode.EnterPortalCount);
+ Assert.Null(harness.Presentation.BeginProjection);
+ Assert.False(harness.Controller.IsActive);
+ }
+
+ [Fact]
+ public void ReentrantNewStartDuringPlacement_CannotBeCompletedByOldTail()
+ {
+ var harness = new Harness();
+ harness.Controller.OnTeleportStarted(20);
+ harness.Controller.OfferDestination(
+ Position(0x20210001u, 20, 1f, 2f, 3f),
+ teleportTimestampAdvanced: true);
+ harness.Streaming.PriorityClearCount = 0;
+ harness.Placement.OnPlace = () => harness.Controller.OnTeleportStarted(21);
+ harness.Presentation.Enqueue(TeleportAnimEvent.Place);
+
+ harness.Controller.Tick(0.016f);
+
+ Assert.True(harness.Controller.IsActive);
+ Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
+ Assert.Equal(0, harness.Reveal.PortalMaterializationCount);
+ Assert.Equal(1, harness.Streaming.PriorityClearCount);
+ }
+
+ [Fact]
+ public void ReentrantNewStartDuringLoginComplete_PreservesReplacementLifetime()
+ {
+ var harness = new Harness();
+ harness.Controller.OnTeleportStarted(30);
+ harness.Controller.OfferDestination(
+ Position(0x20210001u, 30, 1f, 2f, 3f),
+ teleportTimestampAdvanced: true);
+ harness.Session.OnSend = () => harness.Controller.OnTeleportStarted(31);
+ harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete);
+
+ harness.Controller.Tick(0.016f);
+
+ Assert.True(harness.Controller.IsActive);
+ Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
+ Assert.False(harness.Reveal.Snapshot.Completed);
+ Assert.Equal(1, harness.Session.LoginCompleteCount);
+ }
+
+ [Fact]
+ public void DisposeFailure_CanBeRetriedWithoutDoubleDisposingAfterSuccess()
+ {
+ var harness = new Harness();
+ harness.Presentation.DisposeFailuresRemaining = 1;
+
+ Assert.Throws(() => harness.Controller.Dispose());
+ harness.Controller.Dispose();
+ harness.Controller.Dispose();
+
+ Assert.Equal(2, harness.Presentation.DisposeCount);
+ }
+
+ [Fact]
+ public void ConcretePlacement_CommitsEntityControllerAndOneSpatialReconcile()
+ {
+ const uint guid = 0x50000001u;
+ const uint cell = 0x20210001u;
+ var world = new GpuWorldState();
+ world.AddLandblock(new LoadedLandblock(
+ 0x2021FFFFu,
+ new DatReaderWriter.DBObjs.LandBlock(),
+ Array.Empty()));
+ var runtime = new LiveEntityRuntime(world, new NullResources());
+ runtime.RegisterLiveEntity(Spawn(guid, cell));
+ WorldEntity entity = runtime.MaterializeLiveEntity(
+ guid,
+ cell,
+ id => new WorldEntity
+ {
+ Id = id,
+ ServerGuid = guid,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ })!;
+ var identity = new LocalPlayerIdentityState { ServerGuid = guid };
+ var controllerSlot = new LocalPlayerControllerSlot
+ {
+ Controller = new PlayerMovementController(new PhysicsEngine()),
+ };
+ controllerSlot.Controller.SetPosition(Vector3.Zero, cell, Vector3.Zero);
+ var cameras = new ChaseCameraInputState
+ {
+ Legacy = new ChaseCamera(),
+ Retail = new RetailChaseCamera(),
+ };
+ var origin = new LiveWorldOriginState();
+ origin.SetPlaceholder(0x20, 0x21);
+ var spatial = new FakeSpatialReconcile(() => new PlacementSnapshot(
+ entity.Position,
+ entity.ParentCellId ?? 0u,
+ entity.Rotation,
+ controllerSlot.Controller.Position,
+ controllerSlot.Controller.CellId,
+ controllerSlot.Controller.BodyOrientation));
+ var placement = new LocalPlayerTeleportPlacement(
+ new PhysicsEngine(),
+ runtime,
+ identity,
+ controllerSlot,
+ new LocalPlayerPhysicsHostSlot(),
+ cameras,
+ origin,
+ spatial);
+ var position = new Vector3(12f, 24f, 6f);
+ Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f);
+
+ placement.Place(position, cell, rotation, forced: false);
+
+ Assert.Equal(entity.Position, controllerSlot.Controller.Position);
+ Assert.Equal(entity.ParentCellId, controllerSlot.Controller.CellId);
+ Assert.Equal(cell & 0xFFFF0000u, entity.ParentCellId & 0xFFFF0000u);
+ Assert.Equal(rotation, entity.Rotation);
+ Assert.Equal(rotation, controllerSlot.Controller.BodyOrientation);
+ Assert.Equal(1, spatial.Count);
+ Assert.Equal(entity.Position, spatial.Snapshot.EntityPosition);
+ Assert.Equal(entity.ParentCellId, spatial.Snapshot.EntityCell);
+ Assert.Equal(entity.Rotation, spatial.Snapshot.EntityRotation);
+ Assert.Equal(
+ controllerSlot.Controller.Position,
+ spatial.Snapshot.ControllerPosition);
+ Assert.Equal(
+ controllerSlot.Controller.CellId,
+ spatial.Snapshot.ControllerCell);
+ Assert.Equal(
+ controllerSlot.Controller.BodyOrientation,
+ spatial.Snapshot.ControllerRotation);
+ }
+
+ private static int Index(IReadOnlyList events, string prefix)
+ {
+ for (int i = 0; i < events.Count; i++)
+ {
+ if (events[i].StartsWith(prefix, StringComparison.Ordinal))
+ return i;
+ }
+
+ return int.MaxValue;
+ }
+
+ private static WorldSession.EntityPositionUpdate Position(
+ uint cellId,
+ ushort teleportSequence,
+ float x,
+ float y,
+ float z) => new(
+ 0x50000001u,
+ new CreateObject.ServerPosition(
+ cellId,
+ x,
+ y,
+ z,
+ 1f,
+ 0f,
+ 0f,
+ 0f),
+ null,
+ null,
+ true,
+ 1,
+ 1,
+ teleportSequence,
+ 1);
+
+ private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new(
+ Guid: guid,
+ Position: new CreateObject.ServerPosition(
+ cell,
+ 0f,
+ 0f,
+ 0f,
+ 1f,
+ 0f,
+ 0f,
+ 0f),
+ SetupTableId: 0x02000001u,
+ AnimPartChanges: [],
+ TextureChanges: [],
+ SubPalettes: [],
+ BasePaletteId: null,
+ ObjScale: null,
+ Name: "player",
+ ItemType: null,
+ MotionState: null,
+ MotionTableId: null);
+
+ private sealed class NullResources : ILiveEntityResourceLifecycle
+ {
+ public void Register(WorldEntity entity) { }
+ public void Unregister(WorldEntity entity) { }
+ }
+
+ private sealed class FakeSpatialReconcile : ILiveSpatialReconcilePhase
+ {
+ private readonly Func _capture;
+
+ public FakeSpatialReconcile(Func capture) =>
+ _capture = capture;
+
+ public int Count;
+ public PlacementSnapshot Snapshot;
+
+ public void Reconcile()
+ {
+ Snapshot = _capture();
+ Count++;
+ }
+ }
+
+ private readonly record struct PlacementSnapshot(
+ Vector3 EntityPosition,
+ uint EntityCell,
+ Quaternion EntityRotation,
+ Vector3 ControllerPosition,
+ uint ControllerCell,
+ Quaternion ControllerRotation);
+
+ private sealed class Harness
+ {
+ public readonly FakeAuthority Authority = new();
+ public readonly FakeInput Input = new();
+ public readonly FakeMode Mode;
+ public readonly FakeStreaming Streaming;
+ public readonly FakePlacement Placement;
+ public readonly FakeSession Session;
+ public readonly FakePresentation Presentation;
+ public readonly WorldRevealCoordinator Reveal;
+ public readonly LocalPlayerTeleportController Controller;
+
+ public Harness(
+ int centerX = 0x20,
+ int centerY = 0x21,
+ bool worldReady = true,
+ List? order = null)
+ {
+ order ??= new List();
+ Mode = new FakeMode(order);
+ Streaming = new FakeStreaming(centerX, centerY, order);
+ Placement = new FakePlacement(order);
+ Session = new FakeSession(order);
+ Presentation = new FakePresentation(order);
+ Reveal = new WorldRevealCoordinator(
+ isRenderNeighborhoodReady: (_, _) => worldReady,
+ isSpawnCellReady: _ => worldReady,
+ isTerrainNeighborhoodReady: (_, _) => worldReady,
+ areCompositeTexturesReady: () => worldReady,
+ prepareCompositeTextures: (_, _) => { },
+ invalidateCompositeTextures: () => { },
+ isSpawnClaimUnhydratable: _ => false,
+ log: order.Add);
+ Controller = new LocalPlayerTeleportController(
+ Authority,
+ Input,
+ Mode,
+ Streaming,
+ Reveal,
+ Placement,
+ Session,
+ Presentation);
+ }
+ }
+
+ private sealed class FakeAuthority : ILocalPlayerTeleportAuthority
+ {
+ public bool IsFresh = true;
+ public bool IsFreshStart(ushort sequence) => IsFresh;
+ }
+
+ private sealed class FakeInput : ILocalPlayerTeleportInputLifetime
+ {
+ public int EndCount;
+ public void EndMouseLook() => EndCount++;
+ }
+
+ private sealed class FakeMode : ILocalPlayerTeleportModeOperations
+ {
+ private readonly List _order;
+
+ public FakeMode(List order)
+ {
+ _order = order;
+ Controller = new PlayerMovementController(new PhysicsEngine());
+ Controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero);
+ }
+
+ public PlayerMovementController? Controller { get; set; }
+ public Matrix4x4 Projection => Matrix4x4.Identity;
+ public int EnterPortalCount;
+ public Func? RebuildOnEnter;
+
+ public bool TryEnterPortalSpace()
+ {
+ EnterPortalCount++;
+ Controller ??= RebuildOnEnter?.Invoke();
+ if (Controller is null)
+ return false;
+ Controller.State = PlayerState.PortalSpace;
+ return true;
+ }
+
+ public void EnterWorld()
+ {
+ _order.Add("enter-world");
+ if (Controller is { } controller)
+ controller.State = PlayerState.InWorld;
+ }
+ }
+
+ private sealed class FakeStreaming : ILocalPlayerTeleportStreamingOperations
+ {
+ private readonly List _order;
+
+ public FakeStreaming(int centerX, int centerY, List order)
+ {
+ CenterX = centerX;
+ CenterY = centerY;
+ _order = order;
+ }
+
+ public int CenterX { get; }
+ public int CenterY { get; }
+ public bool IsRecenterPending => RecenterPending;
+ public bool RecenterPending;
+ public bool ResetResult = true;
+ public bool LastResetWasSessionEnding;
+ public int PriorityClearCount;
+ public readonly List<(int X, int Y, bool Sealed)> Recenters = new();
+ public readonly List<(uint Landblock, int Radius)> PriorityWrites = new();
+
+ public bool BeginRecenter(int x, int y, bool isSealedDungeon)
+ {
+ Recenters.Add((x, y, isSealedDungeon));
+ return !RecenterPending;
+ }
+
+ public bool ResetRecenter(bool sessionEnding)
+ {
+ LastResetWasSessionEnding = sessionEnding;
+ return ResetResult;
+ }
+
+ public bool IsSealedDungeon(uint cellId) =>
+ (cellId & 0xFFFFu) >= 0x0100u;
+
+ public void SetPriority(uint landblockId, int radius) =>
+ PriorityWrites.Add((landblockId, radius));
+
+ public void ClearPriority()
+ {
+ PriorityClearCount++;
+ _order.Add("priority-clear");
+ }
+ }
+
+ private sealed class FakePlacement : ILocalPlayerTeleportPlacement
+ {
+ private readonly List _order;
+
+ public FakePlacement(List order) => _order = order;
+
+ public Vector3 Position;
+ public uint CellId;
+ public Quaternion Rotation;
+ public bool Forced;
+ public Action? OnPlace;
+
+ public void Place(Vector3 position, uint cellId, Quaternion rotation, bool forced)
+ {
+ _order.Add("placement");
+ Position = position;
+ CellId = cellId;
+ Rotation = rotation;
+ Forced = forced;
+ OnPlace?.Invoke();
+ }
+ }
+
+ private sealed class FakeSession : ILocalPlayerTeleportSession
+ {
+ private readonly List _order;
+
+ public FakeSession(List order) => _order = order;
+
+ public int LoginCompleteCount;
+ public Action? OnSend;
+
+ public void SendLoginComplete()
+ {
+ _order.Add("login-complete");
+ LoginCompleteCount++;
+ OnSend?.Invoke();
+ }
+ }
+
+ private sealed class FakePresentation : ILocalPlayerTeleportPresentation
+ {
+ private readonly List _order;
+ private readonly Queue> _events = new();
+
+ public FakePresentation(List order) => _order = order;
+
+ public bool IsPortalViewportVisible { get; private set; }
+ public int CurrentTunnelFrame => 72;
+ public Matrix4x4? BeginProjection;
+ public bool EmitPlaceWhenReady;
+ public readonly List WorldReadyValues = new();
+ public int DisposeFailuresRemaining;
+ public int DisposeCount;
+
+ public void Enqueue(params TeleportAnimEvent[] events) => _events.Enqueue(events);
+
+ public void Begin(Matrix4x4 projection)
+ {
+ BeginProjection = projection;
+ _order.Add("presentation-begin");
+ }
+
+ public (TeleportAnimSnapshot Snapshot, IReadOnlyList Events)
+ Tick(float deltaSeconds, bool worldReady)
+ {
+ WorldReadyValues.Add(worldReady);
+ if (_events.Count > 0)
+ return (default, _events.Dequeue());
+ if (EmitPlaceWhenReady && worldReady)
+ {
+ EmitPlaceWhenReady = false;
+ return (default, new[] { TeleportAnimEvent.Place });
+ }
+
+ return (default, Array.Empty());
+ }
+
+ public void TickTunnel(float deltaSeconds) => _order.Add("tunnel-tick");
+ public void EnterTunnel() => IsPortalViewportVisible = true;
+ public void ExitTunnel() => IsPortalViewportVisible = false;
+
+ public void Reset()
+ {
+ _order.Add("presentation-reset");
+ IsPortalViewportVisible = false;
+ BeginProjection = null;
+ _events.Clear();
+ }
+
+ public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) => projection;
+ public ICamera ApplyViewPlane(ICamera camera) => camera;
+ public void DrawPortalViewport(int width, int height, Matrix4x4 projection) { }
+ public void Dispose()
+ {
+ DisposeCount++;
+ if (DisposeFailuresRemaining-- > 0)
+ throw new InvalidOperationException("injected disposal failure");
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
index 498d6694..1c2f605f 100644
--- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
+++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
@@ -175,7 +175,16 @@ public sealed class GameWindowLiveEntityCompositionTests
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs"));
- Assert.Contains("_localPlayerMode.ResetSession();", source, StringComparison.Ordinal);
+ Assert.Contains("_playerModeController?.ResetSession();", source,
+ StringComparison.Ordinal);
+ string playerModeSource = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "src",
+ "AcDream.App",
+ "Input",
+ "PlayerModeController.cs"));
+ Assert.Contains("_mode.ResetSession();", playerModeSource,
+ StringComparison.Ordinal);
Assert.Contains(
"_liveEntityNetworkUpdates?.ResetSessionState();",
source,
diff --git a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs
index c2a35f44..dac86aa2 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs
@@ -124,6 +124,40 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
Assert.Equal(12f, watcher.Position.Frame.Origin.X);
}
+ [Fact]
+ public void PreparedRebind_DoesNotPublishDelegatesUntilCommit()
+ {
+ const uint guid = 0x70000025u;
+ var runtime = Runtime();
+ LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
+ EntityPhysicsHost original = Host(
+ guid,
+ position: new Vector3(1f, 0f, 0f));
+ runtime.InstallPhysicsHost(record, original);
+ EntityPhysicsHost configuration = Host(
+ guid,
+ position: new Vector3(9f, 0f, 0f));
+
+ EntityPhysicsHost stable = EntityPhysicsHost.SelectStableHostWithoutRebind(
+ runtime,
+ record,
+ configuration);
+
+ // A late DAT/physics/camera preparation failure occurs here in the
+ // player-mode transaction. The live record must still expose its
+ // previous delegates and manager identity.
+ Assert.Same(original, stable);
+ Assert.Same(original, record.PhysicsHost);
+ Assert.Equal(1f, original.Position.Frame.Origin.X);
+
+ EntityPhysicsHost committed = EntityPhysicsHost.InstallOrRebind(
+ runtime,
+ record,
+ configuration);
+ Assert.Same(original, committed);
+ Assert.Equal(9f, committed.Position.Frame.Origin.X);
+ }
+
[Fact]
public void SameGuidReplacement_HostCallbacksRemainIncarnationLocal()
{
diff --git a/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs b/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs
index aadcd00e..eb4b7261 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs
@@ -23,7 +23,7 @@ public sealed class LiveEntityWorldOriginCoordinatorTests
world,
Reveal(),
() => playerGuid,
- _ => false);
+ new FakeSealedDungeonCellClassifier());
LiveEntityOriginInitialization ignored = coordinator.TryInitialize(
Spawn(0x50000002u, 0x09090001u));
@@ -59,7 +59,7 @@ public sealed class LiveEntityWorldOriginCoordinatorTests
world,
Reveal(),
() => playerGuid,
- _ => false);
+ new FakeSealedDungeonCellClassifier());
Assert.True(coordinator.TryInitialize(Spawn(playerGuid, 0x03040001u)).IsKnown);
origin.Reset();
@@ -114,4 +114,10 @@ public sealed class LiveEntityWorldOriginCoordinatorTests
ItemType: null,
MotionState: null,
MotionTableId: null);
+
+ private sealed class FakeSealedDungeonCellClassifier
+ : ISealedDungeonCellClassifier
+ {
+ public bool IsSealedDungeon(uint cellId) => false;
+ }
}
diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
index dd1a69eb..ee92d1e7 100644
--- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
+++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
@@ -414,18 +414,32 @@ public sealed class UpdateFrameOrchestratorTests
StringComparison.Ordinal);
Assert.DoesNotContain("wantCaptureMouse: ()", source,
StringComparison.Ordinal);
- Assert.Equal(5, CountOccurrences(
+ Assert.Equal(2, CountOccurrences(
source,
"_gameplayInputFrame?.EndMouseLook();"));
Assert.Contains(
"new(\"mouse capture\", () => _gameplayInputFrame?.EndMouseLook())",
source,
StringComparison.Ordinal);
+ string teleportSource = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "src",
+ "AcDream.App",
+ "Streaming",
+ "LocalPlayerTeleportController.cs"));
AssertAppearsInOrder(
- source,
- "_teleportTransit.CanBegin(teleportSequence)",
- "_gameplayInputFrame?.EndMouseLook();",
- "_teleportTransit.QueueStart(teleportSequence)");
+ teleportSource,
+ "_transit.CanBegin(teleportSequence)",
+ "_input.EndMouseLook();",
+ "_transit.QueueStart(teleportSequence)");
+ string playerModeSource = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "src",
+ "AcDream.App",
+ "Input",
+ "PlayerModeController.cs"));
+ Assert.Contains("_input.EndMouseLook();", playerModeSource,
+ StringComparison.Ordinal);
AssertAppearsInOrder(
source,
"private void OnFocusChanged(bool focused)",
@@ -433,6 +447,50 @@ public sealed class UpdateFrameOrchestratorTests
"_gameplayInputFrame?.EndMouseLook();");
}
+ [Fact]
+ public void GameWindow_DelegatesTheCompleteLocalTeleportLifetime()
+ {
+ string root = FindRepoRoot();
+ string source = File.ReadAllText(Path.Combine(
+ root,
+ "src",
+ "AcDream.App",
+ "Rendering",
+ "GameWindow.cs"));
+ string networkSource = File.ReadAllText(Path.Combine(
+ root,
+ "src",
+ "AcDream.App",
+ "Physics",
+ "LiveEntityNetworkUpdateController.cs"));
+
+ Assert.Contains(
+ "new AcDream.App.Streaming.LocalPlayerTeleportController(",
+ source,
+ StringComparison.Ordinal);
+ Assert.Equal(1, CountOccurrences(
+ source,
+ "_localPlayerTeleport!.Tick(frameDelta);"));
+ Assert.DoesNotContain("_teleportTransit", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("_teleportAnim", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("_teleportViewPlane", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("_pendingTeleport", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("AimTeleportDestination", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("ResetTeleportTransitState", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("PlaceTeleportArrival", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("TryActivatePendingTeleportPresentation", source,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("Action", networkSource,
+ StringComparison.Ordinal);
+ Assert.Contains("ILocalPlayerTeleportNetworkSink", networkSource,
+ StringComparison.Ordinal);
+ AssertAppearsInOrder(
+ source,
+ "_liveEntityLiveness?.Tick(ClientTimerNow());",
+ "_localPlayerTeleport!.Tick(frameDelta);",
+ "_playerModeAutoEntry?.TryEnter();");
+ }
+
[Fact]
public void GameplayInputOwnersUseTypedSeamsWithoutGameWindowBackReferences()
{
@@ -498,6 +556,45 @@ public sealed class UpdateFrameOrchestratorTests
BindingFlags.Instance | BindingFlags.NonPublic),
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
+ FieldInfo approachCompletions = Assert.Single(
+ typeof(AcDream.App.Input.PlayerModeController).GetFields(
+ BindingFlags.Instance | BindingFlags.NonPublic),
+ field => field.Name == "_approachCompletions");
+ Assert.Equal(
+ typeof(AcDream.App.Interaction.IPlayerApproachCompletionLifetimeOwner),
+ approachCompletions.FieldType);
+ Assert.DoesNotContain(
+ typeof(AcDream.App.Input.PlayerModeController).GetFields(
+ BindingFlags.Instance | BindingFlags.NonPublic),
+ field => field.FieldType
+ == typeof(AcDream.App.Interaction.SelectionInteractionController));
+ Assert.DoesNotContain(
+ typeof(AcDream.App.Interaction.PlayerApproachCompletionState).GetFields(
+ BindingFlags.Instance | BindingFlags.NonPublic),
+ field => typeof(Delegate).IsAssignableFrom(field.FieldType));
+
+ string playerModeSource = File.ReadAllText(Path.Combine(
+ FindRepoRoot(),
+ "src",
+ "AcDream.App",
+ "Input",
+ "PlayerModeController.cs"));
+ AssertAppearsInOrder(
+ playerModeSource,
+ "controller.PreparePositionForCommit(",
+ "_camera.EnterChaseMode(legacyCamera, retailCamera);",
+ "EntityPhysicsHost.SelectStableHostWithoutRebind(",
+ "_shadow.SyncPose(",
+ "EntityPhysicsHost.InstallOrRebind(",
+ "playerEntity.SetPosition(initial.Position);",
+ "controller.CommitPreparedPosition();",
+ "_controllerSlot.Controller = controller;",
+ "_mode.IsPlayerMode = true;");
+ Assert.Contains("_shadow.Restore(playerEntity, priorShadow);", playerModeSource,
+ StringComparison.Ordinal);
+ Assert.Contains("_camera.RestoreState(priorCamera);", playerModeSource,
+ StringComparison.Ordinal);
+
string mouseLookSource = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
diff --git a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
index 4ba0fea6..bc63c7f1 100644
--- a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
+++ b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
@@ -27,6 +27,7 @@ public sealed class AutoEnterPlayerModeTests
// Defaults TRUE: the world-reveal hold is not under test in the
// original K.2 cases (see WorldNotReady test for it).
public bool WorldReady = true;
+ public bool PlayerModeActive;
public int EnteredCount;
public PlayerModeAutoEntry Build() =>
@@ -35,7 +36,8 @@ public sealed class AutoEnterPlayerModeTests
isPlayerEntityPresent: () => PlayerEntityPresent,
isPlayerControllerReady: () => PlayerControllerReady,
isWorldReady: () => WorldReady,
- enterPlayerMode: () => EnteredCount++);
+ enterPlayerMode: () => EnteredCount++,
+ isPlayerModeActive: () => PlayerModeActive);
}
[Fact]
@@ -180,4 +182,25 @@ public sealed class AutoEnterPlayerModeTests
Assert.True(guard.TryEnter());
Assert.Equal(1, s.EnteredCount);
}
+
+ [Fact]
+ public void PortalEntryThatAlreadyActivatedPlayerMode_RetiresArmedGuard()
+ {
+ var s = new State
+ {
+ LiveInWorld = true,
+ PlayerEntityPresent = true,
+ PlayerControllerReady = true,
+ };
+ var guard = s.Build();
+ guard.Arm();
+
+ // F751/player-mode activation occurs before the auto-entry phase in
+ // the update graph. The guard must not rebuild that PortalSpace owner.
+ s.PlayerModeActive = true;
+
+ Assert.False(guard.TryEnter());
+ Assert.False(guard.IsArmed);
+ Assert.Equal(0, s.EnteredCount);
+ }
}