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:
Erik 2026-07-22 02:50:15 +02:00
parent c557038353
commit eeb0f6b45c
29 changed files with 3311 additions and 1073 deletions

View file

@ -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

View file

@ -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;

View 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);
}
}
}

View file

@ -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;
}
}

View file

@ -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;

View 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;
}
}

View file

@ -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

View 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);
}
}

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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);
}
}

View 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();
}

View file

@ -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

View 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 -&gt; root/controller/camera mutation -&gt; 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;
}
}

View 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);
}
}

View file

@ -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(

View file

@ -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: () => { });
}

View file

@ -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 _));
}
}

View file

@ -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);

View file

@ -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()
{

View file

@ -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);
}
}

View file

@ -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");
}
}
}

View file

@ -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,

View file

@ -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()
{

View file

@ -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;
}
}

View file

@ -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",

View file

@ -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);
}
}