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

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