refactor(app): compose session and player startup

Move streaming, live-session, hydration, local-player, combat, and teleport construction behind the typed Phase-7 boundary. Add exact-owner runtime bindings and focused spawn-claim classification so partial startup rolls back without retaining old session targets while preserving the accepted construction and frame dependencies.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 18:28:32 +02:00
parent 3573da12e1
commit 7771c07fb6
31 changed files with 1759 additions and 532 deletions

View file

@ -67,6 +67,22 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
_owner = owner;
}
public IDisposable BindOwned(ICombatAttackOperations owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (_owner is not null)
throw new InvalidOperationException(
"Combat attack operations are already bound.");
_owner = owner;
return new Binding(this, owner);
}
private void Unbind(ICombatAttackOperations expected)
{
if (ReferenceEquals(_owner, expected))
_owner = null;
}
public bool CanStartAttack() => _owner?.CanStartAttack() == true;
public void PrepareAttackRequest() => _owner?.PrepareAttackRequest();
public bool SendAttack(AttackHeight height, float power) =>
@ -75,6 +91,23 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
public bool IsDualWield => _owner?.IsDualWield == true;
public bool PlayerReadyForAttack => _owner?.PlayerReadyForAttack == true;
public bool AutoRepeatAttack => _owner?.AutoRepeatAttack == true;
private sealed class Binding : IDisposable
{
private CombatAttackOperationsSlot? _slot;
private readonly ICombatAttackOperations _expected;
public Binding(
CombatAttackOperationsSlot slot,
ICombatAttackOperations expected)
{
_slot = slot;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _slot, null)?.Unbind(_expected);
}
}
/// <summary>

View file

@ -0,0 +1,749 @@
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
using AcDream.App.Streaming;
using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.Plugins;
using AcDream.Core.Rendering;
using AcDream.Core.Selection;
using AcDream.Core.World;
using Silk.NET.Windowing;
namespace AcDream.App.Composition;
internal sealed record SessionPlayerDependencies(
RuntimeOptions Options,
IWindow Window,
object DatLock,
RuntimeSettingsController Settings,
SettingsDevToolsResult SettingsDevTools,
PhysicsEngine PhysicsEngine,
PhysicsDataCache PhysicsDataCache,
WorldGameState WorldGameState,
WorldEvents WorldEvents,
SelectionState Selection,
ClientObjectTable Objects,
EntityClassificationCache ClassificationCache,
LiveEntityRuntimeSlot RuntimeSlot,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> AnimatedEntities,
RemoteMovementObservationTracker RemoteMovementObservations,
RemotePhysicsUpdater RemotePhysicsUpdater,
RemoteInboundMotionDispatcher RemoteInboundMotion,
RetailInboundEventDispatcher InboundEntityEvents,
DeferredLiveEntityMotionRuntimeBindings MotionBindings,
LocalPlayerIdentityState PlayerIdentity,
LocalPlayerControllerSlot PlayerController,
LocalPlayerPhysicsHostSlot PlayerHost,
LocalPlayerModeState PlayerMode,
ChaseCameraInputState ChaseCameraInput,
LocalPlayerOutboundController PlayerOutbound,
DeferredLocalPlayerTeleportNetworkSink TeleportSink,
LiveWorldOriginState WorldOrigin,
WorldRenderRangeState RenderRange,
LocalPlayerShadowState PlayerShadow,
LocalPlayerSkillState PlayerSkills,
ViewportAspectState ViewportAspect,
PlayerApproachCompletionState PlayerApproachCompletions,
PointerPositionState PointerPosition,
DispatcherMovementInputSource MovementInput,
IInputCaptureSource InputCapture,
AcDream.App.Rendering.Vfx.ParticleVisibilityController ParticleVisibility,
TranslucencyFadeManager TranslucencyFades,
EntityEffectPoseRegistry EffectPoses,
UpdateFrameClock UpdateClock,
MovementTruthDiagnosticController MovementDiagnostics,
CombatAttackOperationsSlot CombatAttackOperations,
CombatState Combat,
CombatFeedbackSlot CombatFeedback,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
Action<string> Log);
internal sealed record SessionPlayerResult(
LandblockStreamer Streamer,
StreamingController Streaming,
StreamingOriginRecenterCoordinator StreamingOriginRecenter,
WorldRevealCoordinator WorldReveal,
DatSpawnClaimHydrationClassifier SpawnClaimHydration,
LiveSessionController LiveSession,
LiveEntityHydrationController Hydration,
LiveEntityNetworkUpdateController NetworkUpdates,
LiveEntityLivenessController Liveness,
LiveEntitySessionController SessionEvents,
GameplayInputFrameController GameplayInput,
StreamingFrameController StreamingFrame,
LocalPlayerAnimationController LocalPlayerAnimation,
LocalPlayerShadowSynchronizer LocalPlayerShadow,
LiveLocalPlayerFrameRuntime LocalPlayerFrameRuntime,
RetailLocalPlayerFrameController LocalPlayerFrame,
LiveSpatialPresentationReconciler LiveSpatialReconciler,
LiveObjectFrameController LiveObjectFrame,
PlayerModeController PlayerMode,
PlayerModeAutoEntry PlayerModeAutoEntry,
LocalPlayerTeleportController LocalTeleport,
SessionPlayerRuntimeBindings RuntimeBindings);
internal interface IGameWindowSessionPlayerPublication
{
void PublishSessionPlayer(SessionPlayerResult result);
}
internal enum SessionPlayerCompositionPoint
{
StreamingRadiiResolved,
StreamerCreated,
StreamerStarted,
StreamingCreated,
RuntimeSettingsBound,
WorldRevealCreated,
LiveSessionCreated,
HydrationCreated,
LiveEntityGraphBound,
CombatOperationsBound,
GameplayInputBound,
UpdateLeavesCreated,
PlayerModeBound,
PortalTransferred,
TeleportBound,
ResultPublished,
}
internal sealed class SessionPlayerCompositionPhase
: ISessionPlayerCompositionPhase<
HostInputCameraResult,
ContentEffectsAudioResult,
WorldRenderResult,
InteractionRetainedUiResult,
LivePresentationResult,
SessionPlayerResult>
{
private readonly SessionPlayerDependencies _dependencies;
private readonly IGameWindowSessionPlayerPublication _publication;
private readonly Action<SessionPlayerCompositionPoint>? _faultInjection;
public SessionPlayerCompositionPhase(
SessionPlayerDependencies dependencies,
IGameWindowSessionPlayerPublication publication,
Action<SessionPlayerCompositionPoint>? faultInjection = null)
{
_dependencies = dependencies
?? throw new ArgumentNullException(nameof(dependencies));
_publication = publication
?? throw new ArgumentNullException(nameof(publication));
_faultInjection = faultInjection;
}
public SessionPlayerResult Compose(
HostInputCameraResult host,
ContentEffectsAudioResult content,
WorldRenderResult world,
InteractionRetainedUiResult interaction,
LivePresentationResult live)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(world);
ArgumentNullException.ThrowIfNull(interaction);
ArgumentNullException.ThrowIfNull(live);
var scope = new CompositionAcquisitionScope();
SessionPlayerRuntimeBindings? bindings = null;
bool bindingsOwnedByScope = false;
try
{
SessionPlayerResult result = ComposeCore(
host,
content,
world,
interaction,
live,
scope,
ref bindings,
ref bindingsOwnedByScope);
scope.Complete();
return result;
}
catch (Exception failure)
{
if (bindings is not null && !bindingsOwnedByScope)
{
scope.Own(
"session/player runtime bindings",
bindings,
static value => value.Dispose());
bindingsOwnedByScope = true;
}
scope.RollbackAndThrow(failure);
throw new System.Diagnostics.UnreachableException();
}
}
private SessionPlayerResult ComposeCore(
HostInputCameraResult host,
ContentEffectsAudioResult content,
WorldRenderResult world,
InteractionRetainedUiResult interaction,
LivePresentationResult live,
CompositionAcquisitionScope scope,
ref SessionPlayerRuntimeBindings? bindings,
ref bool bindingsOwnedByScope)
{
SessionPlayerDependencies d = _dependencies;
WorldRenderFoundation foundation = world.Foundation;
int nearRadius = d.Settings.ResolvedQuality.NearRadius;
int farRadius = d.Settings.ResolvedQuality.FarRadius;
if (d.Options.LegacyStreamRadius is { } legacyRadius)
{
nearRadius = legacyRadius;
farRadius = Math.Max(legacyRadius, farRadius);
}
d.RenderRange.NearRadius = nearRadius;
d.RenderRange.FarRadius = farRadius;
d.Log(
$"streaming: nearRadius={nearRadius} " +
$"(window={2 * nearRadius + 1}x{2 * nearRadius + 1}) " +
$"farRadius={farRadius} " +
$"(window={2 * farRadius + 1}x{2 * farRadius + 1})");
Fault(SessionPlayerCompositionPoint.StreamingRadiiResolved);
var landblockBuildFactory = new LandblockBuildFactory(
content.Dats,
d.DatLock,
world.TerrainBuild.HeightTable,
d.PhysicsDataCache,
d.Options.DumpSceneryZ);
var streamerLease = scope.Acquire(
"landblock streamer",
() => LandblockStreamer.CreateForRequests(
landblockBuildFactory.Build,
(id, landblock) =>
{
if (landblock is null)
return null;
uint x = (id >> 24) & 0xFFu;
uint y = (id >> 16) & 0xFFu;
return AcDream.Core.Terrain.LandblockMesh.Build(
landblock.Heightmap,
x,
y,
world.TerrainBuild.HeightTable,
world.TerrainBuild.Blending,
world.TerrainBuild.SurfaceCache);
}),
static value => value.Dispose());
Fault(SessionPlayerCompositionPoint.StreamerCreated);
streamerLease.Resource.Start();
Fault(SessionPlayerCompositionPoint.StreamerStarted);
var streaming = new StreamingController(
enqueueLoad: (id, kind, generation) => streamerLease.Resource.EnqueueLoad(
new LandblockBuildRequest(
id,
kind,
generation,
new LandblockBuildOrigin(
d.WorldOrigin.CenterX,
d.WorldOrigin.CenterY))),
enqueueUnload: streamerLease.Resource.EnqueueUnload,
drainCompletions: streamerLease.Resource.DrainCompletions,
state: live.WorldState,
nearRadius: nearRadius,
farRadius: farRadius,
presentationPipeline: live.LandblockPipeline,
clearPendingLoads: streamerLease.Resource.ClearPendingLoads);
var streamingOriginRecenter = new StreamingOriginRecenterCoordinator(
streaming,
d.WorldOrigin);
streaming.MaxCompletionsPerFrame =
d.Settings.ResolvedQuality.MaxCompletionsPerFrame;
Fault(SessionPlayerCompositionPoint.StreamingCreated);
bindings = new SessionPlayerRuntimeBindings();
var settingsTargets = new RuntimeSettingsTargets(
new SilkRuntimeDisplayWindowTarget(d.Window),
live.DrawDispatcher,
foundation.TerrainAtlas,
streaming,
d.RenderRange,
interaction.RetainedUi?.Host.Root,
d.Log);
bindings.Adopt(
"runtime settings targets",
d.Settings.BindRuntimeTargetsOwned(settingsTargets));
Fault(SessionPlayerCompositionPoint.RuntimeSettingsBound);
var spawnClaimClassifier = new DatSpawnClaimHydrationClassifier(
content.Dats,
d.DatLock);
var worldReveal = new WorldRevealCoordinator(
streaming.IsRenderNeighborhoodResident,
d.PhysicsEngine.IsSpawnCellReady,
d.PhysicsEngine.IsNeighborhoodTerrainResident,
() => live.DrawDispatcher.CompositeTexturesReady,
(destinationCell, radius) => live.DrawDispatcher.PrepareCompositeTextures(
live.WorldState.Entities,
live.WorldState.FlatViewGeneration,
destinationCell,
radius),
live.DrawDispatcher.InvalidateCompositeWarmupReadiness,
spawnClaimClassifier.IsUnhydratable,
d.Log);
Fault(SessionPlayerCompositionPoint.WorldRevealCreated);
return CompleteSessionPlayer(
host,
content,
world,
interaction,
live,
scope,
streamerLease,
streaming,
streamingOriginRecenter,
worldReveal,
spawnClaimClassifier,
bindings,
ref bindingsOwnedByScope);
}
private SessionPlayerResult CompleteSessionPlayer(
HostInputCameraResult host,
ContentEffectsAudioResult content,
WorldRenderResult world,
InteractionRetainedUiResult interaction,
LivePresentationResult live,
CompositionAcquisitionScope scope,
CompositionAcquisitionScope.CompositionAcquisitionLease<LandblockStreamer>
streamerLease,
StreamingController streaming,
StreamingOriginRecenterCoordinator streamingOriginRecenter,
WorldRevealCoordinator worldReveal,
DatSpawnClaimHydrationClassifier spawnClaimClassifier,
SessionPlayerRuntimeBindings bindings,
ref bool bindingsOwnedByScope)
{
SessionPlayerDependencies d = _dependencies;
var networkUpdateBridge = new DeferredLiveEntityNetworkUpdateSink();
var sealedDungeonCells = new DatSealedDungeonCellClassifier(
content.Dats,
d.DatLock);
var projectionMaterializer = new DatLiveEntityProjectionMaterializer(
d.Options,
content.Dats,
live.LiveEntities,
d.PhysicsDataCache,
content.AnimationLoader,
live.EntitySpawnAdapter,
world.Foundation.TextureCache,
d.ClassificationCache,
d.EffectPoses,
live.EquippedChildren,
d.WorldGameState,
d.WorldEvents,
d.PhysicsEngine.ShadowObjects,
content.CollisionBuilder,
live.ProjectileController,
live.AnimationPresenter,
live.StaticAnimationScheduler,
d.WorldOrigin,
d.UpdateClock);
var originCoordinator = new LiveEntityWorldOriginCoordinator(
d.WorldOrigin,
streaming,
live.WorldState,
worldReveal,
d.PlayerIdentity,
sealedDungeonCells,
d.Log);
var liveSessionLease = scope.Acquire(
"live-session controller",
static () => new LiveSessionController(),
static value => value.Dispose());
LiveSessionController liveSession = liveSessionLease.Resource;
bindings.Adopt(
"retained-UI live session",
interaction.LateBindings.Session.Bind(liveSession));
var localPhysicsTimestamps =
new LiveSessionLocalPhysicsTimestampPublisher(
d.PlayerIdentity,
liveSession);
Fault(SessionPlayerCompositionPoint.LiveSessionCreated);
var teardown = new LiveEntityRuntimeTeardownController(
live.LiveEntities,
live.Presentation,
live.EntityEffects,
live.RemoteTeleport,
live.SelectionInteractions,
d.Selection,
d.AnimatedEntities,
d.RemoteMovementObservations,
d.TranslucencyFades,
live.ProjectionWithdrawal,
live.EquippedChildren,
d.PhysicsEngine.ShadowObjects,
live.Lights,
d.ClassificationCache,
d.PlayerIdentity);
var deletion = new LiveEntityDeletionController(
live.LiveEntities,
d.Objects,
teardown,
d.PlayerIdentity,
d.Options.DumpLiveSpawns ? d.Log : null);
var hydration = new LiveEntityHydrationController(
live.LiveEntities,
d.Objects,
d.DatLock,
projectionMaterializer,
new LiveEntityRelationshipProjection(live.EquippedChildren),
new LiveEntityReadyPublisher(
live.LiveEntities,
live.EntityEffects,
live.Presentation),
originCoordinator,
networkUpdateBridge,
localPhysicsTimestamps,
d.PlayerIdentity,
deletion,
d.Options.DumpLiveSpawns ? d.Log : null);
bindings.Adopt(
"landblock-loaded hydration",
live.LandblockLoaded.Bind(hydration));
Fault(SessionPlayerCompositionPoint.HydrationCreated);
var networkUpdates = new LiveEntityNetworkUpdateController(
live.LiveEntities,
d.Objects,
hydration,
live.EntityEffects,
live.Presentation,
live.Lights,
live.EquippedChildren,
live.ProjectileController,
live.RemoteTeleport,
d.AnimatedEntities,
new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(d.RuntimeSlot),
d.RemoteMovementObservations,
d.RemotePhysicsUpdater,
d.RemoteInboundMotion,
live.MotionRuntime,
d.PhysicsEngine,
content.Dats,
content.AnimationLoader,
interaction.CombatTarget,
d.WorldOrigin,
d.TeleportSink,
d.PlayerController,
d.PlayerOutbound,
d.PlayerHost,
d.PlayerIdentity,
d.UpdateClock,
liveSession,
localPhysicsTimestamps.Publish,
d.MovementDiagnostics);
var liveness = new LiveEntityLivenessController(
live.LiveEntities,
d.PlayerIdentity,
deletion);
var sessionEvents = new LiveEntitySessionController(
d.InboundEntityEvents,
hydration,
networkUpdates,
d.TeleportSink,
live.EntityEffects);
bindings.Adopt(
"live parent acceptance",
live.ParentAcceptance.BindOwned(
hydration.TryAcceptParentForProjection));
bindings.Adopt(
"same-generation network updates",
networkUpdateBridge.BindOwned(networkUpdates));
bindings.BindEntityReady(
live.EquippedChildren,
candidate => _ = hydration.OnEntityReady(candidate));
bindings.BindAppearanceApplied(
hydration,
guid =>
{
if (guid == d.PlayerIdentity.ServerGuid)
live.PaperdollPresenter?.MarkDirty();
});
Fault(SessionPlayerCompositionPoint.LiveEntityGraphBound);
d.WorldOrigin.SetPlaceholder(
world.TerrainBuild.InitialCenterX,
world.TerrainBuild.InitialCenterY);
bindings.Adopt(
"combat attack operations",
d.CombatAttackOperations.BindOwned(
new LiveCombatAttackOperations(
d.Combat,
new CombatAttackTargetSource(
d.Selection,
live.LiveEntities,
d.Objects,
d.PlayerIdentity),
d.Settings,
d.PlayerController,
d.PlayerOutbound,
liveSession,
liveSession,
d.CombatFeedback)));
Fault(SessionPlayerCompositionPoint.CombatOperationsBound);
MouseLookController? mouseLook =
host.MouseSource is not null && host.MouseLookCursor is not null
? new MouseLookController(
host.MouseSource,
d.PointerPosition,
d.PlayerMode,
d.PlayerController,
host.CameraController,
d.ChaseCameraInput,
d.MovementInput,
d.PlayerOutbound,
liveSession,
host.MouseLookCursor,
new EnvironmentInputMonotonicClock())
: null;
var gameplayInput = new GameplayInputFrameController(
host.InputDispatcher,
d.MovementInput,
mouseLook,
new CombatAttackInputFrameAdapter(interaction.CombatAttack));
if (host.CameraPointerInput is { } cameraPointer)
{
bindings.Adopt(
"camera pointer gameplay frame",
cameraPointer.BindGameplayFrameOwned(gameplayInput));
}
Fault(SessionPlayerCompositionPoint.GameplayInputBound);
var streamingFrame = new StreamingFrameController(
d.Options.LiveMode,
d.PlayerMode,
d.PlayerController,
liveSession,
d.WorldOrigin,
networkUpdates,
new FlyCameraStreamingObserverSource(host.CameraController),
new PhysicsStreamingDungeonCellSource(d.PhysicsEngine),
streamingOriginRecenter,
streaming,
new LiveProjectionRescueRebucketter(
live.WorldState,
live.LiveEntities));
var liveEffectFrame = new LiveEffectFrameController(
d.TranslucencyFades,
content.AnimationHookFrames,
live.EntityEffects,
content.ParticleSink,
live.Lights,
d.ParticleVisibility,
content.ParticleSystem,
content.ScriptRunner,
d.UpdateClock,
new SettingsParticleRangeSource(d.Settings));
var liveSpatialReconciler = new LiveSpatialPresentationReconciler(
live.EntityEffects,
live.EquippedChildren,
content.ParticleSink,
live.Lights);
var localPlayerAnimation = new LocalPlayerAnimationController(
live.LiveEntities,
d.PlayerIdentity,
d.AnimatedEntities,
live.AnimationPresenter,
content.AnimationHookFrames);
var localPlayerShadow = new LocalPlayerShadowSynchronizer(
d.PhysicsEngine,
live.LiveEntities,
d.PlayerIdentity,
d.WorldOrigin,
d.PlayerShadow);
var localPlayerProjection = new LocalPlayerProjectionController(
new LiveLocalPlayerProjectionRuntime(
live.LiveEntities,
d.PlayerIdentity,
d.WorldOrigin,
localPlayerShadow));
var localPlayerFrameRuntime = new LiveLocalPlayerFrameRuntime(
host.CameraController,
d.PlayerMode,
d.PlayerController,
d.ChaseCameraInput,
d.MovementInput,
d.InputCapture,
live.LiveEntities,
d.PlayerIdentity,
d.PlayerHost,
localPlayerProjection,
d.PlayerOutbound,
liveSession);
var localPlayerFrame = new RetailLocalPlayerFrameController(
localPlayerFrameRuntime,
d.MovementInput);
var liveObjectFrame = new LiveObjectFrameController(
d.InboundEntityEvents,
localPlayerFrame,
live.SelectionInteractions,
live.LiveEntities,
d.PlayerIdentity,
d.WorldOrigin,
live.AnimationScheduler,
live.StaticAnimationScheduler,
live.AnimationPresenter,
d.AnimatedEntities,
live.EquippedChildren,
liveEffectFrame);
Fault(SessionPlayerCompositionPoint.UpdateLeavesCreated);
var playerMode = new PlayerModeController(
d.PlayerMode,
d.PlayerController,
d.PlayerHost,
d.ChaseCameraInput,
host.CameraController,
d.PhysicsEngine,
live.LiveEntities,
d.PlayerIdentity,
d.WorldOrigin,
d.MotionBindings,
content.Dats,
d.DatLock,
d.PhysicsDataCache,
d.AnimatedEntities,
localPlayerAnimation,
localPlayerShadow,
d.PlayerApproachCompletions,
gameplayInput,
liveSession,
d.MovementDiagnostics,
d.PlayerSkills,
d.ViewportAspect);
if (d.SettingsDevTools.DevTools is { } devTools)
{
bindings.Adopt(
"developer player mode",
devTools.LateBindings.PlayerModeCommands.BindOwned(playerMode));
bindings.Adopt(
"developer command bus",
devTools.CommandBus.BindOwned(liveSession));
}
var playerModeAutoEntry = new PlayerModeAutoEntry(
new LivePlayerModeAutoEntryContext(
liveSession,
live.LiveEntities,
d.PlayerIdentity,
worldReveal,
d.PlayerMode,
playerMode));
playerMode.BindAutoEntry(playerModeAutoEntry);
Fault(SessionPlayerCompositionPoint.PlayerModeBound);
LocalPlayerTeleportController localTeleport =
d.PortalTunnelFallback.Transfer(
portalTunnel => new LocalPlayerTeleportController(
new LiveLocalPlayerTeleportAuthority(
live.LiveEntities,
d.PlayerIdentity),
gameplayInput,
playerMode,
new LocalPlayerTeleportStreamingOperations(
d.WorldOrigin,
streamingOriginRecenter,
streaming,
sealedDungeonCells),
worldReveal,
new LocalPlayerTeleportPlacement(
d.PhysicsEngine,
live.LiveEntities,
d.PlayerIdentity,
d.PlayerController,
d.PlayerHost,
d.ChaseCameraInput,
d.WorldOrigin,
liveSpatialReconciler),
new LocalPlayerTeleportSession(liveSession),
new LocalPlayerTeleportPresentation(portalTunnel)));
var teleportLease = scope.Own(
"local-player teleport",
localTeleport,
static value => value.Dispose());
Fault(SessionPlayerCompositionPoint.PortalTransferred);
bindings.Adopt(
"local teleport network sink",
d.TeleportSink.BindOwned(localTeleport));
bindings.Adopt(
"selection view plane",
interaction.LateBindings.SelectionViewPlane.Bind(localTeleport));
Fault(SessionPlayerCompositionPoint.TeleportBound);
IDisposable componentLifecycleBinding =
live.ComponentLifecycle.BindOwned(teardown);
try
{
live.RuntimeBindings.Adopt(
"live runtime component teardown",
componentLifecycleBinding);
}
catch
{
componentLifecycleBinding.Dispose();
throw;
}
var bindingsLease = scope.Own(
"session/player runtime bindings",
bindings,
static value => value.Dispose());
bindingsOwnedByScope = true;
var result = new SessionPlayerResult(
streamerLease.Resource,
streaming,
streamingOriginRecenter,
worldReveal,
spawnClaimClassifier,
liveSession,
hydration,
networkUpdates,
liveness,
sessionEvents,
gameplayInput,
streamingFrame,
localPlayerAnimation,
localPlayerShadow,
localPlayerFrameRuntime,
localPlayerFrame,
liveSpatialReconciler,
liveObjectFrame,
playerMode,
playerModeAutoEntry,
teleportLease.Resource,
bindings);
_publication.PublishSessionPlayer(result);
streamerLease.Transfer();
liveSessionLease.Transfer();
teleportLease.Transfer();
bindingsLease.Transfer();
Fault(SessionPlayerCompositionPoint.ResultPublished);
return result;
}
private void Fault(SessionPlayerCompositionPoint point) =>
_faultInjection?.Invoke(point);
}

View file

@ -0,0 +1,95 @@
using AcDream.App.Rendering;
using AcDream.App.World;
namespace AcDream.App.Composition;
/// <summary>
/// Named Phase-7 external edges. Releases run in reverse order; completed
/// edges are removed immediately so an incomplete cleanup retries only the
/// remaining work.
/// </summary>
internal sealed class SessionPlayerRuntimeBindings : IDisposable
{
private readonly List<(string Name, IDisposable Binding)> _bindings = [];
private bool _deactivationStarted;
public void Adopt(string name, IDisposable binding)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(binding);
ObjectDisposedException.ThrowIf(_deactivationStarted, this);
_bindings.Add((name, binding));
}
public void BindEntityReady(
EquippedChildRenderController source,
Action<LiveEntityReadyCandidate> handler)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(handler);
source.EntityReady += handler;
Adopt(
"equipped-child entity ready",
new DelegateBinding(() => source.EntityReady -= handler));
}
public void BindAppearanceApplied(
LiveEntityHydrationController source,
Action<uint> handler)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(handler);
source.AppearanceApplied += handler;
Adopt(
"live appearance applied",
new DelegateBinding(() => source.AppearanceApplied -= handler));
}
public void Dispose()
{
if (_deactivationStarted && _bindings.Count == 0)
return;
_deactivationStarted = true;
List<Exception>? failures = null;
for (int i = _bindings.Count - 1; i >= 0; i--)
{
(string name, IDisposable binding) = _bindings[i];
try
{
binding.Dispose();
_bindings.RemoveAt(i);
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"Session/player binding '{name}' did not detach.",
failure));
}
}
if (failures is not null)
{
throw new AggregateException(
"Session/player binding cleanup remains incomplete.",
failures);
}
}
private sealed class DelegateBinding : IDisposable
{
private Action? _release;
public DelegateBinding(Action release) =>
_release = release ?? throw new ArgumentNullException(nameof(release));
public void Dispose()
{
Action? release = _release;
if (release is null)
return;
release();
_release = null;
}
}
}

View file

@ -245,6 +245,20 @@ internal sealed class CameraPointerInputController
_gameplayFrame = gameplayFrame;
}
public IDisposable BindGameplayFrameOwned(
GameplayInputFrameController gameplayFrame)
{
ArgumentNullException.ThrowIfNull(gameplayFrame);
if (_gameplayFrame is not null)
{
throw new InvalidOperationException(
"The raw pointer owner already has a gameplay frame.");
}
_gameplayFrame = gameplayFrame;
return new GameplayFrameBinding(this, gameplayFrame);
}
public void UnbindGameplayFrame(GameplayInputFrameController gameplayFrame)
{
ArgumentNullException.ThrowIfNull(gameplayFrame);
@ -252,6 +266,24 @@ internal sealed class CameraPointerInputController
_gameplayFrame = null;
}
private sealed class GameplayFrameBinding : IDisposable
{
private CameraPointerInputController? _owner;
private readonly GameplayInputFrameController _expected;
public GameplayFrameBinding(
CameraPointerInputController owner,
GameplayInputFrameController expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?
.UnbindGameplayFrame(_expected);
}
public void HandleFocusChanged(bool focused)
{
if (!focused)

View file

@ -199,6 +199,20 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
_session = session;
}
public IDisposable BindOwned(LiveSessionController session)
{
ArgumentNullException.ThrowIfNull(session);
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_session is not null)
{
throw new InvalidOperationException(
"Developer command-bus authority is already bound.");
}
_session = session;
return new Binding(this, session);
}
public void Unbind(LiveSessionController session)
{
ArgumentNullException.ThrowIfNull(session);
@ -211,6 +225,23 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
_deactivated = true;
_session = null;
}
private sealed class Binding : IDisposable
{
private DevToolsCommandBusSource? _owner;
private readonly LiveSessionController _expected;
public Binding(
DevToolsCommandBusSource owner,
LiveSessionController expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}
/// <summary>Typed panel operations used by menu and input presentation.</summary>

View file

@ -167,6 +167,20 @@ internal sealed class DeferredDevToolsPlayerModeCommands
_target = target;
}
public IDisposable BindOwned(IDevToolsPlayerModeTarget target)
{
ArgumentNullException.ThrowIfNull(target);
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null)
{
throw new InvalidOperationException(
"Developer player-mode commands are already bound.");
}
_target = target;
return new Binding(this, target);
}
public void Unbind(IDevToolsPlayerModeTarget target)
{
ArgumentNullException.ThrowIfNull(target);
@ -185,6 +199,23 @@ internal sealed class DeferredDevToolsPlayerModeCommands
if (!_deactivated)
_target?.ToggleFlyOrChase();
}
private sealed class Binding : IDisposable
{
private DeferredDevToolsPlayerModeCommands? _owner;
private readonly IDevToolsPlayerModeTarget _expected;
public Binding(
DeferredDevToolsPlayerModeCommands owner,
IDevToolsPlayerModeTarget expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}
internal interface IDevToolsRuntimeFacts

View file

@ -21,7 +21,8 @@ public sealed class GameWindow :
IGameWindowSettingsDevToolsPublication,
IGameWindowWorldRenderPublication,
IGameWindowInteractionRetainedUiPublication,
IGameWindowLivePresentationPublication
IGameWindowLivePresentationPublication,
IGameWindowSessionPlayerPublication
{
private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp()
@ -99,6 +100,7 @@ public sealed class GameWindow :
private AcDream.App.Rendering.RenderFrameDiagnosticsController?
_renderFrameDiagnostics;
private LivePresentationRuntimeBindings? _livePresentationBindings;
private SessionPlayerRuntimeBindings? _sessionPlayerBindings;
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
@ -123,22 +125,14 @@ public sealed class GameWindow :
private AcDream.App.Streaming.StreamingOriginRecenterCoordinator?
_streamingOriginRecenter;
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
private AcDream.App.Streaming.DatSpawnClaimHydrationClassifier?
_spawnClaimHydration;
private readonly AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink
_localPlayerTeleportSink = new();
private AcDream.App.Streaming.LocalPlayerTeleportController?
_localPlayerTeleport;
private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange =
new(nearRadius: 4, farRadius: 12);
private int _nearRadius
{
get => _renderRange.NearRadius;
set => _renderRange.NearRadius = value;
}
private int _farRadius
{
get => _renderRange.FarRadius;
set => _renderRange.FarRadius = value;
}
// Phase B.3: physics engine — populated from the streaming pipeline.
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
@ -170,7 +164,7 @@ public sealed class GameWindow :
_liveEntityProjectionWithdrawal;
private AcDream.App.World.LiveEntityHydrationController? _liveEntityHydration;
private AcDream.App.Physics.LiveEntityNetworkUpdateController? _liveEntityNetworkUpdates;
private AcDream.App.Net.LiveEntitySessionController _liveEntitySessionEvents = null!;
private AcDream.App.Net.LiveEntitySessionController? _liveEntitySessionEvents;
private readonly AcDream.App.Physics.DeferredLiveEntityMotionRuntimeBindings
_liveEntityMotionBindings = new();
@ -996,6 +990,51 @@ public sealed class GameWindow :
_livePresentationBindings = result.RuntimeBindings;
}
void IGameWindowSessionPlayerPublication.PublishSessionPlayer(
SessionPlayerResult result)
{
ArgumentNullException.ThrowIfNull(result);
if (_streamer is not null
|| _streamingController is not null
|| _streamingOriginRecenter is not null
|| _worldReveal is not null
|| _spawnClaimHydration is not null
|| _liveSessionController is not null
|| _liveEntityHydration is not null
|| _liveEntityNetworkUpdates is not null
|| _liveEntityLiveness is not null
|| _liveEntitySessionEvents is not null
|| _gameplayInputFrame is not null
|| _localPlayerAnimation is not null
|| _localPlayerShadowSynchronizer is not null
|| _playerModeController is not null
|| _playerModeAutoEntry is not null
|| _localPlayerTeleport is not null
|| _sessionPlayerBindings is not null)
{
throw new InvalidOperationException(
"The GameWindow composition shell already owns session/player state.");
}
_streamer = result.Streamer;
_streamingController = result.Streaming;
_streamingOriginRecenter = result.StreamingOriginRecenter;
_worldReveal = result.WorldReveal;
_spawnClaimHydration = result.SpawnClaimHydration;
_liveSessionController = result.LiveSession;
_liveEntityHydration = result.Hydration;
_liveEntityNetworkUpdates = result.NetworkUpdates;
_liveEntityLiveness = result.Liveness;
_liveEntitySessionEvents = result.SessionEvents;
_gameplayInputFrame = result.GameplayInput;
_localPlayerAnimation = result.LocalPlayerAnimation;
_localPlayerShadowSynchronizer = result.LocalPlayerShadow;
_playerModeController = result.PlayerMode;
_playerModeAutoEntry = result.PlayerModeAutoEntry;
_localPlayerTeleport = result.LocalTeleport;
_sessionPlayerBindings = result.RuntimeBindings;
}
private static void PublishCompositionOwner<T>(
ref T? destination,
T value,
@ -1139,9 +1178,6 @@ public sealed class GameWindow :
Console.WriteLine),
this).Compose(platform, contentEffectsAudio, settingsDevTools);
string shadersDir = worldRender.Foundation.ShadersDirectory;
TerrainAtlas terrainAtlas = worldRender.Foundation.TerrainAtlas;
int centerX = worldRender.TerrainBuild.InitialCenterX;
int centerY = worldRender.TerrainBuild.InitialCenterY;
Console.WriteLine(
$"loading world view centered on " +
$"0x{worldRender.TerrainBuild.InitialCenterLandblockId:X8}");
@ -1243,441 +1279,64 @@ public sealed class GameWindow :
worldRender,
interactionUi);
// Apply radii from the same immutable quality snapshot used for the
// window's MSAA, terrain anisotropy, and dispatcher A2C setup.
// Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat.
_nearRadius = _runtimeSettings.ResolvedQuality.NearRadius;
_farRadius = _runtimeSettings.ResolvedQuality.FarRadius;
// Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and
// ensures farRadius >= streamRadius. Parsed once into
// RuntimeOptions.LegacyStreamRadius (null when unset or invalid).
if (_options.LegacyStreamRadius is { } sr)
{
_nearRadius = sr;
_farRadius = System.Math.Max(sr, _farRadius);
}
Console.WriteLine(
$"streaming: nearRadius={_nearRadius} (window={2 * _nearRadius + 1}x{2 * _nearRadius + 1})" +
$" farRadius={_farRadius} (window={2 * _farRadius + 1}x{2 * _farRadius + 1})");
// Phase A.5 T11+: the streamer now runs on a dedicated worker thread.
// loadLandblock acquires _datLock (T10) before touching DatCollection.
// buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so
// it can call LandblockMesh.Build without a dat read — _heightTable and
// _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9).
var landblockBuildFactory = new AcDream.App.Streaming.LandblockBuildFactory(
_dats!,
_datLock,
_heightTable!,
_physicsDataCache,
_options.DumpSceneryZ);
_streamer = AcDream.App.Streaming.LandblockStreamer.CreateForRequests(
loadLandblock: landblockBuildFactory.Build,
buildMeshOrNull: (id, lb) =>
{
if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null)
return null;
uint lbX = (id >> 24) & 0xFFu;
uint lbY = (id >> 16) & 0xFFu;
// _surfaceCache is ConcurrentDictionary (T9) — safe from worker thread.
// _heightTable and _blendCtx are read-only after initialization.
// lb.Heightmap is the pre-loaded LandBlock; no dat read needed here.
return AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache);
});
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(
new AcDream.App.Streaming.LandblockBuildRequest(
id,
kind,
generation,
new AcDream.App.Streaming.LandblockBuildOrigin(
_liveCenterX,
_liveCenterY))),
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
drainCompletions: _streamer.DrainCompletions,
state: _worldState,
nearRadius: _nearRadius,
farRadius: _farRadius,
clearPendingLoads: _streamer.ClearPendingLoads,
// Retained-object recovery runs after each
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
// objects it still considers known.
presentationPipeline: _landblockPresentationPipeline!);
_streamingOriginRecenter =
new AcDream.App.Streaming.StreamingOriginRecenterCoordinator(
_streamingController,
_liveWorldOrigin);
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame =
_runtimeSettings.ResolvedQuality.MaxCompletionsPerFrame;
_runtimeSettings.BindRuntimeTargets(
new RuntimeSettingsTargets(
new SilkRuntimeDisplayWindowTarget(_window!),
_wbDrawDispatcher!,
terrainAtlas,
_streamingController,
_renderRange,
_uiHost?.Root,
Console.WriteLine));
_worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident,
isSpawnCellReady: _physicsEngine.IsSpawnCellReady,
isTerrainNeighborhoodReady: _physicsEngine.IsNeighborhoodTerrainResident,
areCompositeTexturesReady: () => _wbDrawDispatcher?.CompositeTexturesReady == true,
prepareCompositeTextures: (destinationCell, radius) =>
_wbDrawDispatcher?.PrepareCompositeTextures(
_worldState.Entities,
_worldState.FlatViewGeneration,
destinationCell,
radius),
invalidateCompositeTextures: () =>
_wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(),
isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable,
log: message => Console.WriteLine(message));
var networkUpdateBridge =
new AcDream.App.World.DeferredLiveEntityNetworkUpdateSink();
var sealedDungeonCells =
new AcDream.App.Streaming.DatSealedDungeonCellClassifier(
_dats!,
_datLock);
var projectionMaterializer = new DatLiveEntityProjectionMaterializer(
_options,
_dats!,
_liveEntities!,
_physicsDataCache,
_animLoader!,
_wbEntitySpawnAdapter!,
_textureCache!,
_classificationCache,
_effectPoses,
_equippedChildRenderer!,
_worldGameState,
_worldEvents,
_physicsEngine.ShadowObjects,
_liveEntityCollisionBuilder!,
_projectileController!,
_animationPresenter,
_staticAnimationScheduler!,
_liveWorldOrigin,
_updateFrameClock);
var originCoordinator =
new AcDream.App.World.LiveEntityWorldOriginCoordinator(
_liveWorldOrigin,
_streamingController,
_worldState,
_worldReveal,
_localPlayerIdentity,
sealedDungeonCells,
Console.WriteLine);
_liveSessionController = new AcDream.App.Net.LiveSessionController();
interactionUi.LateBindings.AdoptLateOwnerBinding(
"live session",
interactionUi.LateBindings.Session.Bind(_liveSessionController));
var localPhysicsTimestamps =
new AcDream.App.World.LiveSessionLocalPhysicsTimestampPublisher(
_localPlayerIdentity,
_liveSessionController);
var liveEntityTeardown =
new AcDream.App.World.LiveEntityRuntimeTeardownController(
livePresentation.LiveEntities,
_liveEntityPresentation!,
_entityEffects!,
_remoteTeleportController!,
_selectionInteractions,
_selection,
_animatedEntities,
_remoteMovementObservations,
_translucencyFades,
_liveEntityProjectionWithdrawal!,
_equippedChildRenderer!,
_physicsEngine.ShadowObjects,
_liveEntityLights!,
_classificationCache,
_localPlayerIdentity);
livePresentation.ComponentLifecycle.Bind(liveEntityTeardown);
var liveEntityDeletion =
new AcDream.App.World.LiveEntityDeletionController(
livePresentation.LiveEntities,
Objects,
liveEntityTeardown,
_localPlayerIdentity,
_options.DumpLiveSpawns ? Console.WriteLine : null);
_liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController(
livePresentation.LiveEntities,
Objects,
_datLock,
projectionMaterializer,
new AcDream.App.World.LiveEntityRelationshipProjection(
livePresentation.EquippedChildren),
new AcDream.App.World.LiveEntityReadyPublisher(
livePresentation.LiveEntities,
_entityEffects!,
_liveEntityPresentation!),
originCoordinator,
networkUpdateBridge,
localPhysicsTimestamps,
_localPlayerIdentity,
liveEntityDeletion,
_options.DumpLiveSpawns ? Console.WriteLine : null);
livePresentation.RuntimeBindings.Adopt(
"landblock-loaded hydration",
livePresentation.LandblockLoaded.Bind(_liveEntityHydration));
_liveEntityNetworkUpdates =
new AcDream.App.Physics.LiveEntityNetworkUpdateController(
livePresentation.LiveEntities,
Objects,
_liveEntityHydration,
_entityEffects!,
_liveEntityPresentation!,
_liveEntityLights!,
_equippedChildRenderer!,
_projectileController!,
_remoteTeleportController!,
_animatedEntities,
new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(
_liveEntityRuntimeSlot),
_remoteMovementObservations,
_remotePhysicsUpdater,
_remoteInboundMotion,
livePresentation.MotionRuntime,
_physicsEngine,
_dats!,
_animLoader!,
_combatTargetController,
_liveWorldOrigin,
_localPlayerTeleportSink,
_playerControllerSlot,
_localPlayerOutbound,
_playerHostSlot,
_localPlayerIdentity,
_updateFrameClock,
_liveSessionController,
localPhysicsTimestamps.Publish,
_movementTruthDiagnostics);
_liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
livePresentation.LiveEntities,
_localPlayerIdentity,
liveEntityDeletion);
_liveEntitySessionEvents = new AcDream.App.Net.LiveEntitySessionController(
_inboundEntityEvents,
_liveEntityHydration,
_liveEntityNetworkUpdates,
_localPlayerTeleportSink,
_entityEffects!);
livePresentation.ParentAcceptance.Bind(
_liveEntityHydration.TryAcceptParentForProjection);
networkUpdateBridge.Bind(_liveEntityNetworkUpdates);
livePresentation.EquippedChildren.EntityReady += candidate =>
_liveEntityHydration.OnEntityReady(candidate);
_liveEntityHydration.AppearanceApplied += guid =>
{
if (guid == _playerServerGuid)
_paperdollFramePresenter?.MarkDirty();
};
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
// enter the world as the first character on the account, and stream
// CreateObject messages into _worldGameState as they arrive. Entirely
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
_liveWorldOrigin.SetPlaceholder(centerX, centerY);
_combatAttackOperations.Bind(
new AcDream.App.Combat.LiveCombatAttackOperations(
Combat,
new AcDream.App.Combat.CombatAttackTargetSource(
_selection,
_liveEntities!,
Objects,
_localPlayerIdentity),
_runtimeSettings,
_playerControllerSlot,
_localPlayerOutbound,
_liveSessionController,
_liveSessionController,
_combatFeedback));
AcDream.App.Input.MouseLookController? mouseLookController =
_mouseSource is not null && _mouseLookCursor is not null
? new AcDream.App.Input.MouseLookController(
_mouseSource,
_pointerPosition,
_localPlayerMode,
_playerControllerSlot,
_cameraController!,
_chaseCameraInput,
_movementInput,
_localPlayerOutbound,
_liveSessionController,
_mouseLookCursor,
new AcDream.App.Input.EnvironmentInputMonotonicClock())
: null;
_gameplayInputFrame = new AcDream.App.Input.GameplayInputFrameController(
_inputDispatcher,
_movementInput,
mouseLookController,
new AcDream.App.Input.CombatAttackInputFrameAdapter(
_combatAttackController!));
_cameraPointerInput?.BindGameplayFrame(_gameplayInputFrame);
var streamingFrame = new AcDream.App.Streaming.StreamingFrameController(
_options.LiveMode,
_localPlayerMode,
_playerControllerSlot,
_liveSessionController,
_liveWorldOrigin,
_liveEntityNetworkUpdates!,
new AcDream.App.Streaming.FlyCameraStreamingObserverSource(
_cameraController!),
new AcDream.App.Streaming.PhysicsStreamingDungeonCellSource(
_physicsEngine),
_streamingOriginRecenter!,
_streamingController!,
new AcDream.App.Streaming.LiveProjectionRescueRebucketter(
_worldState,
livePresentation.LiveEntities));
var liveEffectFrame = new AcDream.App.Update.LiveEffectFrameController(
_translucencyFades,
_animationHookFrames!,
_entityEffects!,
_particleSink!,
_liveEntityLights!,
_particleVisibility,
_particleSystem!,
_scriptRunner!,
_updateFrameClock,
new AcDream.App.Update.SettingsParticleRangeSource(
_runtimeSettings));
var liveSpatialReconciler =
new AcDream.App.Update.LiveSpatialPresentationReconciler(
_entityEffects!,
_equippedChildRenderer!,
_particleSink!,
_liveEntityLights!);
_localPlayerAnimation =
new AcDream.App.Input.LocalPlayerAnimationController(
livePresentation.LiveEntities,
_localPlayerIdentity,
_animatedEntities,
_animationPresenter,
_animationHookFrames!);
_localPlayerShadowSynchronizer =
new AcDream.App.Physics.LocalPlayerShadowSynchronizer(
_physicsEngine,
livePresentation.LiveEntities,
_localPlayerIdentity,
_liveWorldOrigin,
_localPlayerShadow);
var localPlayerProjection =
new AcDream.App.Input.LocalPlayerProjectionController(
new AcDream.App.Input.LiveLocalPlayerProjectionRuntime(
livePresentation.LiveEntities,
_localPlayerIdentity,
_liveWorldOrigin,
_localPlayerShadowSynchronizer));
var localPlayerFrameRuntime =
new AcDream.App.Input.LiveLocalPlayerFrameRuntime(
_cameraController!,
_localPlayerMode,
_playerControllerSlot,
_chaseCameraInput,
_movementInput,
_inputCapture,
livePresentation.LiveEntities,
_localPlayerIdentity,
_playerHostSlot,
localPlayerProjection,
_localPlayerOutbound,
_liveSessionController);
var localPlayerFrame =
new AcDream.App.Input.RetailLocalPlayerFrameController(
localPlayerFrameRuntime,
_movementInput);
var liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController(
_inboundEntityEvents,
localPlayerFrame,
_selectionInteractions,
livePresentation.LiveEntities,
_localPlayerIdentity,
_liveWorldOrigin,
_liveAnimationScheduler,
livePresentation.StaticAnimationScheduler,
_animationPresenter,
_animatedEntities,
_equippedChildRenderer!,
liveEffectFrame);
_playerModeController = new AcDream.App.Input.PlayerModeController(
_localPlayerMode,
_playerControllerSlot,
_playerHostSlot,
_chaseCameraInput,
_cameraController!,
_physicsEngine,
livePresentation.LiveEntities,
_localPlayerIdentity,
_liveWorldOrigin,
_liveEntityMotionBindings,
_dats!,
_datLock,
_physicsDataCache,
_animatedEntities,
_localPlayerAnimation,
_localPlayerShadowSynchronizer,
_playerApproachCompletions,
_gameplayInputFrame,
_liveSessionController,
_movementTruthDiagnostics,
_localPlayerSkills,
_viewportAspect);
_devToolsComposition?.LateBindings.PlayerModeCommands.Bind(
_playerModeController);
_devToolsCommandBus?.Bind(_liveSessionController);
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
new AcDream.App.Input.LivePlayerModeAutoEntryContext(
_liveSessionController,
livePresentation.LiveEntities,
_localPlayerIdentity,
_worldReveal
?? throw new InvalidOperationException(
"World reveal was not composed before player auto-entry."),
_localPlayerMode,
_playerModeController));
_playerModeController.BindAutoEntry(_playerModeAutoEntry);
_localPlayerTeleport = _portalTunnelFallback.Transfer(
portalTunnel => new AcDream.App.Streaming.LocalPlayerTeleportController(
new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority(
livePresentation.LiveEntities,
_localPlayerIdentity),
_gameplayInputFrame,
_playerModeController,
new AcDream.App.Streaming.LocalPlayerTeleportStreamingOperations(
_liveWorldOrigin,
_streamingOriginRecenter!,
_streamingController!,
sealedDungeonCells),
_worldReveal,
new AcDream.App.Streaming.LocalPlayerTeleportPlacement(
SessionPlayerResult sessionPlayer =
new SessionPlayerCompositionPhase(
new SessionPlayerDependencies(
_options,
_window!,
_datLock,
_runtimeSettings,
settingsDevTools,
_physicsEngine,
livePresentation.LiveEntities,
_physicsDataCache,
_worldGameState,
_worldEvents,
_selection,
Objects,
_classificationCache,
_liveEntityRuntimeSlot,
_animatedEntities,
_remoteMovementObservations,
_remotePhysicsUpdater,
_remoteInboundMotion,
_inboundEntityEvents,
_liveEntityMotionBindings,
_localPlayerIdentity,
_playerControllerSlot,
_playerHostSlot,
_localPlayerMode,
_chaseCameraInput,
_localPlayerOutbound,
_localPlayerTeleportSink,
_liveWorldOrigin,
liveSpatialReconciler),
new AcDream.App.Streaming.LocalPlayerTeleportSession(
_liveSessionController),
new AcDream.App.Streaming.LocalPlayerTeleportPresentation(
portalTunnel)));
_localPlayerTeleportSink.Bind(_localPlayerTeleport);
interactionUi.LateBindings.AdoptLateOwnerBinding(
"selection view plane",
interactionUi.LateBindings.SelectionViewPlane.Bind(
_localPlayerTeleport));
_renderRange,
_localPlayerShadow,
_localPlayerSkills,
_viewportAspect,
_playerApproachCompletions,
_pointerPosition,
_movementInput,
_inputCapture,
_particleVisibility,
_translucencyFades,
_effectPoses,
_updateFrameClock,
_movementTruthDiagnostics,
_combatAttackOperations,
Combat,
_combatFeedback,
_portalTunnelFallback,
Console.WriteLine),
this).Compose(
hostInputCamera,
contentEffectsAudio,
worldRender,
interactionUi,
livePresentation);
var teleportRenderState =
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
_localPlayerTeleport);
sessionPlayer.LocalTeleport);
var renderLoginState = new AcDream.App.Rendering.RenderLoginStateSource(
_options.LiveMode,
_localPlayerMode);
@ -1743,7 +1402,7 @@ public sealed class GameWindow :
new AcDream.App.Rendering.WorldRenderFrameBuilder(
new AcDream.App.Rendering.RuntimeWorldFrameCameraSource(
_cameraController!,
_localPlayerTeleport),
sessionPlayer.LocalTeleport),
new AcDream.App.Rendering.RuntimeWorldFrameVisibilityPreparation(
_retailSelectionScene,
_particleVisibility,
@ -1876,7 +1535,7 @@ public sealed class GameWindow :
var privatePresentation =
new AcDream.App.Rendering.PrivatePresentationRenderer(
new AcDream.App.Rendering.LocalPlayerPortalViewport(
_localPlayerTeleport!,
sessionPlayer.LocalTeleport,
_cameraController!),
renderFrameResources,
_paperdollFramePresenter,
@ -1899,19 +1558,19 @@ public sealed class GameWindow :
_devToolsFramePresenter
?? AcDream.App.Rendering.NullRenderFrameFailureRecovery.Instance);
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame,
sessionPlayer.LiveObjectFrame,
_worldState,
_liveSessionController,
localPlayerFrame,
liveSpatialReconciler);
sessionPlayer.LiveSession,
sessionPlayer.LocalPlayerFrame,
sessionPlayer.LiveSpatialReconciler);
var cameraFrame = new AcDream.App.Rendering.CameraFrameController(
_cameraController!,
_inputCapture,
_cameraInput,
localPlayerFrameRuntime,
sessionPlayer.LocalPlayerFrameRuntime,
_chaseCameraInput,
localPlayerFrame,
liveSpatialReconciler,
sessionPlayer.LocalPlayerFrame,
sessionPlayer.LiveSpatialReconciler,
new AcDream.App.Combat.CombatCameraTargetSource(
_runtimeSettings,
Combat,
@ -1923,15 +1582,15 @@ public sealed class GameWindow :
new AcDream.App.Update.ConsoleUpdateFrameFailureSink(),
_updateFrameClock,
new AcDream.App.Update.PhysicsScriptClockPublisher(_scriptRunner!),
streamingFrame,
_gameplayInputFrame,
sessionPlayer.StreamingFrame,
sessionPlayer.GameplayInput,
liveFrameCoordinator,
new AcDream.App.Update.LiveEntityLivenessFramePhase(
_liveEntityLiveness,
sessionPlayer.Liveness,
new AcDream.App.Update.StopwatchClientMonotonicTimeSource()),
_localPlayerTeleport,
sessionPlayer.LocalTeleport,
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
_playerModeAutoEntry),
sessionPlayer.PlayerModeAutoEntry),
cameraFrame);
_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);
_liveSessionHost = CreateLiveSessionHost();
@ -1988,7 +1647,7 @@ public sealed class GameWindow :
runtimeDiagnostics,
new AcDream.App.Input.PlayerModeGameplayCommands(
_localPlayerMode,
_playerModeController),
sessionPlayer.PlayerMode),
new AcDream.App.Input.ItemTargetModeCommands(
_itemInteractionController!),
new AcDream.App.Input.GameplayCameraModeCommands(
@ -1996,7 +1655,7 @@ public sealed class GameWindow :
combatCommand,
new AcDream.App.Input.GameplayWindowCommands(_window!.Close));
var targets = new AcDream.App.Input.RuntimeGameplayInputPriorityTargets(
_gameplayInputFrame!,
sessionPlayer.GameplayInput,
pointer,
_retailUiRuntime,
_selectionInteractions,
@ -2095,7 +1754,7 @@ public sealed class GameWindow :
private void ResetSessionPlayerPresentation()
{
_playerModeController?.ResetSession();
_spawnClaimRangeMemo = null;
_spawnClaimHydration?.Reset();
}
private void ResetSessionIdentity()
@ -2157,7 +1816,10 @@ public sealed class GameWindow :
return new AcDream.App.Net.LiveSessionEventRouter(
session,
_liveEntitySessionEvents.CreateSink(),
(_liveEntitySessionEvents
?? throw new InvalidOperationException(
"Live entity session routing was not composed."))
.CreateSink(),
new AcDream.App.Net.LiveEnvironmentSessionSink(
_worldEnvironment.ApplyAdminEnvirons,
_worldEnvironment.SynchronizeFromServer),
@ -2392,34 +2054,6 @@ public sealed class GameWindow :
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
=> _framebufferResize.Resize(newSize);
// #107 (2026-06-10): memoized "this indoor spawn claim can never hydrate"
// check against the dat's LandBlockInfo.NumCells. Used by the auto-entry
// hold so a garbage claim doesn't stall login forever; the Resolve-head
// safety net demotes it loudly once entry proceeds.
private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;
private bool IsSpawnClaimUnhydratable(uint claim)
{
if ((claim & 0xFFFFu) < 0x0100u) return false;
if (_spawnClaimRangeMemo is { } m && m.Claim == claim) return m.Unhydratable;
bool unhydratable = false;
if (_dats is not null)
{
DatReaderWriter.DBObjs.LandBlockInfo? lbInfo;
lock (_datLock)
{
lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
(claim & 0xFFFF0000u) | 0xFFFEu);
}
uint low = claim & 0xFFFFu;
unhydratable = lbInfo is null || lbInfo.NumCells == 0
|| low >= 0x0100u + lbInfo.NumCells;
}
_spawnClaimRangeMemo = (claim, unhydratable);
return unhydratable;
}
private void OnClosing()
{
_hostQuiescence.StopAccepting();
@ -2565,11 +2199,18 @@ public sealed class GameWindow :
// the later render-frontend stage still disposes the GL owners.
new ResourceShutdownStage("frame borrowers",
[
new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets),
new("world frame composition", () =>
{
_frameGraphs.Withdraw();
}),
new("session/player bindings", () =>
{
SessionPlayerRuntimeBindings? bindings = _sessionPlayerBindings;
if (bindings is null)
return;
bindings.Dispose();
_sessionPlayerBindings = null;
}),
]),
new ResourceShutdownStage("session dependents",
[
@ -2587,8 +2228,6 @@ public sealed class GameWindow :
_cameraPointerInput;
if (pointer is null) return;
pointer.ReleaseMouseLookAfterSessionRetirement();
if (_gameplayInputFrame is { } gameplayFrame)
pointer.UnbindGameplayFrame(gameplayFrame);
_cameraPointerInput = null;
}),
new("retail UI", () =>

View file

@ -246,8 +246,38 @@ internal sealed class RuntimeSettingsController :
_runtimeTargets = targets;
}
public IDisposable BindRuntimeTargetsOwned(IRuntimeSettingsTargets targets)
{
BindRuntimeTargets(targets);
return new RuntimeTargetBinding(this, targets);
}
private void UnbindRuntimeTargets(IRuntimeSettingsTargets expected)
{
if (ReferenceEquals(_runtimeTargets, expected))
_runtimeTargets = null;
}
public void UnbindRuntimeTargets() => _runtimeTargets = null;
private sealed class RuntimeTargetBinding : IDisposable
{
private RuntimeSettingsController? _owner;
private readonly IRuntimeSettingsTargets _expected;
public RuntimeTargetBinding(
RuntimeSettingsController owner,
IRuntimeSettingsTargets expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?
.UnbindRuntimeTargets(_expected);
}
public SettingsVM CreateViewModel(
KeyBindings persistedBindings,
InputDispatcher dispatcher,

View file

@ -0,0 +1,57 @@
using AcDream.Content;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Streaming;
/// <summary>
/// Memoized range check for indoor spawn claims. Claims below the indoor-cell
/// range are always hydratable; indoor claims are validated against the exact
/// landblock's authored cell count.
/// </summary>
internal sealed class DatSpawnClaimHydrationClassifier
{
private readonly Func<uint, LandBlockInfo?> _lookup;
private (uint Claim, bool Unhydratable)? _memo;
public DatSpawnClaimHydrationClassifier(
IDatReaderWriter dats,
object datLock)
: this(CreateLookup(dats, datLock))
{
}
internal DatSpawnClaimHydrationClassifier(
Func<uint, LandBlockInfo?> lookup) =>
_lookup = lookup ?? throw new ArgumentNullException(nameof(lookup));
public bool IsUnhydratable(uint claim)
{
uint low = claim & 0xFFFFu;
if (low < 0x0100u)
return false;
if (_memo is { } memo && memo.Claim == claim)
return memo.Unhydratable;
LandBlockInfo? info = _lookup((claim & 0xFFFF0000u) | 0xFFFEu);
bool unhydratable = info is null
|| info.NumCells == 0
|| low >= 0x0100u + info.NumCells;
_memo = (claim, unhydratable);
return unhydratable;
}
public void Reset() => _memo = null;
private static Func<uint, LandBlockInfo?> CreateLookup(
IDatReaderWriter dats,
object datLock)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(datLock);
return id =>
{
lock (datLock)
return dats.Get<LandBlockInfo>(id);
};
}
}

View file

@ -41,6 +41,17 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
throw new InvalidOperationException("The local teleport sink is already bound.");
}
public IDisposable BindOwned(ILocalPlayerTeleportNetworkSink inner)
{
Bind(inner);
return new Binding(this, inner);
}
private void Unbind(ILocalPlayerTeleportNetworkSink expected)
{
_ = Interlocked.CompareExchange(ref _inner, null, expected);
}
public void OnTeleportStarted(uint sequence) => Required().OnTeleportStarted(sequence);
public void OfferDestination(
@ -53,6 +64,23 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
private ILocalPlayerTeleportNetworkSink Required() =>
_inner ?? throw new InvalidOperationException(
"The local teleport sink was used before composition completed.");
private sealed class Binding : IDisposable
{
private DeferredLocalPlayerTeleportNetworkSink? _owner;
private readonly ILocalPlayerTeleportNetworkSink _expected;
public Binding(
DeferredLocalPlayerTeleportNetworkSink owner,
ILocalPlayerTeleportNetworkSink expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}
internal interface ILocalPlayerTeleportInputLifetime

View file

@ -103,10 +103,38 @@ internal sealed class DeferredLiveEntityNetworkUpdateSink : ILiveEntityNetworkUp
throw new InvalidOperationException("The live-entity network sink is already bound.");
}
public IDisposable BindOwned(ILiveEntityNetworkUpdateSink inner)
{
Bind(inner);
return new Binding(this, inner);
}
private void Unbind(ILiveEntityNetworkUpdateSink expected)
{
_ = Interlocked.CompareExchange(ref _inner, null, expected);
}
public void ApplySameGeneration(SameGenerationCreateObjectEvents events) =>
(_inner ?? throw new InvalidOperationException(
"The live-entity network sink must be bound before a session starts."))
.ApplySameGeneration(events);
private sealed class Binding : IDisposable
{
private DeferredLiveEntityNetworkUpdateSink? _owner;
private readonly ILiveEntityNetworkUpdateSink _expected;
public Binding(
DeferredLiveEntityNetworkUpdateSink owner,
ILiveEntityNetworkUpdateSink expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}
internal sealed class DelegateLiveEntityNetworkUpdateSink(

View file

@ -41,9 +41,37 @@ internal sealed class DeferredLiveEntityParentAcceptance
throw new InvalidOperationException("Live parent acceptance is already bound.");
}
public IDisposable BindOwned(Func<ParentEvent.Parsed, bool> accept)
{
Bind(accept);
return new Binding(this, accept);
}
private void Unbind(Func<ParentEvent.Parsed, bool> expected)
{
_ = Interlocked.CompareExchange(ref _accept, null, expected);
}
public bool TryAccept(ParentEvent.Parsed update) =>
(_accept ?? throw new InvalidOperationException(
"Live parent acceptance must be bound before a session starts."))(update);
private sealed class Binding : IDisposable
{
private DeferredLiveEntityParentAcceptance? _owner;
private readonly Func<ParentEvent.Parsed, bool> _expected;
public Binding(
DeferredLiveEntityParentAcceptance owner,
Func<ParentEvent.Parsed, bool> expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}
internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher

View file

@ -152,6 +152,17 @@ internal sealed class DeferredLiveEntityRuntimeComponentLifecycle :
throw new InvalidOperationException("The live-entity runtime component lifecycle is already bound.");
}
public IDisposable BindOwned(ILiveEntityRuntimeComponentLifecycle lifecycle)
{
Bind(lifecycle);
return new Binding(this, lifecycle);
}
private void Unbind(ILiveEntityRuntimeComponentLifecycle expected)
{
_ = Interlocked.CompareExchange(ref _inner, null, expected);
}
public void TearDown(LiveEntityRecord record)
{
ILiveEntityRuntimeComponentLifecycle lifecycle = Volatile.Read(ref _inner)
@ -159,6 +170,23 @@ internal sealed class DeferredLiveEntityRuntimeComponentLifecycle :
"The live-entity runtime component lifecycle has not been bound.");
lifecycle.TearDown(record);
}
private sealed class Binding : IDisposable
{
private DeferredLiveEntityRuntimeComponentLifecycle? _owner;
private readonly ILiveEntityRuntimeComponentLifecycle _expected;
public Binding(
DeferredLiveEntityRuntimeComponentLifecycle owner,
ILiveEntityRuntimeComponentLifecycle expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}
/// <summary>Delegate adapter used by the App composition root.</summary>