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

@ -27,13 +27,14 @@ What does NOT go here:
## Current queue — 2026-07-22 ## Current queue — 2026-07-22
- **Active structural work:** `GameWindow` decomposition. Slices 17 and Slice - **Active structural work:** `GameWindow` decomposition. Slices 17 and Slice
8 checkpoints AH are complete; I.1I.6 now own the executable ordered 8 checkpoints AH are complete; I.1I.7 now own the executable ordered
startup path through retained UI and live presentation, including the startup path through retained UI and live presentation, including the
canonical runtime/world, selection/radar, landblock publishers, and canonical runtime/world, selection/radar, landblock publishers, and
portal/sky/particle resources. portal/sky/particle resources.
`GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration I.7 now owns streaming/session/hydration/local-player/teleport startup and
handoffs and the class is 2,840 raw lines. I.7I.9 plus the remaining every late edge explicitly. `GameWindow.OnUpdate` and `GameWindow.OnRender`
shutdown/closeout are typed orchestration handoffs and the class is 2,479 raw lines. I.8I.9
plus the remaining shutdown/closeout
cleanup is active in cleanup is active in
[`docs/architecture/code-structure.md`](architecture/code-structure.md). [`docs/architecture/code-structure.md`](architecture/code-structure.md).
This is the behavior-preserving prerequisite before new M4 feature bodies. This is the behavior-preserving prerequisite before new M4 feature bodies.

View file

@ -731,11 +731,14 @@ retained UI, live presentation, and landblock publication into ordered
composition phases. Typed exact-owner sources bridge later session, composition phases. Typed exact-owner sources bridge later session,
selection, radar, view-plane, diagnostics, inventory, hydration, and automation selection, radar, view-plane, diagnostics, inventory, hydration, and automation
owners without callbacks to the window. The old item-use and selection wrapper owners without callbacks to the window. The old item-use and selection wrapper
methods are deleted. `GameWindow` is 2,840 raw lines after I.6. I.7L remain methods are deleted. I.7 now owns the complete streaming/session/hydration/
active, with session, streaming, and hydration composition next. The App gate local-player/teleport construction phase and every late edge through named
passes 3,407 tests / 3 intentional skips and the complete Release suite passes exact-owner tokens; the former 432-line inline body and spawn-claim memo are
7,779 tests / 5 intentional skips. The clean solution build retains only the gone. `GameWindow` is 2,479 raw lines after I.7. I.8L remain active, with
17 test-project warnings tracked by #228; the I.6 behavior, architecture, and atomic frame-root publication and terminal session start next. The App gate
passes 3,420 tests / 3 intentional skips and the complete Release suite passes
7,792 tests / 5 intentional skips. The clean solution build retains only the
17 test-project warnings tracked by #228; the I.7 behavior, architecture, and
adversarial reviews are clean. adversarial reviews are clean.
### 4.4 Exit criteria ### 4.4 Exit criteria

View file

@ -97,15 +97,17 @@ runtime-target binding without replay, and one current/toon settings owner.
`GameWindow` is 3,663 lines / 162 fields / 37 methods at G. Checkpoint H adds `GameWindow` is 3,663 lines / 162 fields / 37 methods at G. Checkpoint H adds
sole terrain-atlas and sky-shader roots, one retained Host/runtime lease, an sole terrain-atlas and sky-shader roots, one retained Host/runtime lease, an
atomic update/render frame-root pair, prepared portal fallback/transfer, and atomic update/render frame-root pair, prepared portal fallback/transfer, and
checked retryable GL leaf construction and state mutation. Checkpoint I.1I.6 checked retryable GL leaf construction and state mutation. Checkpoint I.1I.7
now provide the executable ordered startup pipeline through retained UI and now provide the executable ordered startup pipeline through retained UI and
live presentation. The canonical live runtime/world, presentation/effect live presentation. The canonical live runtime/world, presentation/effect
controllers, selection/radar, landblock publishers, and portal/sky/particle controllers, selection/radar, landblock publishers, and portal/sky/particle
resources have transactional phase ownership and exact-owner late bindings. resources have transactional phase ownership and exact-owner late bindings.
`GameWindow` is now 2,840 raw lines. I.7I.9, shutdown, canonical soak I.7 adds the complete streaming/session/hydration/local-player/teleport phase,
snapshots, and closeout checkpoints JL remain. The App gate passes 3,407 tests including explicit reversible ownership for every late edge. `GameWindow` is
/ 3 intentional skips, the complete Release suite passes 7,779 tests / 5 now 2,479 raw lines. I.8I.9, shutdown, canonical soak snapshots, and closeout
skips, and all three I.6 corrected-diff reviews are clean. checkpoints JL remain. The App gate passes 3,420 tests / 3 intentional skips,
the complete Release suite passes 7,792 tests / 5 skips, and all three I.7
corrected-diff reviews are clean.
This is a behavior-preserving structural program. Severe regressions still get This is a behavior-preserving structural program. Severe regressions still get
root-cause fixes in separate commits; ordinary feature work resumes with M4 root-cause fixes in separate commits; ordinary feature work resumes with M4

View file

@ -53,15 +53,17 @@ startup application, and inert late target binding. `GameWindow` is 3,663
lines / 162 fields / 37 methods at G. Checkpoint H adds sole terrain/sky lines / 162 fields / 37 methods at G. Checkpoint H adds sole terrain/sky
resource roots, one retained Host/runtime lease, an atomic update/render root resource roots, one retained Host/runtime lease, an atomic update/render root
pair, a prepared portal fallback/transfer transaction, and checked retryable GL pair, a prepared portal fallback/transfer transaction, and checked retryable GL
leaf construction. Checkpoint I.1I.6 now provide the executable ordered leaf construction. Checkpoint I.1I.7 now provide the executable ordered
startup pipeline through retained UI and live presentation. The canonical live startup pipeline through retained UI and live presentation. The canonical live
runtime/world, presentation/effect controllers, selection/radar, landblock runtime/world, presentation/effect controllers, selection/radar, landblock
publishers, and portal/sky/particle resources have transactional phase publishers, and portal/sky/particle resources have transactional phase
ownership and exact-owner late bindings. `GameWindow` is 2,840 raw lines. ownership and exact-owner late bindings. I.7 now owns the complete streaming,
I.7I.9 and checkpoints JL remain active. Issue #232 tracks process-residency session, hydration, local-player, combat, and teleport startup graph, including
variance in the soak without loosening its leak threshold. The App gate passes named reversible late edges. `GameWindow` is 2,479 raw lines. I.8I.9 and
3,407 tests / 3 skips, the complete Release suite passes 7,779 tests / 5 skips, checkpoints JL remain active. Issue #232 tracks process-residency variance in
and all three I.6 corrected-diff reviews are clean. the soak without loosening its leak threshold. The App gate passes 3,420 tests
/ 3 skips, the complete Release suite passes 7,792 tests / 5 skips, and all
three I.7 corrected-diff reviews are clean.
Carried: Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus

View file

@ -281,9 +281,16 @@ ledger only after the complete pipeline is live.
under the same ownership contract. `GameWindow.cs` is 2,840 raw lines; the under the same ownership contract. `GameWindow.cs` is 2,840 raw lines; the
App gate passes 3,407 tests / 3 intentional skips and the complete Release App gate passes 3,407 tests / 3 intentional skips and the complete Release
suite passes 7,779 tests / 5 intentional skips. I.7 session, streaming, and suite passes 7,779 tests / 5 intentional skips. I.7 session, streaming, and
hydration composition is the active cut; its ownership, rollback, and gate hydration composition is complete; its ownership, rollback, and gate
contract is contract is
[`2026-07-22-gamewindow-slice-8-checkpoint-i7-session-player.md`](2026-07-22-gamewindow-slice-8-checkpoint-i7-session-player.md). [`2026-07-22-gamewindow-slice-8-checkpoint-i7-session-player.md`](2026-07-22-gamewindow-slice-8-checkpoint-i7-session-player.md).
- I.7 removes the former 432-line Phase-7 body. Streaming, session, hydration,
local-player, combat, and teleport startup now compose through one typed
result; all late edges have named reversible exact-owner tokens. The
spawn-claim DAT range memo has a focused owner. `GameWindow.cs` is 2,479 raw
lines. App Release passes 3,420 tests / 3 skips; the complete Release suite
passes 7,792 tests / 5 skips with the same 17 warnings tracked by #228. I.8
frame-root publication and terminal session start is the active cut.
Checkpoint I remains active until I.9 closes the complete nine-phase pipeline. Checkpoint I remains active until I.9 closes the complete nine-phase pipeline.
## 6. Automated acceptance ## 6. Automated acceptance

View file

@ -1,6 +1,6 @@
# GameWindow Slice 8 — Checkpoint I.7 Session and Player Composition # GameWindow Slice 8 — Checkpoint I.7 Session and Player Composition
**Status:** Active **Status:** Complete 2026-07-22
**Parent:** `2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md` **Parent:** `2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md`
@ -183,3 +183,20 @@ edge has explicit reversible ownership, failure and retry gates pass, the old
window body is gone, documentation/memory report the measured class size and window body is gone, documentation/memory report the measured class size and
test totals, and the commit is independently buildable. I.8 then composes the test totals, and the commit is independently buildable. I.8 then composes the
update/render roots and terminal session start over the typed result graph. update/render roots and terminal session start over the typed result graph.
## 8. Completion evidence
- `SessionPlayerCompositionPhase` is the production Phase-7 path. The former
432-line radius/streaming/session/hydration/player/teleport block is absent
from `GameWindow`.
- `SessionPlayerRuntimeBindings` plus exact-owner tokens cover every Phase-7
late edge. Focused tests cover reverse retry, rebind, and stale-token safety.
- `DatSpawnClaimHydrationClassifier` owns the former window memo and exact
indoor-cell boundaries, including missing/empty DAT cases and reset.
- `GameWindow.cs` is 2,479 raw lines, down 13,244 lines (84.2%) from the
15,723-line campaign baseline.
- App Release: 3,420 passed / 3 intentional skips. Complete Release suite:
7,792 passed / 5 intentional skips. Clean solution build: 0 errors and the
17 pre-existing test warnings tracked by #228.
- Behavior/order, architecture/ownership, and adversarial rollback reviews are
clean. No gameplay mechanism changed and no divergence row was introduced.

View file

@ -29,7 +29,8 @@ audit.
- [x] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots - [x] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots
explicit single ownership and transfer seams. explicit single ownership and transfer seams.
- [ ] I — group `OnLoad` into small ordered, fakeable composition phases with - [ ] I — group `OnLoad` into small ordered, fakeable composition phases with
transactional partial-acquisition rollback. Active detailed plan: transactional partial-acquisition rollback. I.1I.7 are complete; I.8
frame-root publication and terminal session start is active. Detailed plan:
[`2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md`](2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md). [`2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md`](2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md).
- [ ] J — move the exact retryable shutdown manifest to a focused lifetime - [ ] J — move the exact retryable shutdown manifest to a focused lifetime
owner and prove all partial-load/reentrant/retry paths. owner and prove all partial-load/reentrant/retry paths.

View file

@ -67,6 +67,22 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
_owner = owner; _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 bool CanStartAttack() => _owner?.CanStartAttack() == true;
public void PrepareAttackRequest() => _owner?.PrepareAttackRequest(); public void PrepareAttackRequest() => _owner?.PrepareAttackRequest();
public bool SendAttack(AttackHeight height, float power) => public bool SendAttack(AttackHeight height, float power) =>
@ -75,6 +91,23 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
public bool IsDualWield => _owner?.IsDualWield == true; public bool IsDualWield => _owner?.IsDualWield == true;
public bool PlayerReadyForAttack => _owner?.PlayerReadyForAttack == true; public bool PlayerReadyForAttack => _owner?.PlayerReadyForAttack == true;
public bool AutoRepeatAttack => _owner?.AutoRepeatAttack == 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> /// <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; _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) public void UnbindGameplayFrame(GameplayInputFrameController gameplayFrame)
{ {
ArgumentNullException.ThrowIfNull(gameplayFrame); ArgumentNullException.ThrowIfNull(gameplayFrame);
@ -252,6 +266,24 @@ internal sealed class CameraPointerInputController
_gameplayFrame = null; _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) public void HandleFocusChanged(bool focused)
{ {
if (!focused) if (!focused)

View file

@ -199,6 +199,20 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
_session = session; _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) public void Unbind(LiveSessionController session)
{ {
ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(session);
@ -211,6 +225,23 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
_deactivated = true; _deactivated = true;
_session = null; _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> /// <summary>Typed panel operations used by menu and input presentation.</summary>

View file

@ -167,6 +167,20 @@ internal sealed class DeferredDevToolsPlayerModeCommands
_target = target; _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) public void Unbind(IDevToolsPlayerModeTarget target)
{ {
ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(target);
@ -185,6 +199,23 @@ internal sealed class DeferredDevToolsPlayerModeCommands
if (!_deactivated) if (!_deactivated)
_target?.ToggleFlyOrChase(); _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 internal interface IDevToolsRuntimeFacts

View file

@ -21,7 +21,8 @@ public sealed class GameWindow :
IGameWindowSettingsDevToolsPublication, IGameWindowSettingsDevToolsPublication,
IGameWindowWorldRenderPublication, IGameWindowWorldRenderPublication,
IGameWindowInteractionRetainedUiPublication, IGameWindowInteractionRetainedUiPublication,
IGameWindowLivePresentationPublication IGameWindowLivePresentationPublication,
IGameWindowSessionPlayerPublication
{ {
private static double ClientTimerNow() => private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp() System.Diagnostics.Stopwatch.GetTimestamp()
@ -99,6 +100,7 @@ public sealed class GameWindow :
private AcDream.App.Rendering.RenderFrameDiagnosticsController? private AcDream.App.Rendering.RenderFrameDiagnosticsController?
_renderFrameDiagnostics; _renderFrameDiagnostics;
private LivePresentationRuntimeBindings? _livePresentationBindings; private LivePresentationRuntimeBindings? _livePresentationBindings;
private SessionPlayerRuntimeBindings? _sessionPlayerBindings;
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
@ -123,22 +125,14 @@ public sealed class GameWindow :
private AcDream.App.Streaming.StreamingOriginRecenterCoordinator? private AcDream.App.Streaming.StreamingOriginRecenterCoordinator?
_streamingOriginRecenter; _streamingOriginRecenter;
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
private AcDream.App.Streaming.DatSpawnClaimHydrationClassifier?
_spawnClaimHydration;
private readonly AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink private readonly AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink
_localPlayerTeleportSink = new(); _localPlayerTeleportSink = new();
private AcDream.App.Streaming.LocalPlayerTeleportController? private AcDream.App.Streaming.LocalPlayerTeleportController?
_localPlayerTeleport; _localPlayerTeleport;
private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange = private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange =
new(nearRadius: 4, farRadius: 12); 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. // Phase B.3: physics engine — populated from the streaming pipeline.
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new(); private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
@ -170,7 +164,7 @@ public sealed class GameWindow :
_liveEntityProjectionWithdrawal; _liveEntityProjectionWithdrawal;
private AcDream.App.World.LiveEntityHydrationController? _liveEntityHydration; private AcDream.App.World.LiveEntityHydrationController? _liveEntityHydration;
private AcDream.App.Physics.LiveEntityNetworkUpdateController? _liveEntityNetworkUpdates; 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 private readonly AcDream.App.Physics.DeferredLiveEntityMotionRuntimeBindings
_liveEntityMotionBindings = new(); _liveEntityMotionBindings = new();
@ -996,6 +990,51 @@ public sealed class GameWindow :
_livePresentationBindings = result.RuntimeBindings; _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>( private static void PublishCompositionOwner<T>(
ref T? destination, ref T? destination,
T value, T value,
@ -1139,9 +1178,6 @@ public sealed class GameWindow :
Console.WriteLine), Console.WriteLine),
this).Compose(platform, contentEffectsAudio, settingsDevTools); this).Compose(platform, contentEffectsAudio, settingsDevTools);
string shadersDir = worldRender.Foundation.ShadersDirectory; string shadersDir = worldRender.Foundation.ShadersDirectory;
TerrainAtlas terrainAtlas = worldRender.Foundation.TerrainAtlas;
int centerX = worldRender.TerrainBuild.InitialCenterX;
int centerY = worldRender.TerrainBuild.InitialCenterY;
Console.WriteLine( Console.WriteLine(
$"loading world view centered on " + $"loading world view centered on " +
$"0x{worldRender.TerrainBuild.InitialCenterLandblockId:X8}"); $"0x{worldRender.TerrainBuild.InitialCenterLandblockId:X8}");
@ -1243,441 +1279,64 @@ public sealed class GameWindow :
worldRender, worldRender,
interactionUi); interactionUi);
// Apply radii from the same immutable quality snapshot used for the SessionPlayerResult sessionPlayer =
// window's MSAA, terrain anisotropy, and dispatcher A2C setup. new SessionPlayerCompositionPhase(
// Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat. new SessionPlayerDependencies(
_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, _options,
_dats!, _window!,
_liveEntities!, _datLock,
_runtimeSettings,
settingsDevTools,
_physicsEngine,
_physicsDataCache, _physicsDataCache,
_animLoader!,
_wbEntitySpawnAdapter!,
_textureCache!,
_classificationCache,
_effectPoses,
_equippedChildRenderer!,
_worldGameState, _worldGameState,
_worldEvents, _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, _selection,
_animatedEntities, Objects,
_remoteMovementObservations,
_translucencyFades,
_liveEntityProjectionWithdrawal!,
_equippedChildRenderer!,
_physicsEngine.ShadowObjects,
_liveEntityLights!,
_classificationCache, _classificationCache,
_localPlayerIdentity); _liveEntityRuntimeSlot,
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, _animatedEntities,
new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(
_liveEntityRuntimeSlot),
_remoteMovementObservations, _remoteMovementObservations,
_remotePhysicsUpdater, _remotePhysicsUpdater,
_remoteInboundMotion, _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, _inboundEntityEvents,
_liveEntityHydration, _liveEntityMotionBindings,
_liveEntityNetworkUpdates, _localPlayerIdentity,
_playerControllerSlot,
_playerHostSlot,
_localPlayerMode,
_chaseCameraInput,
_localPlayerOutbound,
_localPlayerTeleportSink, _localPlayerTeleportSink,
_entityEffects!); _liveWorldOrigin,
livePresentation.ParentAcceptance.Bind( _renderRange,
_liveEntityHydration.TryAcceptParentForProjection); _localPlayerShadow,
networkUpdateBridge.Bind(_liveEntityNetworkUpdates); _localPlayerSkills,
livePresentation.EquippedChildren.EntityReady += candidate => _viewportAspect,
_liveEntityHydration.OnEntityReady(candidate); _playerApproachCompletions,
_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, _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, _movementInput,
_inputCapture, _inputCapture,
livePresentation.LiveEntities, _particleVisibility,
_localPlayerIdentity, _translucencyFades,
_playerHostSlot, _effectPoses,
localPlayerProjection, _updateFrameClock,
_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, _movementTruthDiagnostics,
_localPlayerSkills, _combatAttackOperations,
_viewportAspect); Combat,
_devToolsComposition?.LateBindings.PlayerModeCommands.Bind( _combatFeedback,
_playerModeController); _portalTunnelFallback,
_devToolsCommandBus?.Bind(_liveSessionController); Console.WriteLine),
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( this).Compose(
new AcDream.App.Input.LivePlayerModeAutoEntryContext( hostInputCamera,
_liveSessionController, contentEffectsAudio,
livePresentation.LiveEntities, worldRender,
_localPlayerIdentity, interactionUi,
_worldReveal livePresentation);
?? 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(
_physicsEngine,
livePresentation.LiveEntities,
_localPlayerIdentity,
_playerControllerSlot,
_playerHostSlot,
_chaseCameraInput,
_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));
var teleportRenderState = var teleportRenderState =
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource( new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
_localPlayerTeleport); sessionPlayer.LocalTeleport);
var renderLoginState = new AcDream.App.Rendering.RenderLoginStateSource( var renderLoginState = new AcDream.App.Rendering.RenderLoginStateSource(
_options.LiveMode, _options.LiveMode,
_localPlayerMode); _localPlayerMode);
@ -1743,7 +1402,7 @@ public sealed class GameWindow :
new AcDream.App.Rendering.WorldRenderFrameBuilder( new AcDream.App.Rendering.WorldRenderFrameBuilder(
new AcDream.App.Rendering.RuntimeWorldFrameCameraSource( new AcDream.App.Rendering.RuntimeWorldFrameCameraSource(
_cameraController!, _cameraController!,
_localPlayerTeleport), sessionPlayer.LocalTeleport),
new AcDream.App.Rendering.RuntimeWorldFrameVisibilityPreparation( new AcDream.App.Rendering.RuntimeWorldFrameVisibilityPreparation(
_retailSelectionScene, _retailSelectionScene,
_particleVisibility, _particleVisibility,
@ -1876,7 +1535,7 @@ public sealed class GameWindow :
var privatePresentation = var privatePresentation =
new AcDream.App.Rendering.PrivatePresentationRenderer( new AcDream.App.Rendering.PrivatePresentationRenderer(
new AcDream.App.Rendering.LocalPlayerPortalViewport( new AcDream.App.Rendering.LocalPlayerPortalViewport(
_localPlayerTeleport!, sessionPlayer.LocalTeleport,
_cameraController!), _cameraController!),
renderFrameResources, renderFrameResources,
_paperdollFramePresenter, _paperdollFramePresenter,
@ -1899,19 +1558,19 @@ public sealed class GameWindow :
_devToolsFramePresenter _devToolsFramePresenter
?? AcDream.App.Rendering.NullRenderFrameFailureRecovery.Instance); ?? AcDream.App.Rendering.NullRenderFrameFailureRecovery.Instance);
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame, sessionPlayer.LiveObjectFrame,
_worldState, _worldState,
_liveSessionController, sessionPlayer.LiveSession,
localPlayerFrame, sessionPlayer.LocalPlayerFrame,
liveSpatialReconciler); sessionPlayer.LiveSpatialReconciler);
var cameraFrame = new AcDream.App.Rendering.CameraFrameController( var cameraFrame = new AcDream.App.Rendering.CameraFrameController(
_cameraController!, _cameraController!,
_inputCapture, _inputCapture,
_cameraInput, _cameraInput,
localPlayerFrameRuntime, sessionPlayer.LocalPlayerFrameRuntime,
_chaseCameraInput, _chaseCameraInput,
localPlayerFrame, sessionPlayer.LocalPlayerFrame,
liveSpatialReconciler, sessionPlayer.LiveSpatialReconciler,
new AcDream.App.Combat.CombatCameraTargetSource( new AcDream.App.Combat.CombatCameraTargetSource(
_runtimeSettings, _runtimeSettings,
Combat, Combat,
@ -1923,15 +1582,15 @@ public sealed class GameWindow :
new AcDream.App.Update.ConsoleUpdateFrameFailureSink(), new AcDream.App.Update.ConsoleUpdateFrameFailureSink(),
_updateFrameClock, _updateFrameClock,
new AcDream.App.Update.PhysicsScriptClockPublisher(_scriptRunner!), new AcDream.App.Update.PhysicsScriptClockPublisher(_scriptRunner!),
streamingFrame, sessionPlayer.StreamingFrame,
_gameplayInputFrame, sessionPlayer.GameplayInput,
liveFrameCoordinator, liveFrameCoordinator,
new AcDream.App.Update.LiveEntityLivenessFramePhase( new AcDream.App.Update.LiveEntityLivenessFramePhase(
_liveEntityLiveness, sessionPlayer.Liveness,
new AcDream.App.Update.StopwatchClientMonotonicTimeSource()), new AcDream.App.Update.StopwatchClientMonotonicTimeSource()),
_localPlayerTeleport, sessionPlayer.LocalTeleport,
new AcDream.App.Update.PlayerModeAutoEntryFramePhase( new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
_playerModeAutoEntry), sessionPlayer.PlayerModeAutoEntry),
cameraFrame); cameraFrame);
_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator); _frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);
_liveSessionHost = CreateLiveSessionHost(); _liveSessionHost = CreateLiveSessionHost();
@ -1988,7 +1647,7 @@ public sealed class GameWindow :
runtimeDiagnostics, runtimeDiagnostics,
new AcDream.App.Input.PlayerModeGameplayCommands( new AcDream.App.Input.PlayerModeGameplayCommands(
_localPlayerMode, _localPlayerMode,
_playerModeController), sessionPlayer.PlayerMode),
new AcDream.App.Input.ItemTargetModeCommands( new AcDream.App.Input.ItemTargetModeCommands(
_itemInteractionController!), _itemInteractionController!),
new AcDream.App.Input.GameplayCameraModeCommands( new AcDream.App.Input.GameplayCameraModeCommands(
@ -1996,7 +1655,7 @@ public sealed class GameWindow :
combatCommand, combatCommand,
new AcDream.App.Input.GameplayWindowCommands(_window!.Close)); new AcDream.App.Input.GameplayWindowCommands(_window!.Close));
var targets = new AcDream.App.Input.RuntimeGameplayInputPriorityTargets( var targets = new AcDream.App.Input.RuntimeGameplayInputPriorityTargets(
_gameplayInputFrame!, sessionPlayer.GameplayInput,
pointer, pointer,
_retailUiRuntime, _retailUiRuntime,
_selectionInteractions, _selectionInteractions,
@ -2095,7 +1754,7 @@ public sealed class GameWindow :
private void ResetSessionPlayerPresentation() private void ResetSessionPlayerPresentation()
{ {
_playerModeController?.ResetSession(); _playerModeController?.ResetSession();
_spawnClaimRangeMemo = null; _spawnClaimHydration?.Reset();
} }
private void ResetSessionIdentity() private void ResetSessionIdentity()
@ -2157,7 +1816,10 @@ public sealed class GameWindow :
return new AcDream.App.Net.LiveSessionEventRouter( return new AcDream.App.Net.LiveSessionEventRouter(
session, session,
_liveEntitySessionEvents.CreateSink(), (_liveEntitySessionEvents
?? throw new InvalidOperationException(
"Live entity session routing was not composed."))
.CreateSink(),
new AcDream.App.Net.LiveEnvironmentSessionSink( new AcDream.App.Net.LiveEnvironmentSessionSink(
_worldEnvironment.ApplyAdminEnvirons, _worldEnvironment.ApplyAdminEnvirons,
_worldEnvironment.SynchronizeFromServer), _worldEnvironment.SynchronizeFromServer),
@ -2392,34 +2054,6 @@ public sealed class GameWindow :
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize) private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
=> _framebufferResize.Resize(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() private void OnClosing()
{ {
_hostQuiescence.StopAccepting(); _hostQuiescence.StopAccepting();
@ -2565,11 +2199,18 @@ public sealed class GameWindow :
// the later render-frontend stage still disposes the GL owners. // the later render-frontend stage still disposes the GL owners.
new ResourceShutdownStage("frame borrowers", new ResourceShutdownStage("frame borrowers",
[ [
new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets),
new("world frame composition", () => new("world frame composition", () =>
{ {
_frameGraphs.Withdraw(); _frameGraphs.Withdraw();
}), }),
new("session/player bindings", () =>
{
SessionPlayerRuntimeBindings? bindings = _sessionPlayerBindings;
if (bindings is null)
return;
bindings.Dispose();
_sessionPlayerBindings = null;
}),
]), ]),
new ResourceShutdownStage("session dependents", new ResourceShutdownStage("session dependents",
[ [
@ -2587,8 +2228,6 @@ public sealed class GameWindow :
_cameraPointerInput; _cameraPointerInput;
if (pointer is null) return; if (pointer is null) return;
pointer.ReleaseMouseLookAfterSessionRetirement(); pointer.ReleaseMouseLookAfterSessionRetirement();
if (_gameplayInputFrame is { } gameplayFrame)
pointer.UnbindGameplayFrame(gameplayFrame);
_cameraPointerInput = null; _cameraPointerInput = null;
}), }),
new("retail UI", () => new("retail UI", () =>

View file

@ -246,8 +246,38 @@ internal sealed class RuntimeSettingsController :
_runtimeTargets = targets; _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; 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( public SettingsVM CreateViewModel(
KeyBindings persistedBindings, KeyBindings persistedBindings,
InputDispatcher dispatcher, 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."); 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 OnTeleportStarted(uint sequence) => Required().OnTeleportStarted(sequence);
public void OfferDestination( public void OfferDestination(
@ -53,6 +64,23 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
private ILocalPlayerTeleportNetworkSink Required() => private ILocalPlayerTeleportNetworkSink Required() =>
_inner ?? throw new InvalidOperationException( _inner ?? throw new InvalidOperationException(
"The local teleport sink was used before composition completed."); "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 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."); 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) => public void ApplySameGeneration(SameGenerationCreateObjectEvents events) =>
(_inner ?? throw new InvalidOperationException( (_inner ?? throw new InvalidOperationException(
"The live-entity network sink must be bound before a session starts.")) "The live-entity network sink must be bound before a session starts."))
.ApplySameGeneration(events); .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( internal sealed class DelegateLiveEntityNetworkUpdateSink(

View file

@ -41,9 +41,37 @@ internal sealed class DeferredLiveEntityParentAcceptance
throw new InvalidOperationException("Live parent acceptance is already bound."); 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) => public bool TryAccept(ParentEvent.Parsed update) =>
(_accept ?? throw new InvalidOperationException( (_accept ?? throw new InvalidOperationException(
"Live parent acceptance must be bound before a session starts."))(update); "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 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."); 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) public void TearDown(LiveEntityRecord record)
{ {
ILiveEntityRuntimeComponentLifecycle lifecycle = Volatile.Read(ref _inner) ILiveEntityRuntimeComponentLifecycle lifecycle = Volatile.Read(ref _inner)
@ -159,6 +170,23 @@ internal sealed class DeferredLiveEntityRuntimeComponentLifecycle :
"The live-entity runtime component lifecycle has not been bound."); "The live-entity runtime component lifecycle has not been bound.");
lifecycle.TearDown(record); 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> /// <summary>Delegate adapter used by the App composition root.</summary>

View file

@ -24,6 +24,23 @@ public sealed class LiveCombatAttackOperationsTests
Assert.Throws<InvalidOperationException>(() => slot.Bind(second)); Assert.Throws<InvalidOperationException>(() => slot.Bind(second));
} }
[Fact]
public void OwnedBindingReleasesOnlyItsExactOwnerAndAllowsRebind()
{
var slot = new CombatAttackOperationsSlot();
var first = new FakeOperations { CanStartValue = false };
var second = new FakeOperations { CanStartValue = true };
IDisposable firstBinding = slot.BindOwned(first);
Assert.Throws<InvalidOperationException>(() => slot.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = slot.BindOwned(second);
firstBinding.Dispose();
Assert.True(slot.CanStartAttack());
}
[Fact] [Fact]
public void CanStartRequiresLiveWorldBeforeTargetResolution() public void CanStartRequiresLiveWorldBeforeTargetResolution()
{ {
@ -137,7 +154,8 @@ public sealed class LiveCombatAttackOperationsTests
private sealed class FakeOperations : ICombatAttackOperations private sealed class FakeOperations : ICombatAttackOperations
{ {
public bool CanStartAttack() => true; public bool CanStartValue { get; init; } = true;
public bool CanStartAttack() => CanStartValue;
public void PrepareAttackRequest() public void PrepareAttackRequest()
{ {
} }

View file

@ -0,0 +1,160 @@
using AcDream.App.Composition;
using AcDream.App.Streaming;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Composition;
public sealed class SessionPlayerCompositionTests
{
[Fact]
public void RuntimeBindingsReleaseInReverseAndRetryOnlyFailedEdges()
{
var calls = new List<string>();
var bindings = new SessionPlayerRuntimeBindings();
bindings.Adopt("first", new RetryBinding("first", calls, 0));
bindings.Adopt("second", new RetryBinding("second", calls, 1));
Assert.Throws<AggregateException>(bindings.Dispose);
Assert.Equal(["second", "first"], calls);
bindings.Dispose();
bindings.Dispose();
Assert.Equal(["second", "first", "second"], calls);
Assert.Throws<ObjectDisposedException>(() =>
bindings.Adopt("late", new RetryBinding("late", calls, 0)));
}
[Fact]
public void SpawnClaimClassifierPreservesIndoorBoundaryMemoAndReset()
{
int lookups = 0;
uint lastDid = 0;
var info = new LandBlockInfo { NumCells = 2 };
var classifier = new DatSpawnClaimHydrationClassifier(did =>
{
lookups++;
lastDid = did;
return info;
});
Assert.False(classifier.IsUnhydratable(0x123400FFu));
Assert.Equal(0, lookups);
Assert.False(classifier.IsUnhydratable(0x12340100u));
Assert.Equal(0x1234FFFEu, lastDid);
Assert.False(classifier.IsUnhydratable(0x12340100u));
Assert.Equal(1, lookups);
Assert.False(classifier.IsUnhydratable(0x12340101u));
Assert.True(classifier.IsUnhydratable(0x12340102u));
classifier.Reset();
Assert.True(classifier.IsUnhydratable(0x12340102u));
Assert.Equal(4, lookups);
}
[Fact]
public void MissingOrEmptyLandblockMakesIndoorClaimUnhydratable()
{
var missing = new DatSpawnClaimHydrationClassifier(_ => null);
var empty = new DatSpawnClaimHydrationClassifier(
_ => new LandBlockInfo { NumCells = 0 });
Assert.True(missing.IsUnhydratable(0x12340100u));
Assert.True(empty.IsUnhydratable(0x12340100u));
}
[Fact]
public void GameWindowUsesSessionPhaseAndContainsNoPhaseSevenBody()
{
string root = FindRepoRoot();
string window = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
string phase = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains("new SessionPlayerCompositionPhase(", window);
Assert.DoesNotContain("LandblockStreamer.CreateForRequests(", window);
Assert.DoesNotContain("new AcDream.App.Net.LiveSessionController()", window);
Assert.DoesNotContain(
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
window);
Assert.DoesNotContain("IsSpawnClaimUnhydratable", window);
Assert.Contains("LandblockStreamer.CreateForRequests(", phase);
Assert.Contains("new LiveSessionController()", phase);
Assert.Contains("new LocalPlayerTeleportController(", phase);
Assert.Contains("new DatSpawnClaimHydrationClassifier(", phase);
}
[Fact]
public void ProductionPhaseStartsStreamerBeforeSessionAndTransfersPortalLast()
{
string phase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
AssertAppearsInOrder(
phase,
"LandblockStreamer.CreateForRequests(",
"streamerLease.Resource.Start();",
"new StreamingController(",
"new WorldRevealCoordinator(",
"new LiveSessionController()",
"new LiveEntityHydrationController(",
"new LiveEntityNetworkUpdateController(",
"new GameplayInputFrameController(",
"new PlayerModeController(",
"d.PortalTunnelFallback.Transfer(",
"d.TeleportSink.BindOwned(localTeleport)",
"_publication.PublishSessionPlayer(result);");
}
private sealed class RetryBinding(
string name,
List<string> calls,
int failures) : IDisposable
{
private int _failures = failures;
public void Dispose()
{
calls.Add(name);
if (_failures-- > 0)
throw new InvalidOperationException("retry");
}
}
private static void AssertAppearsInOrder(string source, params string[] values)
{
int cursor = 0;
foreach (string value in values)
{
int found = source.IndexOf(value, cursor, StringComparison.Ordinal);
Assert.True(found >= 0, $"Missing expected source fragment: {value}");
cursor = found + value.Length;
}
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -1,6 +1,7 @@
using System.Numerics; using System.Numerics;
using System.Reflection; using System.Reflection;
using AcDream.App.Diagnostics; using AcDream.App.Diagnostics;
using AcDream.App.Net;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
@ -8,6 +9,24 @@ namespace AcDream.App.Tests.Rendering;
public sealed class DevToolsFramePresenterTests public sealed class DevToolsFramePresenterTests
{ {
[Fact]
public void CommandBusOwnedBindingReleasesExactlyAndAllowsRebind()
{
var source = new DevToolsCommandBusSource();
using var first = new LiveSessionController();
using var second = new LiveSessionController();
IDisposable firstBinding = source.BindOwned(first);
Assert.Same(first.Commands, source.Current);
Assert.Throws<InvalidOperationException>(() => source.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = source.BindOwned(second);
firstBinding.Dispose();
Assert.Same(second.Commands, source.Current);
}
[Fact] [Fact]
public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder() public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder()
{ {

View file

@ -61,6 +61,25 @@ public sealed class DevToolsRuntimeSourcesTests
Assert.Throws<ObjectDisposedException>(() => source.Bind(first)); Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
} }
[Fact]
public void PlayerModeOwnedBindingReleasesExactlyAndAllowsRebind()
{
var source = new DeferredDevToolsPlayerModeCommands();
var first = new PlayerModeTarget();
var second = new PlayerModeTarget();
IDisposable firstBinding = source.BindOwned(first);
Assert.Throws<InvalidOperationException>(() => source.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = source.BindOwned(second);
firstBinding.Dispose();
source.ToggleFlyOrChase();
Assert.Equal(0, first.ToggleCalls);
Assert.Equal(1, second.ToggleCalls);
}
[Fact] [Fact]
public void WorldCountSlotCannotFreezeOrReleaseTheWrongWorld() public void WorldCountSlotCannotFreezeOrReleaseTheWrongWorld()
{ {

View file

@ -39,14 +39,24 @@ public sealed class GameWindowRenderLeafCompositionTests
[Fact] [Fact]
public void Composition_transfers_portal_tunnel_before_constructing_frame_borrowers() public void Composition_transfers_portal_tunnel_before_constructing_frame_borrowers()
{ {
string phase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
string source = GameWindowSource(); string source = GameWindowSource();
AssertAppearsInOrder(
phase,
"d.PortalTunnelFallback.Transfer(",
"new LocalPlayerTeleportController(",
"new LocalPlayerTeleportPresentation(portalTunnel)",
"d.TeleportSink.BindOwned(localTeleport)",
"_publication.PublishSessionPlayer(result);");
AssertAppearsInOrder( AssertAppearsInOrder(
source, source,
"_portalTunnelFallback.Transfer(", "new SessionPlayerCompositionPhase(",
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
"new AcDream.App.Streaming.LocalPlayerTeleportPresentation(",
"_localPlayerTeleportSink.Bind(_localPlayerTeleport);",
"new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(", "new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(",
"new AcDream.App.Rendering.RenderFrameResourceController(", "new AcDream.App.Rendering.RenderFrameResourceController(",
"new AcDream.App.Rendering.PrivatePresentationRenderer(", "new AcDream.App.Rendering.PrivatePresentationRenderer(",

View file

@ -217,7 +217,7 @@ public sealed class GameWindowSlice8BoundaryTests
"private AcDream.App.Net.LiveSessionHost"); "private AcDream.App.Net.LiveSessionHost");
string body = MethodBody( string body = MethodBody(
"private void OnFramebufferResize(", "private void OnFramebufferResize(",
"private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;"); "private void OnClosing()");
Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal); Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal);
Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal); Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal);
@ -294,8 +294,18 @@ public sealed class GameWindowSlice8BoundaryTests
"new SettingsDevToolsCompositionPhase(", "new SettingsDevToolsCompositionPhase(",
"this).Compose(platform, hostInputCamera, contentEffectsAudio);", "this).Compose(platform, hostInputCamera, contentEffectsAudio);",
"new WorldRenderCompositionPhase(", "new WorldRenderCompositionPhase(",
"_runtimeSettings.BindRuntimeTargets(", "new SessionPlayerCompositionPhase(",
"_liveSessionHost.Start(_options)"); "_liveSessionHost.Start(_options)");
string sessionPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains(
"d.Settings.BindRuntimeTargetsOwned(settingsTargets)",
sessionPhase,
StringComparison.Ordinal);
string settingsPhase = File.ReadAllText(Path.Combine( string settingsPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(), FindRepoRoot(),
"src", "src",
@ -320,7 +330,8 @@ public sealed class GameWindowSlice8BoundaryTests
AssertAppearsInOrder( AssertAppearsInOrder(
shutdown, shutdown,
"_runtimeSettings.UnbindViewModel()", "_runtimeSettings.UnbindViewModel()",
"_runtimeSettings.UnbindRuntimeTargets", "new ResourceShutdownStage(\"frame borrowers\"",
"bindings.Dispose();",
"_retailUiLease.Dispose()", "_retailUiLease.Dispose()",
"_streamer?.Dispose()", "_streamer?.Dispose()",
"_wbDrawDispatcher?.Dispose()", "_wbDrawDispatcher?.Dispose()",
@ -425,7 +436,6 @@ public sealed class GameWindowSlice8BoundaryTests
"pointer.Dispose();", "pointer.Dispose();",
"new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"session dependents\"",
"pointer.ReleaseMouseLookAfterSessionRetirement();", "pointer.ReleaseMouseLookAfterSessionRetirement();",
"pointer.UnbindGameplayFrame(gameplayFrame);",
"_cameraPointerInput = null;"); "_cameraPointerInput = null;");
AssertAppearsInOrder( AssertAppearsInOrder(
dispose, dispose,
@ -472,6 +482,12 @@ public sealed class GameWindowSlice8BoundaryTests
"Composition", "Composition",
"WorldRenderComposition.cs")); "WorldRenderComposition.cs"));
string livePhase = LivePresentationSource(); string livePhase = LivePresentationSource();
string sessionPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
AssertAppearsInOrder( AssertAppearsInOrder(
livePhase, livePhase,
@ -483,8 +499,13 @@ public sealed class GameWindowSlice8BoundaryTests
load, load,
"new WorldRenderCompositionPhase(", "new WorldRenderCompositionPhase(",
"new LivePresentationCompositionPhase(", "new LivePresentationCompositionPhase(",
"_portalTunnelFallback.Transfer(", "new SessionPlayerCompositionPhase(",
"_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);"); "_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);");
AssertAppearsInOrder(
sessionPhase,
"d.PortalTunnelFallback.Transfer(",
"new LocalPlayerTeleportController(",
"_publication.PublishSessionPlayer(result);");
AssertAppearsInOrder( AssertAppearsInOrder(
worldPhase, worldPhase,
"lifetime.AcquireTerrainAtlas(", "lifetime.AcquireTerrainAtlas(",

View file

@ -524,6 +524,27 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal(1, second.UiLockCalls); Assert.Equal(1, second.UiLockCalls);
} }
[Fact]
public void OwnedRuntimeTargetsReleaseExactlyAndAllowRebind()
{
var events = new List<string>();
RuntimeSettingsController controller = CreateController(events: events);
var first = new FakeRuntimeTargets(events);
var second = new FakeRuntimeTargets(events);
IDisposable firstBinding = controller.BindRuntimeTargetsOwned(first);
Assert.Throws<InvalidOperationException>(() =>
controller.BindRuntimeTargetsOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = controller.BindRuntimeTargetsOwned(second);
firstBinding.Dispose();
controller.SetUiLocked(true);
Assert.Equal(0, first.UiLockCalls);
Assert.Equal(1, second.UiLockCalls);
}
[Fact] [Fact]
public void DisplayPersistenceFailureDoesNotPublishStateOrTargets() public void DisplayPersistenceFailureDoesNotPublishStateOrTargets()
{ {

View file

@ -13,6 +13,25 @@ namespace AcDream.App.Tests.Streaming;
public sealed class LocalPlayerTeleportControllerTests public sealed class LocalPlayerTeleportControllerTests
{ {
[Fact]
public void DeferredNetworkOwnedBindingReleasesExactlyAndAllowsRebind()
{
var deferred = new DeferredLocalPlayerTeleportNetworkSink();
var first = new FakeNetworkSink();
var second = new FakeNetworkSink();
IDisposable firstBinding = deferred.BindOwned(first);
Assert.Throws<InvalidOperationException>(() => deferred.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = deferred.BindOwned(second);
firstBinding.Dispose();
deferred.OnTeleportStarted(9);
Assert.Empty(first.Starts);
Assert.Equal([9u], second.Starts);
}
[Fact] [Fact]
public void DestinationBeforeStart_IsReplayedOnceAfterPresentationActivates() public void DestinationBeforeStart_IsReplayedOnceAfterPresentationActivates()
{ {
@ -684,6 +703,20 @@ public sealed class LocalPlayerTeleportControllerTests
} }
} }
private sealed class FakeNetworkSink : ILocalPlayerTeleportNetworkSink
{
public List<uint> Starts { get; } = [];
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
public void OfferDestination(
WorldSession.EntityPositionUpdate update,
bool teleportTimestampAdvanced)
{
}
public void ResetSession()
{
}
}
private sealed class FakePresentation : ILocalPlayerTeleportPresentation private sealed class FakePresentation : ILocalPlayerTeleportPresentation
{ {
private readonly List<string> _order; private readonly List<string> _order;

View file

@ -42,6 +42,29 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests
Assert.Throws<ArgumentException>(() => bridge.Bind(bridge)); Assert.Throws<ArgumentException>(() => bridge.Bind(bridge));
} }
[Fact]
public void OwnedBindingReleasesExactlyAndAllowsRebind()
{
var bridge = new DeferredLiveEntityRuntimeComponentLifecycle();
int firstCalls = 0;
int secondCalls = 0;
var first = new DelegateLiveEntityRuntimeComponentLifecycle(
_ => firstCalls++);
var second = new DelegateLiveEntityRuntimeComponentLifecycle(
_ => secondCalls++);
IDisposable firstBinding = bridge.BindOwned(first);
Assert.Throws<InvalidOperationException>(() => bridge.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = bridge.BindOwned(second);
firstBinding.Dispose();
bridge.TearDown(CreateRecord());
Assert.Equal(0, firstCalls);
Assert.Equal(1, secondCalls);
}
[Fact] [Fact]
public async Task ConcurrentBind_HasOneWinnerAndPublishesThatOwner() public async Task ConcurrentBind_HasOneWinnerAndPublishesThatOwner()
{ {

View file

@ -32,6 +32,24 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Throws<InvalidOperationException>(() => bridge.Bind(_ => false)); Assert.Throws<InvalidOperationException>(() => bridge.Bind(_ => false));
} }
[Fact]
public void ParentAcceptanceOwnedBindingReleasesExactlyAndAllowsRebind()
{
var bridge = new DeferredLiveEntityParentAcceptance();
Func<ParentEvent.Parsed, bool> first = _ => true;
Func<ParentEvent.Parsed, bool> second = _ => false;
IDisposable firstBinding = bridge.BindOwned(first);
Assert.True(bridge.TryAccept(default));
Assert.Throws<InvalidOperationException>(() => bridge.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = bridge.BindOwned(second);
firstBinding.Dispose();
Assert.False(bridge.TryAccept(default));
}
[Fact] [Fact]
public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization() public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization()
{ {
@ -1580,6 +1598,25 @@ public sealed class LiveEntityHydrationControllerTests
deferred.Bind(new RecordingNetworkSink())); deferred.Bind(new RecordingNetworkSink()));
} }
[Fact]
public void DeferredNetworkOwnedBindingReleasesExactlyAndAllowsRebind()
{
var deferred = new DeferredLiveEntityNetworkUpdateSink();
var first = new RecordingNetworkSink();
var second = new RecordingNetworkSink();
IDisposable firstBinding = deferred.BindOwned(first);
Assert.Throws<InvalidOperationException>(() => deferred.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = deferred.BindOwned(second);
firstBinding.Dispose();
deferred.ApplySameGeneration(default);
Assert.Equal(0, first.ApplyCount);
Assert.Equal(1, second.ApplyCount);
}
private const uint Guid = 0x70000001u; private const uint Guid = 0x70000001u;
private const uint Cell = 0x01010001u; private const uint Cell = 0x01010001u;

View file

@ -343,9 +343,15 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App", "AcDream.App",
"Composition", "Composition",
"LivePresentationComposition.cs")); "LivePresentationComposition.cs"));
string sessionPlayer = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains("new AcDream.App.Update.LiveObjectFrameController(", source); Assert.Contains("new LiveObjectFrameController(", sessionPlayer);
Assert.Contains("new AcDream.App.Update.LiveSpatialPresentationReconciler(", source); Assert.Contains("new LiveSpatialPresentationReconciler(", sessionPlayer);
Assert.Contains("new AcDream.App.World.RetailLiveFrameCoordinator(", source); Assert.Contains("new AcDream.App.World.RetailLiveFrameCoordinator(", source);
Assert.DoesNotContain("AdvanceLiveObjectRuntime", source, StringComparison.Ordinal); Assert.DoesNotContain("AdvanceLiveObjectRuntime", source, StringComparison.Ordinal);
Assert.DoesNotContain("ReconcileLiveObjectSpatialPresentation", source, Assert.DoesNotContain("ReconcileLiveObjectSpatialPresentation", source,
@ -363,8 +369,14 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App", "AcDream.App",
"Rendering", "Rendering",
"GameWindow.cs")); "GameWindow.cs"));
string sessionPlayer = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains("new AcDream.App.Streaming.StreamingFrameController(", source); Assert.Contains("new StreamingFrameController(", sessionPlayer);
Assert.DoesNotContain("_streamingFrame", source, StringComparison.Ordinal); Assert.DoesNotContain("_streamingFrame", source, StringComparison.Ordinal);
Assert.Equal(1, CountOccurrences( Assert.Equal(1, CountOccurrences(
source, source,
@ -385,8 +397,14 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App", "AcDream.App",
"Rendering", "Rendering",
"GameWindow.cs")); "GameWindow.cs"));
string sessionPlayer = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains("new AcDream.App.Input.GameplayInputFrameController(", source); Assert.Contains("new GameplayInputFrameController(", sessionPlayer);
Assert.DoesNotContain("_gameplayInputFrame!.Tick", source, Assert.DoesNotContain("_gameplayInputFrame!.Tick", source,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.DoesNotContain("_inputDispatcher?.Tick()", source, Assert.DoesNotContain("_inputDispatcher?.Tick()", source,
@ -468,10 +486,16 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App", "AcDream.App",
"Physics", "Physics",
"LiveEntityNetworkUpdateController.cs")); "LiveEntityNetworkUpdateController.cs"));
string sessionPlayer = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains( Assert.Contains(
"new AcDream.App.Streaming.LocalPlayerTeleportController(", "new LocalPlayerTeleportController(",
source, sessionPlayer,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.DoesNotContain("_localPlayerTeleport!.Tick", source, Assert.DoesNotContain("_localPlayerTeleport!.Tick", source,
StringComparison.Ordinal); StringComparison.Ordinal);
@ -491,7 +515,7 @@ public sealed class UpdateFrameOrchestratorTests
AssertAppearsInOrder( AssertAppearsInOrder(
source, source,
"new AcDream.App.Update.LiveEntityLivenessFramePhase(", "new AcDream.App.Update.LiveEntityLivenessFramePhase(",
"_localPlayerTeleport,", "sessionPlayer.LocalTeleport,",
"new AcDream.App.Update.PlayerModeAutoEntryFramePhase(", "new AcDream.App.Update.PlayerModeAutoEntryFramePhase(",
"cameraFrame);"); "cameraFrame);");
} }
@ -799,7 +823,7 @@ public sealed class UpdateFrameOrchestratorTests
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("d.WorldOrigin.CellLocalForSeed", livePresentation, Assert.Contains("d.WorldOrigin.CellLocalForSeed", livePresentation,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("_liveEntitySessionEvents.CreateSink()", source, Assert.Contains("Live entity session routing was not composed.", source,
StringComparison.Ordinal); StringComparison.Ordinal);
} }