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 <noreply@openai.com>
This commit is contained in:
parent
c557038353
commit
eeb0f6b45c
29 changed files with 3311 additions and 1073 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// </summary>
|
||||
internal sealed class GameplayInputFrameController : IGameplayInputFramePhase
|
||||
internal sealed class GameplayInputFrameController
|
||||
: IGameplayInputFramePhase,
|
||||
AcDream.App.Streaming.ILocalPlayerTeleportInputLifetime
|
||||
{
|
||||
private readonly InputDispatcher? _dispatcher;
|
||||
private readonly DispatcherMovementInputSource _movement;
|
||||
|
|
|
|||
75
src/AcDream.App/Input/LocalPlayerAnimationController.cs
Normal file
75
src/AcDream.App/Input/LocalPlayerAnimationController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerAnimationController
|
||||
{
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animations;
|
||||
private readonly LiveEntityAnimationPresenter _presenter;
|
||||
private readonly AnimationHookFrameQueue _hooks;
|
||||
|
||||
public LocalPlayerAnimationController(
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one mutable local physics-host slot. Player-mode lifecycle owns
|
||||
/// assignment; teleport and update owners receive the read-only seam.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Framebuffer aspect published by the window host.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ public sealed class PlayerModeAutoEntry
|
|||
private readonly Func<bool> _isPlayerEntityPresent;
|
||||
private readonly Func<bool> _isPlayerControllerReady;
|
||||
private readonly Func<bool> _isWorldReady;
|
||||
private readonly Func<bool> _isPlayerModeActive;
|
||||
private readonly Action _enterPlayerMode;
|
||||
|
||||
private bool _armed;
|
||||
|
|
@ -76,13 +77,15 @@ public sealed class PlayerModeAutoEntry
|
|||
Func<bool> isPlayerEntityPresent,
|
||||
Func<bool> isPlayerControllerReady,
|
||||
Func<bool> isWorldReady,
|
||||
Action enterPlayerMode)
|
||||
Action enterPlayerMode,
|
||||
Func<bool>? 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);
|
||||
}
|
||||
|
||||
/// <summary>True iff <see cref="TryEnter"/> 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;
|
||||
|
|
|
|||
572
src/AcDream.App/Input/PlayerModeController.cs
Normal file
572
src/AcDream.App/Input/PlayerModeController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<LiveEntityAnimationState> _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<LiveEntityAnimationState> 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<Exception>();
|
||||
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<Exception>();
|
||||
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<Exception> { 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<DatReaderWriter.DBObjs.Setup>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <c>SnapToCell</c> rather than delta-syncing through the setter.
|
||||
/// </summary>
|
||||
public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
|
||||
=> SetPositionCore(
|
||||
pos,
|
||||
cellId,
|
||||
cellLocal,
|
||||
publishSharedState: true);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
|
|
|
|||
141
src/AcDream.App/Interaction/PlayerApproachCompletionState.cs
Normal file
141
src/AcDream.App/Interaction/PlayerApproachCompletionState.cs
Normal file
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Same-thread mailbox between local MoveTo completion and the selection/
|
||||
/// item interaction phase. It deliberately owns no query, item, transport,
|
||||
/// window, or callback reference.
|
||||
/// </summary>
|
||||
internal sealed class PlayerApproachCompletionState
|
||||
: IPlayerApproachCompletionLifetimeOwner,
|
||||
IPlayerApproachTokenSource
|
||||
{
|
||||
private readonly Queue<PlayerApproachCompletion> _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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
/// </summary>
|
||||
bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null);
|
||||
bool BeginApproach(
|
||||
InteractionApproach approach,
|
||||
Action<PlayerApproachToken>? armAfterCancel = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -20,13 +22,18 @@ internal interface IPlayerInteractionMovementSink
|
|||
/// the same MovementManager used by authoritative movement packets.
|
||||
/// </summary>
|
||||
internal sealed class PlayerInteractionMovementSink(
|
||||
Func<PlayerMovementController?> player)
|
||||
Func<PlayerMovementController?> player,
|
||||
IPlayerApproachTokenSource approachTokens)
|
||||
: IPlayerInteractionMovementSink
|
||||
{
|
||||
private readonly Func<PlayerMovementController?> _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<PlayerApproachToken>? 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
|
||||
|
|
|
|||
|
|
@ -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<string>? _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<string>? toast = null)
|
||||
Action<string>? 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
|
|||
|
||||
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -193,6 +193,36 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
|
|||
return existing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an exact incarnation and returns the manager-stable host that
|
||||
/// a later <see cref="InstallOrRebind"/> 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.
|
||||
/// </summary>
|
||||
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."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the position-only CPhysicsObj facade used before an animated or
|
||||
/// player movement owner exists. The delegates capture the exact live
|
||||
|
|
|
|||
|
|
@ -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<PlayerMovementController?> _playerControllerSource;
|
||||
private readonly LocalPlayerOutboundController _localPlayerOutbound;
|
||||
private readonly Func<EntityPhysicsHost?> _playerHostSource;
|
||||
|
|
@ -57,7 +57,6 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
private readonly IPhysicsScriptTimeSource _gameTime;
|
||||
private readonly Func<WorldSession?> _session;
|
||||
private readonly LiveEntityInboundAuthorityGate _authorityGate;
|
||||
private readonly Action<WorldSession.EntityPositionUpdate> _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<WorldSession.EntityPositionUpdate> teleportTransit,
|
||||
AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink localPlayerTeleport,
|
||||
Func<PlayerMovementController?> playerControllerSource,
|
||||
LocalPlayerOutboundController localPlayerOutbound,
|
||||
Func<EntityPhysicsHost?> playerHostSource,
|
||||
|
|
@ -104,7 +103,6 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
IPhysicsScriptTimeSource gameTime,
|
||||
Func<WorldSession?> session,
|
||||
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
|
||||
Action<WorldSession.EntityPositionUpdate> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
117
src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs
Normal file
117
src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Restores a previously captured camera lifetime before notifying
|
||||
/// subscribers. State remains restored even when a subscriber throws.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
761
src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
Normal file
761
src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
Normal file
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construction-order bridge for the inbound session. It carries no teleport
|
||||
/// state: after binding, every packet and reset is forwarded to the one
|
||||
/// <see cref="LocalPlayerTeleportController"/> owner.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits the local player's deferred portal arrival. It owns the exact
|
||||
/// Place -> root/controller/camera mutation -> spatial reconcile edge.
|
||||
/// </summary>
|
||||
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<TeleportAnimEvent> 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<TeleportAnimEvent> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerTeleportController
|
||||
: ILocalPlayerTeleportFramePhase,
|
||||
ILocalPlayerTeleportNetworkSink,
|
||||
IDisposable
|
||||
{
|
||||
internal const float MaximumWorldHoldSeconds = 10f;
|
||||
internal const int NearRingRadius =
|
||||
WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius;
|
||||
|
||||
private readonly TeleportTransitCoordinator<WorldSession.EntityPositionUpdate> _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;
|
||||
}
|
||||
}
|
||||
44
src/AcDream.App/Streaming/SealedDungeonCellClassifier.cs
Normal file
44
src/AcDream.App/Streaming/SealedDungeonCellClassifier.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a server-supplied full cell ID using the EnvCell's retail
|
||||
/// <see cref="EnvCellFlags.SeenOutside"/> bit. Missing, outdoor, and structural
|
||||
/// shell IDs are never guessed to be sealed.
|
||||
/// </summary>
|
||||
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<EnvCell>(cellId);
|
||||
return envCell is not null
|
||||
&& !envCell.Flags.HasFlag(EnvCellFlags.SeenOutside);
|
||||
}
|
||||
}
|
||||
|
|
@ -114,7 +114,7 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC
|
|||
private readonly GpuWorldState _worldState;
|
||||
private readonly WorldRevealCoordinator _worldReveal;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<uint, bool> _isSealedDungeonCell;
|
||||
private readonly ISealedDungeonCellClassifier _sealedDungeonCells;
|
||||
private readonly Action<string>? _diagnostic;
|
||||
|
||||
public LiveEntityWorldOriginCoordinator(
|
||||
|
|
@ -123,7 +123,7 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC
|
|||
GpuWorldState worldState,
|
||||
WorldRevealCoordinator worldReveal,
|
||||
Func<uint> playerGuid,
|
||||
Func<uint, bool> isSealedDungeonCell,
|
||||
ISealedDungeonCellClassifier sealedDungeonCells,
|
||||
Action<string>? 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(
|
||||
|
|
|
|||
|
|
@ -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<uint, IPhysicsObjHost>();
|
||||
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<ushort, SWVertex>(),
|
||||
},
|
||||
Polygons = new Dictionary<ushort, Polygon>(),
|
||||
};
|
||||
var envCell = new DatEnvCell
|
||||
{
|
||||
CellPortals = [],
|
||||
VisibleCells = [],
|
||||
};
|
||||
physics.DataCache.CacheCellStruct(
|
||||
cellId,
|
||||
envCell,
|
||||
cellStruct,
|
||||
Matrix4x4.Identity);
|
||||
}
|
||||
return physics;
|
||||
}
|
||||
|
||||
private static EntityPhysicsHost Host(
|
||||
uint guid,
|
||||
Func<uint, IPhysicsObjHost?> 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: () => { });
|
||||
}
|
||||
|
|
@ -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 _));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -104,17 +104,20 @@ public sealed class SelectionInteractionControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class Movement : IPlayerInteractionMovementSink
|
||||
private sealed class Movement(IPlayerApproachTokenSource approachTokens)
|
||||
: IPlayerInteractionMovementSink
|
||||
{
|
||||
public List<InteractionApproach> 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<PlayerApproachToken>? 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<string> 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<InvalidOperationException>(() =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<string>();
|
||||
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<WorldEntity>()));
|
||||
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<MeshRef>(),
|
||||
})!;
|
||||
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<string> 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<PlacementSnapshot> _capture;
|
||||
|
||||
public FakeSpatialReconcile(Func<PlacementSnapshot> 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<string>? order = null)
|
||||
{
|
||||
order ??= new List<string>();
|
||||
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<string> _order;
|
||||
|
||||
public FakeMode(List<string> 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<PlayerMovementController?>? 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<string> _order;
|
||||
|
||||
public FakeStreaming(int centerX, int centerY, List<string> 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<string> _order;
|
||||
|
||||
public FakePlacement(List<string> 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<string> _order;
|
||||
|
||||
public FakeSession(List<string> 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<string> _order;
|
||||
private readonly Queue<IReadOnlyList<TeleportAnimEvent>> _events = new();
|
||||
|
||||
public FakePresentation(List<string> order) => _order = order;
|
||||
|
||||
public bool IsPortalViewportVisible { get; private set; }
|
||||
public int CurrentTunnelFrame => 72;
|
||||
public Matrix4x4? BeginProjection;
|
||||
public bool EmitPlaceWhenReady;
|
||||
public readonly List<bool> 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<TeleportAnimEvent> 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<TeleportAnimEvent>());
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WorldSession.EntityPositionUpdate>", 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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue