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

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