From eeb0f6b45c96579a9c563c13410e0bb8c8e99eb4 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 02:50:15 +0200 Subject: [PATCH] refactor(runtime): extract local teleport and player mode Move local teleport, player-mode, animation, shadow, and sealed-dungeon lifetimes out of GameWindow. Make player-mode entry transactional and isolate approach completions by controller lifetime and approach generation so stale callbacks cannot affect replacements. Co-authored-by: Codex --- ...ndow-slice-6-update-frame-orchestration.md | 14 + .../Input/GameplayInputFrameController.cs | 4 +- .../Input/LocalPlayerAnimationController.cs | 75 ++ .../Input/LocalPlayerRuntimeState.cs | 71 + src/AcDream.App/Input/PlayerModeAutoEntry.cs | 10 +- src/AcDream.App/Input/PlayerModeController.cs | 572 ++++++++ .../Input/PlayerMovementController.cs | 49 +- .../PlayerApproachCompletionState.cs | 141 ++ .../PlayerInteractionMovementSink.cs | 17 +- .../SelectionInteractionController.cs | 42 +- src/AcDream.App/Physics/EntityPhysicsHost.cs | 30 + .../LiveEntityNetworkUpdateController.cs | 23 +- .../Physics/LocalPlayerShadowSynchronizer.cs | 117 ++ src/AcDream.App/Rendering/CameraController.cs | 23 + src/AcDream.App/Rendering/GameWindow.cs | 1174 +++-------------- .../LocalPlayerTeleportController.cs | 761 +++++++++++ .../Streaming/SealedDungeonCellClassifier.cs | 44 + .../World/LiveEntityHydrationPorts.cs | 11 +- ...PlayerMovementPlacementTransactionTests.cs | 93 ++ .../PlayerApproachCompletionStateTests.cs | 79 ++ .../PlayerInteractionMovementSinkTests.cs | 13 +- .../SelectionInteractionControllerTests.cs | 61 +- .../Rendering/CameraControllerTests.cs | 26 + .../LocalPlayerTeleportControllerTests.cs | 747 +++++++++++ .../GameWindowLiveEntityCompositionTests.cs | 11 +- .../LiveEntityPhysicsHostOwnershipTests.cs | 34 + .../LiveEntityWorldOriginCoordinatorTests.cs | 10 +- .../World/UpdateFrameOrchestratorTests.cs | 107 +- .../Input/AutoEnterPlayerModeTests.cs | 25 +- 29 files changed, 3311 insertions(+), 1073 deletions(-) create mode 100644 src/AcDream.App/Input/LocalPlayerAnimationController.cs create mode 100644 src/AcDream.App/Input/PlayerModeController.cs create mode 100644 src/AcDream.App/Interaction/PlayerApproachCompletionState.cs create mode 100644 src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs create mode 100644 src/AcDream.App/Streaming/LocalPlayerTeleportController.cs create mode 100644 src/AcDream.App/Streaming/SealedDungeonCellClassifier.cs create mode 100644 tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs create mode 100644 tests/AcDream.App.Tests/Interaction/PlayerApproachCompletionStateTests.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs 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); + } }