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:
parent
3573da12e1
commit
7771c07fb6
31 changed files with 1759 additions and 532 deletions
|
|
@ -27,13 +27,14 @@ What does NOT go here:
|
|||
## Current queue — 2026-07-22
|
||||
|
||||
- **Active structural work:** `GameWindow` decomposition. Slices 1–7 and Slice
|
||||
8 checkpoints A–H are complete; I.1–I.6 now own the executable ordered
|
||||
8 checkpoints A–H are complete; I.1–I.7 now own the executable ordered
|
||||
startup path through retained UI and live presentation, including the
|
||||
canonical runtime/world, selection/radar, landblock publishers, and
|
||||
portal/sky/particle resources.
|
||||
`GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration
|
||||
handoffs and the class is 2,840 raw lines. I.7–I.9 plus the remaining
|
||||
shutdown/closeout
|
||||
I.7 now owns streaming/session/hydration/local-player/teleport startup and
|
||||
every late edge explicitly. `GameWindow.OnUpdate` and `GameWindow.OnRender`
|
||||
are typed orchestration handoffs and the class is 2,479 raw lines. I.8–I.9
|
||||
plus the remaining shutdown/closeout
|
||||
cleanup is active in
|
||||
[`docs/architecture/code-structure.md`](architecture/code-structure.md).
|
||||
This is the behavior-preserving prerequisite before new M4 feature bodies.
|
||||
|
|
|
|||
|
|
@ -731,11 +731,14 @@ retained UI, live presentation, and landblock publication into ordered
|
|||
composition phases. Typed exact-owner sources bridge later session,
|
||||
selection, radar, view-plane, diagnostics, inventory, hydration, and automation
|
||||
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.7–L remain
|
||||
active, with session, streaming, and hydration composition next. The App gate
|
||||
passes 3,407 tests / 3 intentional skips and the complete Release suite passes
|
||||
7,779 tests / 5 intentional skips. The clean solution build retains only the
|
||||
17 test-project warnings tracked by #228; the I.6 behavior, architecture, and
|
||||
methods are deleted. I.7 now owns the complete streaming/session/hydration/
|
||||
local-player/teleport construction phase and every late edge through named
|
||||
exact-owner tokens; the former 432-line inline body and spawn-claim memo are
|
||||
gone. `GameWindow` is 2,479 raw lines after I.7. I.8–L remain active, with
|
||||
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.
|
||||
|
||||
### 4.4 Exit criteria
|
||||
|
|
|
|||
|
|
@ -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
|
||||
sole terrain-atlas and sky-shader roots, one retained Host/runtime lease, an
|
||||
atomic update/render frame-root pair, prepared portal fallback/transfer, and
|
||||
checked retryable GL leaf construction and state mutation. Checkpoint I.1–I.6
|
||||
checked retryable GL leaf construction and state mutation. Checkpoint I.1–I.7
|
||||
now provide the executable ordered startup pipeline through retained UI and
|
||||
live presentation. The canonical live runtime/world, presentation/effect
|
||||
controllers, selection/radar, landblock publishers, and portal/sky/particle
|
||||
resources have transactional phase ownership and exact-owner late bindings.
|
||||
`GameWindow` is now 2,840 raw lines. I.7–I.9, shutdown, canonical soak
|
||||
snapshots, and closeout checkpoints J–L remain. The App gate passes 3,407 tests
|
||||
/ 3 intentional skips, the complete Release suite passes 7,779 tests / 5
|
||||
skips, and all three I.6 corrected-diff reviews are clean.
|
||||
I.7 adds the complete streaming/session/hydration/local-player/teleport phase,
|
||||
including explicit reversible ownership for every late edge. `GameWindow` is
|
||||
now 2,479 raw lines. I.8–I.9, shutdown, canonical soak snapshots, and closeout
|
||||
checkpoints J–L 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
|
||||
root-cause fixes in separate commits; ordinary feature work resumes with M4
|
||||
|
|
|
|||
|
|
@ -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
|
||||
resource roots, one retained Host/runtime lease, an atomic update/render root
|
||||
pair, a prepared portal fallback/transfer transaction, and checked retryable GL
|
||||
leaf construction. Checkpoint I.1–I.6 now provide the executable ordered
|
||||
leaf construction. Checkpoint I.1–I.7 now provide the executable ordered
|
||||
startup pipeline through retained UI and live presentation. The canonical live
|
||||
runtime/world, presentation/effect controllers, selection/radar, landblock
|
||||
publishers, and portal/sky/particle resources have transactional phase
|
||||
ownership and exact-owner late bindings. `GameWindow` is 2,840 raw lines.
|
||||
I.7–I.9 and checkpoints J–L remain active. Issue #232 tracks process-residency
|
||||
variance in the soak without loosening its leak threshold. The App gate passes
|
||||
3,407 tests / 3 skips, the complete Release suite passes 7,779 tests / 5 skips,
|
||||
and all three I.6 corrected-diff reviews are clean.
|
||||
ownership and exact-owner late bindings. I.7 now owns the complete streaming,
|
||||
session, hydration, local-player, combat, and teleport startup graph, including
|
||||
named reversible late edges. `GameWindow` is 2,479 raw lines. I.8–I.9 and
|
||||
checkpoints J–L remain active. Issue #232 tracks process-residency variance in
|
||||
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:
|
||||
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
hydration composition is the active cut; its ownership, rollback, and gate
|
||||
hydration composition is complete; its ownership, rollback, and gate
|
||||
contract is
|
||||
[`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.
|
||||
|
||||
## 6. Automated acceptance
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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`
|
||||
|
||||
|
|
@ -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
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ audit.
|
|||
- [x] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots
|
||||
explicit single ownership and transfer seams.
|
||||
- [ ] I — group `OnLoad` into small ordered, fakeable composition phases with
|
||||
transactional partial-acquisition rollback. Active detailed plan:
|
||||
transactional partial-acquisition rollback. I.1–I.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).
|
||||
- [ ] J — move the exact retryable shutdown manifest to a focused lifetime
|
||||
owner and prove all partial-load/reentrant/retry paths.
|
||||
|
|
|
|||
|
|
@ -67,6 +67,22 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
|||
_owner = owner;
|
||||
}
|
||||
|
||||
public IDisposable BindOwned(ICombatAttackOperations owner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(owner);
|
||||
if (_owner is not null)
|
||||
throw new InvalidOperationException(
|
||||
"Combat attack operations are already bound.");
|
||||
_owner = owner;
|
||||
return new Binding(this, owner);
|
||||
}
|
||||
|
||||
private void Unbind(ICombatAttackOperations expected)
|
||||
{
|
||||
if (ReferenceEquals(_owner, expected))
|
||||
_owner = null;
|
||||
}
|
||||
|
||||
public bool CanStartAttack() => _owner?.CanStartAttack() == true;
|
||||
public void PrepareAttackRequest() => _owner?.PrepareAttackRequest();
|
||||
public bool SendAttack(AttackHeight height, float power) =>
|
||||
|
|
@ -75,6 +91,23 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
|||
public bool IsDualWield => _owner?.IsDualWield == true;
|
||||
public bool PlayerReadyForAttack => _owner?.PlayerReadyForAttack == true;
|
||||
public bool AutoRepeatAttack => _owner?.AutoRepeatAttack == true;
|
||||
|
||||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private CombatAttackOperationsSlot? _slot;
|
||||
private readonly ICombatAttackOperations _expected;
|
||||
|
||||
public Binding(
|
||||
CombatAttackOperationsSlot slot,
|
||||
ICombatAttackOperations expected)
|
||||
{
|
||||
_slot = slot;
|
||||
_expected = expected;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _slot, null)?.Unbind(_expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
749
src/AcDream.App/Composition/SessionPlayerComposition.cs
Normal file
749
src/AcDream.App/Composition/SessionPlayerComposition.cs
Normal 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);
|
||||
}
|
||||
95
src/AcDream.App/Composition/SessionPlayerRuntimeBindings.cs
Normal file
95
src/AcDream.App/Composition/SessionPlayerRuntimeBindings.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -245,6 +245,20 @@ internal sealed class CameraPointerInputController
|
|||
_gameplayFrame = gameplayFrame;
|
||||
}
|
||||
|
||||
public IDisposable BindGameplayFrameOwned(
|
||||
GameplayInputFrameController gameplayFrame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gameplayFrame);
|
||||
if (_gameplayFrame is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The raw pointer owner already has a gameplay frame.");
|
||||
}
|
||||
|
||||
_gameplayFrame = gameplayFrame;
|
||||
return new GameplayFrameBinding(this, gameplayFrame);
|
||||
}
|
||||
|
||||
public void UnbindGameplayFrame(GameplayInputFrameController gameplayFrame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gameplayFrame);
|
||||
|
|
@ -252,6 +266,24 @@ internal sealed class CameraPointerInputController
|
|||
_gameplayFrame = null;
|
||||
}
|
||||
|
||||
private sealed class GameplayFrameBinding : IDisposable
|
||||
{
|
||||
private CameraPointerInputController? _owner;
|
||||
private readonly GameplayInputFrameController _expected;
|
||||
|
||||
public GameplayFrameBinding(
|
||||
CameraPointerInputController owner,
|
||||
GameplayInputFrameController expected)
|
||||
{
|
||||
_owner = owner;
|
||||
_expected = expected;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _owner, null)?
|
||||
.UnbindGameplayFrame(_expected);
|
||||
}
|
||||
|
||||
public void HandleFocusChanged(bool focused)
|
||||
{
|
||||
if (!focused)
|
||||
|
|
|
|||
|
|
@ -199,6 +199,20 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
|
|||
_session = session;
|
||||
}
|
||||
|
||||
public IDisposable BindOwned(LiveSessionController session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ObjectDisposedException.ThrowIf(_deactivated, this);
|
||||
if (_session is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Developer command-bus authority is already bound.");
|
||||
}
|
||||
|
||||
_session = session;
|
||||
return new Binding(this, session);
|
||||
}
|
||||
|
||||
public void Unbind(LiveSessionController session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
|
@ -211,6 +225,23 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
|
|||
_deactivated = true;
|
||||
_session = null;
|
||||
}
|
||||
|
||||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private DevToolsCommandBusSource? _owner;
|
||||
private readonly LiveSessionController _expected;
|
||||
|
||||
public Binding(
|
||||
DevToolsCommandBusSource owner,
|
||||
LiveSessionController expected)
|
||||
{
|
||||
_owner = owner;
|
||||
_expected = expected;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Typed panel operations used by menu and input presentation.</summary>
|
||||
|
|
|
|||
|
|
@ -167,6 +167,20 @@ internal sealed class DeferredDevToolsPlayerModeCommands
|
|||
_target = target;
|
||||
}
|
||||
|
||||
public IDisposable BindOwned(IDevToolsPlayerModeTarget target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
ObjectDisposedException.ThrowIf(_deactivated, this);
|
||||
if (_target is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Developer player-mode commands are already bound.");
|
||||
}
|
||||
|
||||
_target = target;
|
||||
return new Binding(this, target);
|
||||
}
|
||||
|
||||
public void Unbind(IDevToolsPlayerModeTarget target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
|
|
@ -185,6 +199,23 @@ internal sealed class DeferredDevToolsPlayerModeCommands
|
|||
if (!_deactivated)
|
||||
_target?.ToggleFlyOrChase();
|
||||
}
|
||||
|
||||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private DeferredDevToolsPlayerModeCommands? _owner;
|
||||
private readonly IDevToolsPlayerModeTarget _expected;
|
||||
|
||||
public Binding(
|
||||
DeferredDevToolsPlayerModeCommands owner,
|
||||
IDevToolsPlayerModeTarget expected)
|
||||
{
|
||||
_owner = owner;
|
||||
_expected = expected;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IDevToolsRuntimeFacts
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ public sealed class GameWindow :
|
|||
IGameWindowSettingsDevToolsPublication,
|
||||
IGameWindowWorldRenderPublication,
|
||||
IGameWindowInteractionRetainedUiPublication,
|
||||
IGameWindowLivePresentationPublication
|
||||
IGameWindowLivePresentationPublication,
|
||||
IGameWindowSessionPlayerPublication
|
||||
{
|
||||
private static double ClientTimerNow() =>
|
||||
System.Diagnostics.Stopwatch.GetTimestamp()
|
||||
|
|
@ -99,6 +100,7 @@ public sealed class GameWindow :
|
|||
private AcDream.App.Rendering.RenderFrameDiagnosticsController?
|
||||
_renderFrameDiagnostics;
|
||||
private LivePresentationRuntimeBindings? _livePresentationBindings;
|
||||
private SessionPlayerRuntimeBindings? _sessionPlayerBindings;
|
||||
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
|
||||
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
|
||||
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
|
||||
|
|
@ -123,22 +125,14 @@ public sealed class GameWindow :
|
|||
private AcDream.App.Streaming.StreamingOriginRecenterCoordinator?
|
||||
_streamingOriginRecenter;
|
||||
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
|
||||
private AcDream.App.Streaming.DatSpawnClaimHydrationClassifier?
|
||||
_spawnClaimHydration;
|
||||
private readonly AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink
|
||||
_localPlayerTeleportSink = new();
|
||||
private AcDream.App.Streaming.LocalPlayerTeleportController?
|
||||
_localPlayerTeleport;
|
||||
private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange =
|
||||
new(nearRadius: 4, farRadius: 12);
|
||||
private int _nearRadius
|
||||
{
|
||||
get => _renderRange.NearRadius;
|
||||
set => _renderRange.NearRadius = value;
|
||||
}
|
||||
private int _farRadius
|
||||
{
|
||||
get => _renderRange.FarRadius;
|
||||
set => _renderRange.FarRadius = value;
|
||||
}
|
||||
// Phase B.3: physics engine — populated from the streaming pipeline.
|
||||
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
|
||||
|
||||
|
|
@ -170,7 +164,7 @@ public sealed class GameWindow :
|
|||
_liveEntityProjectionWithdrawal;
|
||||
private AcDream.App.World.LiveEntityHydrationController? _liveEntityHydration;
|
||||
private AcDream.App.Physics.LiveEntityNetworkUpdateController? _liveEntityNetworkUpdates;
|
||||
private AcDream.App.Net.LiveEntitySessionController _liveEntitySessionEvents = null!;
|
||||
private AcDream.App.Net.LiveEntitySessionController? _liveEntitySessionEvents;
|
||||
private readonly AcDream.App.Physics.DeferredLiveEntityMotionRuntimeBindings
|
||||
_liveEntityMotionBindings = new();
|
||||
|
||||
|
|
@ -996,6 +990,51 @@ public sealed class GameWindow :
|
|||
_livePresentationBindings = result.RuntimeBindings;
|
||||
}
|
||||
|
||||
void IGameWindowSessionPlayerPublication.PublishSessionPlayer(
|
||||
SessionPlayerResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
if (_streamer is not null
|
||||
|| _streamingController is not null
|
||||
|| _streamingOriginRecenter is not null
|
||||
|| _worldReveal is not null
|
||||
|| _spawnClaimHydration is not null
|
||||
|| _liveSessionController is not null
|
||||
|| _liveEntityHydration is not null
|
||||
|| _liveEntityNetworkUpdates is not null
|
||||
|| _liveEntityLiveness is not null
|
||||
|| _liveEntitySessionEvents is not null
|
||||
|| _gameplayInputFrame is not null
|
||||
|| _localPlayerAnimation is not null
|
||||
|| _localPlayerShadowSynchronizer is not null
|
||||
|| _playerModeController is not null
|
||||
|| _playerModeAutoEntry is not null
|
||||
|| _localPlayerTeleport is not null
|
||||
|| _sessionPlayerBindings is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The GameWindow composition shell already owns session/player state.");
|
||||
}
|
||||
|
||||
_streamer = result.Streamer;
|
||||
_streamingController = result.Streaming;
|
||||
_streamingOriginRecenter = result.StreamingOriginRecenter;
|
||||
_worldReveal = result.WorldReveal;
|
||||
_spawnClaimHydration = result.SpawnClaimHydration;
|
||||
_liveSessionController = result.LiveSession;
|
||||
_liveEntityHydration = result.Hydration;
|
||||
_liveEntityNetworkUpdates = result.NetworkUpdates;
|
||||
_liveEntityLiveness = result.Liveness;
|
||||
_liveEntitySessionEvents = result.SessionEvents;
|
||||
_gameplayInputFrame = result.GameplayInput;
|
||||
_localPlayerAnimation = result.LocalPlayerAnimation;
|
||||
_localPlayerShadowSynchronizer = result.LocalPlayerShadow;
|
||||
_playerModeController = result.PlayerMode;
|
||||
_playerModeAutoEntry = result.PlayerModeAutoEntry;
|
||||
_localPlayerTeleport = result.LocalTeleport;
|
||||
_sessionPlayerBindings = result.RuntimeBindings;
|
||||
}
|
||||
|
||||
private static void PublishCompositionOwner<T>(
|
||||
ref T? destination,
|
||||
T value,
|
||||
|
|
@ -1139,9 +1178,6 @@ public sealed class GameWindow :
|
|||
Console.WriteLine),
|
||||
this).Compose(platform, contentEffectsAudio, settingsDevTools);
|
||||
string shadersDir = worldRender.Foundation.ShadersDirectory;
|
||||
TerrainAtlas terrainAtlas = worldRender.Foundation.TerrainAtlas;
|
||||
int centerX = worldRender.TerrainBuild.InitialCenterX;
|
||||
int centerY = worldRender.TerrainBuild.InitialCenterY;
|
||||
Console.WriteLine(
|
||||
$"loading world view centered on " +
|
||||
$"0x{worldRender.TerrainBuild.InitialCenterLandblockId:X8}");
|
||||
|
|
@ -1243,441 +1279,64 @@ public sealed class GameWindow :
|
|||
worldRender,
|
||||
interactionUi);
|
||||
|
||||
// Apply radii from the same immutable quality snapshot used for the
|
||||
// window's MSAA, terrain anisotropy, and dispatcher A2C setup.
|
||||
// Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat.
|
||||
_nearRadius = _runtimeSettings.ResolvedQuality.NearRadius;
|
||||
_farRadius = _runtimeSettings.ResolvedQuality.FarRadius;
|
||||
|
||||
// Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and
|
||||
// ensures farRadius >= streamRadius. Parsed once into
|
||||
// RuntimeOptions.LegacyStreamRadius (null when unset or invalid).
|
||||
if (_options.LegacyStreamRadius is { } sr)
|
||||
{
|
||||
_nearRadius = sr;
|
||||
_farRadius = System.Math.Max(sr, _farRadius);
|
||||
}
|
||||
Console.WriteLine(
|
||||
$"streaming: nearRadius={_nearRadius} (window={2 * _nearRadius + 1}x{2 * _nearRadius + 1})" +
|
||||
$" farRadius={_farRadius} (window={2 * _farRadius + 1}x{2 * _farRadius + 1})");
|
||||
|
||||
// Phase A.5 T11+: the streamer now runs on a dedicated worker thread.
|
||||
// loadLandblock acquires _datLock (T10) before touching DatCollection.
|
||||
// buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so
|
||||
// it can call LandblockMesh.Build without a dat read — _heightTable and
|
||||
// _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9).
|
||||
var landblockBuildFactory = new AcDream.App.Streaming.LandblockBuildFactory(
|
||||
_dats!,
|
||||
_datLock,
|
||||
_heightTable!,
|
||||
_physicsDataCache,
|
||||
_options.DumpSceneryZ);
|
||||
_streamer = AcDream.App.Streaming.LandblockStreamer.CreateForRequests(
|
||||
loadLandblock: landblockBuildFactory.Build,
|
||||
buildMeshOrNull: (id, lb) =>
|
||||
{
|
||||
if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null)
|
||||
return null;
|
||||
uint lbX = (id >> 24) & 0xFFu;
|
||||
uint lbY = (id >> 16) & 0xFFu;
|
||||
// _surfaceCache is ConcurrentDictionary (T9) — safe from worker thread.
|
||||
// _heightTable and _blendCtx are read-only after initialization.
|
||||
// lb.Heightmap is the pre-loaded LandBlock; no dat read needed here.
|
||||
return AcDream.Core.Terrain.LandblockMesh.Build(
|
||||
lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache);
|
||||
});
|
||||
_streamer.Start();
|
||||
|
||||
_streamingController = new AcDream.App.Streaming.StreamingController(
|
||||
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(
|
||||
new AcDream.App.Streaming.LandblockBuildRequest(
|
||||
id,
|
||||
kind,
|
||||
generation,
|
||||
new AcDream.App.Streaming.LandblockBuildOrigin(
|
||||
_liveCenterX,
|
||||
_liveCenterY))),
|
||||
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
|
||||
drainCompletions: _streamer.DrainCompletions,
|
||||
state: _worldState,
|
||||
nearRadius: _nearRadius,
|
||||
farRadius: _farRadius,
|
||||
clearPendingLoads: _streamer.ClearPendingLoads,
|
||||
// Retained-object recovery runs after each
|
||||
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
|
||||
// objects it still considers known.
|
||||
presentationPipeline: _landblockPresentationPipeline!);
|
||||
_streamingOriginRecenter =
|
||||
new AcDream.App.Streaming.StreamingOriginRecenterCoordinator(
|
||||
_streamingController,
|
||||
_liveWorldOrigin);
|
||||
// A.5 T22.5: apply max-completions from resolved quality.
|
||||
_streamingController.MaxCompletionsPerFrame =
|
||||
_runtimeSettings.ResolvedQuality.MaxCompletionsPerFrame;
|
||||
_runtimeSettings.BindRuntimeTargets(
|
||||
new RuntimeSettingsTargets(
|
||||
new SilkRuntimeDisplayWindowTarget(_window!),
|
||||
_wbDrawDispatcher!,
|
||||
terrainAtlas,
|
||||
_streamingController,
|
||||
_renderRange,
|
||||
_uiHost?.Root,
|
||||
Console.WriteLine));
|
||||
_worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
|
||||
isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident,
|
||||
isSpawnCellReady: _physicsEngine.IsSpawnCellReady,
|
||||
isTerrainNeighborhoodReady: _physicsEngine.IsNeighborhoodTerrainResident,
|
||||
areCompositeTexturesReady: () => _wbDrawDispatcher?.CompositeTexturesReady == true,
|
||||
prepareCompositeTextures: (destinationCell, radius) =>
|
||||
_wbDrawDispatcher?.PrepareCompositeTextures(
|
||||
_worldState.Entities,
|
||||
_worldState.FlatViewGeneration,
|
||||
destinationCell,
|
||||
radius),
|
||||
invalidateCompositeTextures: () =>
|
||||
_wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(),
|
||||
isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable,
|
||||
log: message => Console.WriteLine(message));
|
||||
|
||||
var networkUpdateBridge =
|
||||
new AcDream.App.World.DeferredLiveEntityNetworkUpdateSink();
|
||||
var sealedDungeonCells =
|
||||
new AcDream.App.Streaming.DatSealedDungeonCellClassifier(
|
||||
_dats!,
|
||||
_datLock);
|
||||
var projectionMaterializer = new DatLiveEntityProjectionMaterializer(
|
||||
_options,
|
||||
_dats!,
|
||||
_liveEntities!,
|
||||
_physicsDataCache,
|
||||
_animLoader!,
|
||||
_wbEntitySpawnAdapter!,
|
||||
_textureCache!,
|
||||
_classificationCache,
|
||||
_effectPoses,
|
||||
_equippedChildRenderer!,
|
||||
_worldGameState,
|
||||
_worldEvents,
|
||||
_physicsEngine.ShadowObjects,
|
||||
_liveEntityCollisionBuilder!,
|
||||
_projectileController!,
|
||||
_animationPresenter,
|
||||
_staticAnimationScheduler!,
|
||||
_liveWorldOrigin,
|
||||
_updateFrameClock);
|
||||
var originCoordinator =
|
||||
new AcDream.App.World.LiveEntityWorldOriginCoordinator(
|
||||
_liveWorldOrigin,
|
||||
_streamingController,
|
||||
_worldState,
|
||||
_worldReveal,
|
||||
_localPlayerIdentity,
|
||||
sealedDungeonCells,
|
||||
Console.WriteLine);
|
||||
_liveSessionController = new AcDream.App.Net.LiveSessionController();
|
||||
interactionUi.LateBindings.AdoptLateOwnerBinding(
|
||||
"live session",
|
||||
interactionUi.LateBindings.Session.Bind(_liveSessionController));
|
||||
var localPhysicsTimestamps =
|
||||
new AcDream.App.World.LiveSessionLocalPhysicsTimestampPublisher(
|
||||
_localPlayerIdentity,
|
||||
_liveSessionController);
|
||||
var liveEntityTeardown =
|
||||
new AcDream.App.World.LiveEntityRuntimeTeardownController(
|
||||
livePresentation.LiveEntities,
|
||||
_liveEntityPresentation!,
|
||||
_entityEffects!,
|
||||
_remoteTeleportController!,
|
||||
_selectionInteractions,
|
||||
_selection,
|
||||
_animatedEntities,
|
||||
_remoteMovementObservations,
|
||||
_translucencyFades,
|
||||
_liveEntityProjectionWithdrawal!,
|
||||
_equippedChildRenderer!,
|
||||
_physicsEngine.ShadowObjects,
|
||||
_liveEntityLights!,
|
||||
_classificationCache,
|
||||
_localPlayerIdentity);
|
||||
livePresentation.ComponentLifecycle.Bind(liveEntityTeardown);
|
||||
var liveEntityDeletion =
|
||||
new AcDream.App.World.LiveEntityDeletionController(
|
||||
livePresentation.LiveEntities,
|
||||
Objects,
|
||||
liveEntityTeardown,
|
||||
_localPlayerIdentity,
|
||||
_options.DumpLiveSpawns ? Console.WriteLine : null);
|
||||
_liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController(
|
||||
livePresentation.LiveEntities,
|
||||
Objects,
|
||||
_datLock,
|
||||
projectionMaterializer,
|
||||
new AcDream.App.World.LiveEntityRelationshipProjection(
|
||||
livePresentation.EquippedChildren),
|
||||
new AcDream.App.World.LiveEntityReadyPublisher(
|
||||
livePresentation.LiveEntities,
|
||||
_entityEffects!,
|
||||
_liveEntityPresentation!),
|
||||
originCoordinator,
|
||||
networkUpdateBridge,
|
||||
localPhysicsTimestamps,
|
||||
_localPlayerIdentity,
|
||||
liveEntityDeletion,
|
||||
_options.DumpLiveSpawns ? Console.WriteLine : null);
|
||||
livePresentation.RuntimeBindings.Adopt(
|
||||
"landblock-loaded hydration",
|
||||
livePresentation.LandblockLoaded.Bind(_liveEntityHydration));
|
||||
_liveEntityNetworkUpdates =
|
||||
new AcDream.App.Physics.LiveEntityNetworkUpdateController(
|
||||
livePresentation.LiveEntities,
|
||||
Objects,
|
||||
_liveEntityHydration,
|
||||
_entityEffects!,
|
||||
_liveEntityPresentation!,
|
||||
_liveEntityLights!,
|
||||
_equippedChildRenderer!,
|
||||
_projectileController!,
|
||||
_remoteTeleportController!,
|
||||
_animatedEntities,
|
||||
new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(
|
||||
_liveEntityRuntimeSlot),
|
||||
_remoteMovementObservations,
|
||||
_remotePhysicsUpdater,
|
||||
_remoteInboundMotion,
|
||||
livePresentation.MotionRuntime,
|
||||
_physicsEngine,
|
||||
_dats!,
|
||||
_animLoader!,
|
||||
_combatTargetController,
|
||||
_liveWorldOrigin,
|
||||
_localPlayerTeleportSink,
|
||||
_playerControllerSlot,
|
||||
_localPlayerOutbound,
|
||||
_playerHostSlot,
|
||||
_localPlayerIdentity,
|
||||
_updateFrameClock,
|
||||
_liveSessionController,
|
||||
localPhysicsTimestamps.Publish,
|
||||
_movementTruthDiagnostics);
|
||||
_liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
|
||||
livePresentation.LiveEntities,
|
||||
_localPlayerIdentity,
|
||||
liveEntityDeletion);
|
||||
_liveEntitySessionEvents = new AcDream.App.Net.LiveEntitySessionController(
|
||||
_inboundEntityEvents,
|
||||
_liveEntityHydration,
|
||||
_liveEntityNetworkUpdates,
|
||||
_localPlayerTeleportSink,
|
||||
_entityEffects!);
|
||||
livePresentation.ParentAcceptance.Bind(
|
||||
_liveEntityHydration.TryAcceptParentForProjection);
|
||||
networkUpdateBridge.Bind(_liveEntityNetworkUpdates);
|
||||
livePresentation.EquippedChildren.EntityReady += candidate =>
|
||||
_liveEntityHydration.OnEntityReady(candidate);
|
||||
_liveEntityHydration.AppearanceApplied += guid =>
|
||||
{
|
||||
if (guid == _playerServerGuid)
|
||||
_paperdollFramePresenter?.MarkDirty();
|
||||
};
|
||||
|
||||
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
|
||||
// enter the world as the first character on the account, and stream
|
||||
// CreateObject messages into _worldGameState as they arrive. Entirely
|
||||
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
|
||||
_liveWorldOrigin.SetPlaceholder(centerX, centerY);
|
||||
_combatAttackOperations.Bind(
|
||||
new AcDream.App.Combat.LiveCombatAttackOperations(
|
||||
Combat,
|
||||
new AcDream.App.Combat.CombatAttackTargetSource(
|
||||
_selection,
|
||||
_liveEntities!,
|
||||
Objects,
|
||||
_localPlayerIdentity),
|
||||
_runtimeSettings,
|
||||
_playerControllerSlot,
|
||||
_localPlayerOutbound,
|
||||
_liveSessionController,
|
||||
_liveSessionController,
|
||||
_combatFeedback));
|
||||
AcDream.App.Input.MouseLookController? mouseLookController =
|
||||
_mouseSource is not null && _mouseLookCursor is not null
|
||||
? new AcDream.App.Input.MouseLookController(
|
||||
_mouseSource,
|
||||
_pointerPosition,
|
||||
_localPlayerMode,
|
||||
_playerControllerSlot,
|
||||
_cameraController!,
|
||||
_chaseCameraInput,
|
||||
_movementInput,
|
||||
_localPlayerOutbound,
|
||||
_liveSessionController,
|
||||
_mouseLookCursor,
|
||||
new AcDream.App.Input.EnvironmentInputMonotonicClock())
|
||||
: null;
|
||||
_gameplayInputFrame = new AcDream.App.Input.GameplayInputFrameController(
|
||||
_inputDispatcher,
|
||||
_movementInput,
|
||||
mouseLookController,
|
||||
new AcDream.App.Input.CombatAttackInputFrameAdapter(
|
||||
_combatAttackController!));
|
||||
_cameraPointerInput?.BindGameplayFrame(_gameplayInputFrame);
|
||||
var streamingFrame = new AcDream.App.Streaming.StreamingFrameController(
|
||||
_options.LiveMode,
|
||||
_localPlayerMode,
|
||||
_playerControllerSlot,
|
||||
_liveSessionController,
|
||||
_liveWorldOrigin,
|
||||
_liveEntityNetworkUpdates!,
|
||||
new AcDream.App.Streaming.FlyCameraStreamingObserverSource(
|
||||
_cameraController!),
|
||||
new AcDream.App.Streaming.PhysicsStreamingDungeonCellSource(
|
||||
_physicsEngine),
|
||||
_streamingOriginRecenter!,
|
||||
_streamingController!,
|
||||
new AcDream.App.Streaming.LiveProjectionRescueRebucketter(
|
||||
_worldState,
|
||||
livePresentation.LiveEntities));
|
||||
var liveEffectFrame = new AcDream.App.Update.LiveEffectFrameController(
|
||||
_translucencyFades,
|
||||
_animationHookFrames!,
|
||||
_entityEffects!,
|
||||
_particleSink!,
|
||||
_liveEntityLights!,
|
||||
_particleVisibility,
|
||||
_particleSystem!,
|
||||
_scriptRunner!,
|
||||
_updateFrameClock,
|
||||
new AcDream.App.Update.SettingsParticleRangeSource(
|
||||
_runtimeSettings));
|
||||
var liveSpatialReconciler =
|
||||
new AcDream.App.Update.LiveSpatialPresentationReconciler(
|
||||
_entityEffects!,
|
||||
_equippedChildRenderer!,
|
||||
_particleSink!,
|
||||
_liveEntityLights!);
|
||||
_localPlayerAnimation =
|
||||
new AcDream.App.Input.LocalPlayerAnimationController(
|
||||
livePresentation.LiveEntities,
|
||||
_localPlayerIdentity,
|
||||
_animatedEntities,
|
||||
_animationPresenter,
|
||||
_animationHookFrames!);
|
||||
_localPlayerShadowSynchronizer =
|
||||
new AcDream.App.Physics.LocalPlayerShadowSynchronizer(
|
||||
_physicsEngine,
|
||||
livePresentation.LiveEntities,
|
||||
_localPlayerIdentity,
|
||||
_liveWorldOrigin,
|
||||
_localPlayerShadow);
|
||||
var localPlayerProjection =
|
||||
new AcDream.App.Input.LocalPlayerProjectionController(
|
||||
new AcDream.App.Input.LiveLocalPlayerProjectionRuntime(
|
||||
livePresentation.LiveEntities,
|
||||
_localPlayerIdentity,
|
||||
_liveWorldOrigin,
|
||||
_localPlayerShadowSynchronizer));
|
||||
var localPlayerFrameRuntime =
|
||||
new AcDream.App.Input.LiveLocalPlayerFrameRuntime(
|
||||
_cameraController!,
|
||||
_localPlayerMode,
|
||||
_playerControllerSlot,
|
||||
_chaseCameraInput,
|
||||
_movementInput,
|
||||
_inputCapture,
|
||||
livePresentation.LiveEntities,
|
||||
_localPlayerIdentity,
|
||||
_playerHostSlot,
|
||||
localPlayerProjection,
|
||||
_localPlayerOutbound,
|
||||
_liveSessionController);
|
||||
var localPlayerFrame =
|
||||
new AcDream.App.Input.RetailLocalPlayerFrameController(
|
||||
localPlayerFrameRuntime,
|
||||
_movementInput);
|
||||
var liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController(
|
||||
_inboundEntityEvents,
|
||||
localPlayerFrame,
|
||||
_selectionInteractions,
|
||||
livePresentation.LiveEntities,
|
||||
_localPlayerIdentity,
|
||||
_liveWorldOrigin,
|
||||
_liveAnimationScheduler,
|
||||
livePresentation.StaticAnimationScheduler,
|
||||
_animationPresenter,
|
||||
_animatedEntities,
|
||||
_equippedChildRenderer!,
|
||||
liveEffectFrame);
|
||||
_playerModeController = new AcDream.App.Input.PlayerModeController(
|
||||
_localPlayerMode,
|
||||
_playerControllerSlot,
|
||||
_playerHostSlot,
|
||||
_chaseCameraInput,
|
||||
_cameraController!,
|
||||
_physicsEngine,
|
||||
livePresentation.LiveEntities,
|
||||
_localPlayerIdentity,
|
||||
_liveWorldOrigin,
|
||||
_liveEntityMotionBindings,
|
||||
_dats!,
|
||||
_datLock,
|
||||
_physicsDataCache,
|
||||
_animatedEntities,
|
||||
_localPlayerAnimation,
|
||||
_localPlayerShadowSynchronizer,
|
||||
_playerApproachCompletions,
|
||||
_gameplayInputFrame,
|
||||
_liveSessionController,
|
||||
_movementTruthDiagnostics,
|
||||
_localPlayerSkills,
|
||||
_viewportAspect);
|
||||
_devToolsComposition?.LateBindings.PlayerModeCommands.Bind(
|
||||
_playerModeController);
|
||||
_devToolsCommandBus?.Bind(_liveSessionController);
|
||||
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
|
||||
new AcDream.App.Input.LivePlayerModeAutoEntryContext(
|
||||
_liveSessionController,
|
||||
livePresentation.LiveEntities,
|
||||
_localPlayerIdentity,
|
||||
_worldReveal
|
||||
?? throw new InvalidOperationException(
|
||||
"World reveal was not composed before player auto-entry."),
|
||||
_localPlayerMode,
|
||||
_playerModeController));
|
||||
_playerModeController.BindAutoEntry(_playerModeAutoEntry);
|
||||
_localPlayerTeleport = _portalTunnelFallback.Transfer(
|
||||
portalTunnel => new AcDream.App.Streaming.LocalPlayerTeleportController(
|
||||
new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority(
|
||||
livePresentation.LiveEntities,
|
||||
_localPlayerIdentity),
|
||||
_gameplayInputFrame,
|
||||
_playerModeController,
|
||||
new AcDream.App.Streaming.LocalPlayerTeleportStreamingOperations(
|
||||
_liveWorldOrigin,
|
||||
_streamingOriginRecenter!,
|
||||
_streamingController!,
|
||||
sealedDungeonCells),
|
||||
_worldReveal,
|
||||
new AcDream.App.Streaming.LocalPlayerTeleportPlacement(
|
||||
SessionPlayerResult sessionPlayer =
|
||||
new SessionPlayerCompositionPhase(
|
||||
new SessionPlayerDependencies(
|
||||
_options,
|
||||
_window!,
|
||||
_datLock,
|
||||
_runtimeSettings,
|
||||
settingsDevTools,
|
||||
_physicsEngine,
|
||||
livePresentation.LiveEntities,
|
||||
_physicsDataCache,
|
||||
_worldGameState,
|
||||
_worldEvents,
|
||||
_selection,
|
||||
Objects,
|
||||
_classificationCache,
|
||||
_liveEntityRuntimeSlot,
|
||||
_animatedEntities,
|
||||
_remoteMovementObservations,
|
||||
_remotePhysicsUpdater,
|
||||
_remoteInboundMotion,
|
||||
_inboundEntityEvents,
|
||||
_liveEntityMotionBindings,
|
||||
_localPlayerIdentity,
|
||||
_playerControllerSlot,
|
||||
_playerHostSlot,
|
||||
_localPlayerMode,
|
||||
_chaseCameraInput,
|
||||
_localPlayerOutbound,
|
||||
_localPlayerTeleportSink,
|
||||
_liveWorldOrigin,
|
||||
liveSpatialReconciler),
|
||||
new AcDream.App.Streaming.LocalPlayerTeleportSession(
|
||||
_liveSessionController),
|
||||
new AcDream.App.Streaming.LocalPlayerTeleportPresentation(
|
||||
portalTunnel)));
|
||||
_localPlayerTeleportSink.Bind(_localPlayerTeleport);
|
||||
interactionUi.LateBindings.AdoptLateOwnerBinding(
|
||||
"selection view plane",
|
||||
interactionUi.LateBindings.SelectionViewPlane.Bind(
|
||||
_localPlayerTeleport));
|
||||
_renderRange,
|
||||
_localPlayerShadow,
|
||||
_localPlayerSkills,
|
||||
_viewportAspect,
|
||||
_playerApproachCompletions,
|
||||
_pointerPosition,
|
||||
_movementInput,
|
||||
_inputCapture,
|
||||
_particleVisibility,
|
||||
_translucencyFades,
|
||||
_effectPoses,
|
||||
_updateFrameClock,
|
||||
_movementTruthDiagnostics,
|
||||
_combatAttackOperations,
|
||||
Combat,
|
||||
_combatFeedback,
|
||||
_portalTunnelFallback,
|
||||
Console.WriteLine),
|
||||
this).Compose(
|
||||
hostInputCamera,
|
||||
contentEffectsAudio,
|
||||
worldRender,
|
||||
interactionUi,
|
||||
livePresentation);
|
||||
|
||||
var teleportRenderState =
|
||||
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
|
||||
_localPlayerTeleport);
|
||||
sessionPlayer.LocalTeleport);
|
||||
var renderLoginState = new AcDream.App.Rendering.RenderLoginStateSource(
|
||||
_options.LiveMode,
|
||||
_localPlayerMode);
|
||||
|
|
@ -1743,7 +1402,7 @@ public sealed class GameWindow :
|
|||
new AcDream.App.Rendering.WorldRenderFrameBuilder(
|
||||
new AcDream.App.Rendering.RuntimeWorldFrameCameraSource(
|
||||
_cameraController!,
|
||||
_localPlayerTeleport),
|
||||
sessionPlayer.LocalTeleport),
|
||||
new AcDream.App.Rendering.RuntimeWorldFrameVisibilityPreparation(
|
||||
_retailSelectionScene,
|
||||
_particleVisibility,
|
||||
|
|
@ -1876,7 +1535,7 @@ public sealed class GameWindow :
|
|||
var privatePresentation =
|
||||
new AcDream.App.Rendering.PrivatePresentationRenderer(
|
||||
new AcDream.App.Rendering.LocalPlayerPortalViewport(
|
||||
_localPlayerTeleport!,
|
||||
sessionPlayer.LocalTeleport,
|
||||
_cameraController!),
|
||||
renderFrameResources,
|
||||
_paperdollFramePresenter,
|
||||
|
|
@ -1899,19 +1558,19 @@ public sealed class GameWindow :
|
|||
_devToolsFramePresenter
|
||||
?? AcDream.App.Rendering.NullRenderFrameFailureRecovery.Instance);
|
||||
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
|
||||
liveObjectFrame,
|
||||
sessionPlayer.LiveObjectFrame,
|
||||
_worldState,
|
||||
_liveSessionController,
|
||||
localPlayerFrame,
|
||||
liveSpatialReconciler);
|
||||
sessionPlayer.LiveSession,
|
||||
sessionPlayer.LocalPlayerFrame,
|
||||
sessionPlayer.LiveSpatialReconciler);
|
||||
var cameraFrame = new AcDream.App.Rendering.CameraFrameController(
|
||||
_cameraController!,
|
||||
_inputCapture,
|
||||
_cameraInput,
|
||||
localPlayerFrameRuntime,
|
||||
sessionPlayer.LocalPlayerFrameRuntime,
|
||||
_chaseCameraInput,
|
||||
localPlayerFrame,
|
||||
liveSpatialReconciler,
|
||||
sessionPlayer.LocalPlayerFrame,
|
||||
sessionPlayer.LiveSpatialReconciler,
|
||||
new AcDream.App.Combat.CombatCameraTargetSource(
|
||||
_runtimeSettings,
|
||||
Combat,
|
||||
|
|
@ -1923,15 +1582,15 @@ public sealed class GameWindow :
|
|||
new AcDream.App.Update.ConsoleUpdateFrameFailureSink(),
|
||||
_updateFrameClock,
|
||||
new AcDream.App.Update.PhysicsScriptClockPublisher(_scriptRunner!),
|
||||
streamingFrame,
|
||||
_gameplayInputFrame,
|
||||
sessionPlayer.StreamingFrame,
|
||||
sessionPlayer.GameplayInput,
|
||||
liveFrameCoordinator,
|
||||
new AcDream.App.Update.LiveEntityLivenessFramePhase(
|
||||
_liveEntityLiveness,
|
||||
sessionPlayer.Liveness,
|
||||
new AcDream.App.Update.StopwatchClientMonotonicTimeSource()),
|
||||
_localPlayerTeleport,
|
||||
sessionPlayer.LocalTeleport,
|
||||
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
|
||||
_playerModeAutoEntry),
|
||||
sessionPlayer.PlayerModeAutoEntry),
|
||||
cameraFrame);
|
||||
_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);
|
||||
_liveSessionHost = CreateLiveSessionHost();
|
||||
|
|
@ -1988,7 +1647,7 @@ public sealed class GameWindow :
|
|||
runtimeDiagnostics,
|
||||
new AcDream.App.Input.PlayerModeGameplayCommands(
|
||||
_localPlayerMode,
|
||||
_playerModeController),
|
||||
sessionPlayer.PlayerMode),
|
||||
new AcDream.App.Input.ItemTargetModeCommands(
|
||||
_itemInteractionController!),
|
||||
new AcDream.App.Input.GameplayCameraModeCommands(
|
||||
|
|
@ -1996,7 +1655,7 @@ public sealed class GameWindow :
|
|||
combatCommand,
|
||||
new AcDream.App.Input.GameplayWindowCommands(_window!.Close));
|
||||
var targets = new AcDream.App.Input.RuntimeGameplayInputPriorityTargets(
|
||||
_gameplayInputFrame!,
|
||||
sessionPlayer.GameplayInput,
|
||||
pointer,
|
||||
_retailUiRuntime,
|
||||
_selectionInteractions,
|
||||
|
|
@ -2095,7 +1754,7 @@ public sealed class GameWindow :
|
|||
private void ResetSessionPlayerPresentation()
|
||||
{
|
||||
_playerModeController?.ResetSession();
|
||||
_spawnClaimRangeMemo = null;
|
||||
_spawnClaimHydration?.Reset();
|
||||
}
|
||||
|
||||
private void ResetSessionIdentity()
|
||||
|
|
@ -2157,7 +1816,10 @@ public sealed class GameWindow :
|
|||
|
||||
return new AcDream.App.Net.LiveSessionEventRouter(
|
||||
session,
|
||||
_liveEntitySessionEvents.CreateSink(),
|
||||
(_liveEntitySessionEvents
|
||||
?? throw new InvalidOperationException(
|
||||
"Live entity session routing was not composed."))
|
||||
.CreateSink(),
|
||||
new AcDream.App.Net.LiveEnvironmentSessionSink(
|
||||
_worldEnvironment.ApplyAdminEnvirons,
|
||||
_worldEnvironment.SynchronizeFromServer),
|
||||
|
|
@ -2392,34 +2054,6 @@ public sealed class GameWindow :
|
|||
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
|
||||
=> _framebufferResize.Resize(newSize);
|
||||
|
||||
// #107 (2026-06-10): memoized "this indoor spawn claim can never hydrate"
|
||||
// check against the dat's LandBlockInfo.NumCells. Used by the auto-entry
|
||||
// hold so a garbage claim doesn't stall login forever; the Resolve-head
|
||||
// safety net demotes it loudly once entry proceeds.
|
||||
private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;
|
||||
|
||||
private bool IsSpawnClaimUnhydratable(uint claim)
|
||||
{
|
||||
if ((claim & 0xFFFFu) < 0x0100u) return false;
|
||||
if (_spawnClaimRangeMemo is { } m && m.Claim == claim) return m.Unhydratable;
|
||||
|
||||
bool unhydratable = false;
|
||||
if (_dats is not null)
|
||||
{
|
||||
DatReaderWriter.DBObjs.LandBlockInfo? lbInfo;
|
||||
lock (_datLock)
|
||||
{
|
||||
lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
|
||||
(claim & 0xFFFF0000u) | 0xFFFEu);
|
||||
}
|
||||
uint low = claim & 0xFFFFu;
|
||||
unhydratable = lbInfo is null || lbInfo.NumCells == 0
|
||||
|| low >= 0x0100u + lbInfo.NumCells;
|
||||
}
|
||||
_spawnClaimRangeMemo = (claim, unhydratable);
|
||||
return unhydratable;
|
||||
}
|
||||
|
||||
private void OnClosing()
|
||||
{
|
||||
_hostQuiescence.StopAccepting();
|
||||
|
|
@ -2565,11 +2199,18 @@ public sealed class GameWindow :
|
|||
// the later render-frontend stage still disposes the GL owners.
|
||||
new ResourceShutdownStage("frame borrowers",
|
||||
[
|
||||
new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets),
|
||||
new("world frame composition", () =>
|
||||
{
|
||||
_frameGraphs.Withdraw();
|
||||
}),
|
||||
new("session/player bindings", () =>
|
||||
{
|
||||
SessionPlayerRuntimeBindings? bindings = _sessionPlayerBindings;
|
||||
if (bindings is null)
|
||||
return;
|
||||
bindings.Dispose();
|
||||
_sessionPlayerBindings = null;
|
||||
}),
|
||||
]),
|
||||
new ResourceShutdownStage("session dependents",
|
||||
[
|
||||
|
|
@ -2587,8 +2228,6 @@ public sealed class GameWindow :
|
|||
_cameraPointerInput;
|
||||
if (pointer is null) return;
|
||||
pointer.ReleaseMouseLookAfterSessionRetirement();
|
||||
if (_gameplayInputFrame is { } gameplayFrame)
|
||||
pointer.UnbindGameplayFrame(gameplayFrame);
|
||||
_cameraPointerInput = null;
|
||||
}),
|
||||
new("retail UI", () =>
|
||||
|
|
|
|||
|
|
@ -246,8 +246,38 @@ internal sealed class RuntimeSettingsController :
|
|||
_runtimeTargets = targets;
|
||||
}
|
||||
|
||||
public IDisposable BindRuntimeTargetsOwned(IRuntimeSettingsTargets targets)
|
||||
{
|
||||
BindRuntimeTargets(targets);
|
||||
return new RuntimeTargetBinding(this, targets);
|
||||
}
|
||||
|
||||
private void UnbindRuntimeTargets(IRuntimeSettingsTargets expected)
|
||||
{
|
||||
if (ReferenceEquals(_runtimeTargets, expected))
|
||||
_runtimeTargets = null;
|
||||
}
|
||||
|
||||
public void UnbindRuntimeTargets() => _runtimeTargets = null;
|
||||
|
||||
private sealed class RuntimeTargetBinding : IDisposable
|
||||
{
|
||||
private RuntimeSettingsController? _owner;
|
||||
private readonly IRuntimeSettingsTargets _expected;
|
||||
|
||||
public RuntimeTargetBinding(
|
||||
RuntimeSettingsController owner,
|
||||
IRuntimeSettingsTargets expected)
|
||||
{
|
||||
_owner = owner;
|
||||
_expected = expected;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _owner, null)?
|
||||
.UnbindRuntimeTargets(_expected);
|
||||
}
|
||||
|
||||
public SettingsVM CreateViewModel(
|
||||
KeyBindings persistedBindings,
|
||||
InputDispatcher dispatcher,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,17 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
|
|||
throw new InvalidOperationException("The local teleport sink is already bound.");
|
||||
}
|
||||
|
||||
public IDisposable BindOwned(ILocalPlayerTeleportNetworkSink inner)
|
||||
{
|
||||
Bind(inner);
|
||||
return new Binding(this, inner);
|
||||
}
|
||||
|
||||
private void Unbind(ILocalPlayerTeleportNetworkSink expected)
|
||||
{
|
||||
_ = Interlocked.CompareExchange(ref _inner, null, expected);
|
||||
}
|
||||
|
||||
public void OnTeleportStarted(uint sequence) => Required().OnTeleportStarted(sequence);
|
||||
|
||||
public void OfferDestination(
|
||||
|
|
@ -53,6 +64,23 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
|
|||
private ILocalPlayerTeleportNetworkSink Required() =>
|
||||
_inner ?? throw new InvalidOperationException(
|
||||
"The local teleport sink was used before composition completed.");
|
||||
|
||||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private DeferredLocalPlayerTeleportNetworkSink? _owner;
|
||||
private readonly ILocalPlayerTeleportNetworkSink _expected;
|
||||
|
||||
public Binding(
|
||||
DeferredLocalPlayerTeleportNetworkSink owner,
|
||||
ILocalPlayerTeleportNetworkSink expected)
|
||||
{
|
||||
_owner = owner;
|
||||
_expected = expected;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
|
||||
}
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerTeleportInputLifetime
|
||||
|
|
|
|||
|
|
@ -103,10 +103,38 @@ internal sealed class DeferredLiveEntityNetworkUpdateSink : ILiveEntityNetworkUp
|
|||
throw new InvalidOperationException("The live-entity network sink is already bound.");
|
||||
}
|
||||
|
||||
public IDisposable BindOwned(ILiveEntityNetworkUpdateSink inner)
|
||||
{
|
||||
Bind(inner);
|
||||
return new Binding(this, inner);
|
||||
}
|
||||
|
||||
private void Unbind(ILiveEntityNetworkUpdateSink expected)
|
||||
{
|
||||
_ = Interlocked.CompareExchange(ref _inner, null, expected);
|
||||
}
|
||||
|
||||
public void ApplySameGeneration(SameGenerationCreateObjectEvents events) =>
|
||||
(_inner ?? throw new InvalidOperationException(
|
||||
"The live-entity network sink must be bound before a session starts."))
|
||||
.ApplySameGeneration(events);
|
||||
|
||||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private DeferredLiveEntityNetworkUpdateSink? _owner;
|
||||
private readonly ILiveEntityNetworkUpdateSink _expected;
|
||||
|
||||
public Binding(
|
||||
DeferredLiveEntityNetworkUpdateSink owner,
|
||||
ILiveEntityNetworkUpdateSink expected)
|
||||
{
|
||||
_owner = owner;
|
||||
_expected = expected;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DelegateLiveEntityNetworkUpdateSink(
|
||||
|
|
|
|||
|
|
@ -41,9 +41,37 @@ internal sealed class DeferredLiveEntityParentAcceptance
|
|||
throw new InvalidOperationException("Live parent acceptance is already bound.");
|
||||
}
|
||||
|
||||
public IDisposable BindOwned(Func<ParentEvent.Parsed, bool> accept)
|
||||
{
|
||||
Bind(accept);
|
||||
return new Binding(this, accept);
|
||||
}
|
||||
|
||||
private void Unbind(Func<ParentEvent.Parsed, bool> expected)
|
||||
{
|
||||
_ = Interlocked.CompareExchange(ref _accept, null, expected);
|
||||
}
|
||||
|
||||
public bool TryAccept(ParentEvent.Parsed update) =>
|
||||
(_accept ?? throw new InvalidOperationException(
|
||||
"Live parent acceptance must be bound before a session starts."))(update);
|
||||
|
||||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private DeferredLiveEntityParentAcceptance? _owner;
|
||||
private readonly Func<ParentEvent.Parsed, bool> _expected;
|
||||
|
||||
public Binding(
|
||||
DeferredLiveEntityParentAcceptance owner,
|
||||
Func<ParentEvent.Parsed, bool> expected)
|
||||
{
|
||||
_owner = owner;
|
||||
_expected = expected;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
|
||||
|
|
|
|||
|
|
@ -152,6 +152,17 @@ internal sealed class DeferredLiveEntityRuntimeComponentLifecycle :
|
|||
throw new InvalidOperationException("The live-entity runtime component lifecycle is already bound.");
|
||||
}
|
||||
|
||||
public IDisposable BindOwned(ILiveEntityRuntimeComponentLifecycle lifecycle)
|
||||
{
|
||||
Bind(lifecycle);
|
||||
return new Binding(this, lifecycle);
|
||||
}
|
||||
|
||||
private void Unbind(ILiveEntityRuntimeComponentLifecycle expected)
|
||||
{
|
||||
_ = Interlocked.CompareExchange(ref _inner, null, expected);
|
||||
}
|
||||
|
||||
public void TearDown(LiveEntityRecord record)
|
||||
{
|
||||
ILiveEntityRuntimeComponentLifecycle lifecycle = Volatile.Read(ref _inner)
|
||||
|
|
@ -159,6 +170,23 @@ internal sealed class DeferredLiveEntityRuntimeComponentLifecycle :
|
|||
"The live-entity runtime component lifecycle has not been bound.");
|
||||
lifecycle.TearDown(record);
|
||||
}
|
||||
|
||||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private DeferredLiveEntityRuntimeComponentLifecycle? _owner;
|
||||
private readonly ILiveEntityRuntimeComponentLifecycle _expected;
|
||||
|
||||
public Binding(
|
||||
DeferredLiveEntityRuntimeComponentLifecycle owner,
|
||||
ILiveEntityRuntimeComponentLifecycle expected)
|
||||
{
|
||||
_owner = owner;
|
||||
_expected = expected;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Delegate adapter used by the App composition root.</summary>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,23 @@ public sealed class LiveCombatAttackOperationsTests
|
|||
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]
|
||||
public void CanStartRequiresLiveWorldBeforeTargetResolution()
|
||||
{
|
||||
|
|
@ -137,7 +154,8 @@ public sealed class LiveCombatAttackOperationsTests
|
|||
|
||||
private sealed class FakeOperations : ICombatAttackOperations
|
||||
{
|
||||
public bool CanStartAttack() => true;
|
||||
public bool CanStartValue { get; init; } = true;
|
||||
public bool CanStartAttack() => CanStartValue;
|
||||
public void PrepareAttackRequest()
|
||||
{
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
|
|
@ -8,6 +9,24 @@ namespace AcDream.App.Tests.Rendering;
|
|||
|
||||
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]
|
||||
public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -61,6 +61,25 @@ public sealed class DevToolsRuntimeSourcesTests
|
|||
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]
|
||||
public void WorldCountSlotCannotFreezeOrReleaseTheWrongWorld()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -39,14 +39,24 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
[Fact]
|
||||
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();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
phase,
|
||||
"d.PortalTunnelFallback.Transfer(",
|
||||
"new LocalPlayerTeleportController(",
|
||||
"new LocalPlayerTeleportPresentation(portalTunnel)",
|
||||
"d.TeleportSink.BindOwned(localTeleport)",
|
||||
"_publication.PublishSessionPlayer(result);");
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_portalTunnelFallback.Transfer(",
|
||||
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
|
||||
"new AcDream.App.Streaming.LocalPlayerTeleportPresentation(",
|
||||
"_localPlayerTeleportSink.Bind(_localPlayerTeleport);",
|
||||
"new SessionPlayerCompositionPhase(",
|
||||
"new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(",
|
||||
"new AcDream.App.Rendering.RenderFrameResourceController(",
|
||||
"new AcDream.App.Rendering.PrivatePresentationRenderer(",
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"private AcDream.App.Net.LiveSessionHost");
|
||||
string body = MethodBody(
|
||||
"private void OnFramebufferResize(",
|
||||
"private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;");
|
||||
"private void OnClosing()");
|
||||
|
||||
Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal);
|
||||
|
|
@ -294,8 +294,18 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"new SettingsDevToolsCompositionPhase(",
|
||||
"this).Compose(platform, hostInputCamera, contentEffectsAudio);",
|
||||
"new WorldRenderCompositionPhase(",
|
||||
"_runtimeSettings.BindRuntimeTargets(",
|
||||
"new SessionPlayerCompositionPhase(",
|
||||
"_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(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
|
|
@ -320,7 +330,8 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
AssertAppearsInOrder(
|
||||
shutdown,
|
||||
"_runtimeSettings.UnbindViewModel()",
|
||||
"_runtimeSettings.UnbindRuntimeTargets",
|
||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
"bindings.Dispose();",
|
||||
"_retailUiLease.Dispose()",
|
||||
"_streamer?.Dispose()",
|
||||
"_wbDrawDispatcher?.Dispose()",
|
||||
|
|
@ -425,7 +436,6 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"pointer.Dispose();",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"pointer.ReleaseMouseLookAfterSessionRetirement();",
|
||||
"pointer.UnbindGameplayFrame(gameplayFrame);",
|
||||
"_cameraPointerInput = null;");
|
||||
AssertAppearsInOrder(
|
||||
dispose,
|
||||
|
|
@ -472,6 +482,12 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"Composition",
|
||||
"WorldRenderComposition.cs"));
|
||||
string livePhase = LivePresentationSource();
|
||||
string sessionPhase = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Composition",
|
||||
"SessionPlayerComposition.cs"));
|
||||
|
||||
AssertAppearsInOrder(
|
||||
livePhase,
|
||||
|
|
@ -483,8 +499,13 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
load,
|
||||
"new WorldRenderCompositionPhase(",
|
||||
"new LivePresentationCompositionPhase(",
|
||||
"_portalTunnelFallback.Transfer(",
|
||||
"new SessionPlayerCompositionPhase(",
|
||||
"_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);");
|
||||
AssertAppearsInOrder(
|
||||
sessionPhase,
|
||||
"d.PortalTunnelFallback.Transfer(",
|
||||
"new LocalPlayerTeleportController(",
|
||||
"_publication.PublishSessionPlayer(result);");
|
||||
AssertAppearsInOrder(
|
||||
worldPhase,
|
||||
"lifetime.AcquireTerrainAtlas(",
|
||||
|
|
|
|||
|
|
@ -524,6 +524,27 @@ public sealed class RuntimeSettingsControllerTests
|
|||
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]
|
||||
public void DisplayPersistenceFailureDoesNotPublishStateOrTargets()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,25 @@ namespace AcDream.App.Tests.Streaming;
|
|||
|
||||
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]
|
||||
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 readonly List<string> _order;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,29 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests
|
|||
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]
|
||||
public async Task ConcurrentBind_HasOneWinnerAndPublishesThatOwner()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -32,6 +32,24 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
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]
|
||||
public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization()
|
||||
{
|
||||
|
|
@ -1580,6 +1598,25 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
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 Cell = 0x01010001u;
|
||||
|
||||
|
|
|
|||
|
|
@ -343,9 +343,15 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
"AcDream.App",
|
||||
"Composition",
|
||||
"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 AcDream.App.Update.LiveSpatialPresentationReconciler(", source);
|
||||
Assert.Contains("new LiveObjectFrameController(", sessionPlayer);
|
||||
Assert.Contains("new LiveSpatialPresentationReconciler(", sessionPlayer);
|
||||
Assert.Contains("new AcDream.App.World.RetailLiveFrameCoordinator(", source);
|
||||
Assert.DoesNotContain("AdvanceLiveObjectRuntime", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("ReconcileLiveObjectSpatialPresentation", source,
|
||||
|
|
@ -363,8 +369,14 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
"AcDream.App",
|
||||
"Rendering",
|
||||
"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.Equal(1, CountOccurrences(
|
||||
source,
|
||||
|
|
@ -385,8 +397,14 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
"AcDream.App",
|
||||
"Rendering",
|
||||
"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,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_inputDispatcher?.Tick()", source,
|
||||
|
|
@ -468,10 +486,16 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
"AcDream.App",
|
||||
"Physics",
|
||||
"LiveEntityNetworkUpdateController.cs"));
|
||||
string sessionPlayer = File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Composition",
|
||||
"SessionPlayerComposition.cs"));
|
||||
|
||||
Assert.Contains(
|
||||
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
|
||||
source,
|
||||
"new LocalPlayerTeleportController(",
|
||||
sessionPlayer,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_localPlayerTeleport!.Tick", source,
|
||||
StringComparison.Ordinal);
|
||||
|
|
@ -491,7 +515,7 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
AssertAppearsInOrder(
|
||||
source,
|
||||
"new AcDream.App.Update.LiveEntityLivenessFramePhase(",
|
||||
"_localPlayerTeleport,",
|
||||
"sessionPlayer.LocalTeleport,",
|
||||
"new AcDream.App.Update.PlayerModeAutoEntryFramePhase(",
|
||||
"cameraFrame);");
|
||||
}
|
||||
|
|
@ -799,7 +823,7 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
StringComparison.Ordinal);
|
||||
Assert.Contains("d.WorldOrigin.CellLocalForSeed", livePresentation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("_liveEntitySessionEvents.CreateSink()", source,
|
||||
Assert.Contains("Live entity session routing was not composed.", source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue