refactor(app): compose live presentation startup

This commit is contained in:
Erik 2026-07-22 17:55:15 +02:00
parent aa6ffa5176
commit 88f32dc4e2
23 changed files with 1767 additions and 626 deletions

View file

@ -26,15 +26,14 @@ What does NOT go here:
## Current queue — 2026-07-22 ## Current queue — 2026-07-22
- **Active structural work:** `GameWindow` decomposition. Slices 17 are - **Active structural work:** `GameWindow` decomposition. Slices 17 and Slice
complete; Slice 8 checkpoints AG now own native callbacks, live-session 8 checkpoints AH are complete; I.1I.6 now own the executable ordered
composition, world-environment state, reversible device input/raw pointer, startup path through retained UI and live presentation, including the
physical framebuffer publication, gameplay action priority, typed commands, canonical runtime/world, selection/radar, landblock publishers, and
the retained-root item-drop edge, and two-phase runtime settings outside the portal/sky/particle resources.
shell.
`GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration
handoffs and the class is 3,663 lines / 162 fields / 37 methods. Remaining handoffs and the class is 2,840 raw lines. I.7I.9 plus the remaining
Slice 8 resource/composition/shutdown shutdown/closeout
cleanup is active in cleanup is active in
[`docs/architecture/code-structure.md`](architecture/code-structure.md). [`docs/architecture/code-structure.md`](architecture/code-structure.md).
This is the behavior-preserving prerequisite before new M4 feature bodies. This is the behavior-preserving prerequisite before new M4 feature bodies.

View file

@ -726,16 +726,17 @@ oracle, platform/host/content/settings phases, and the production world/render
phase. `WorldRenderCompositionPhase` owns Region/environment, the mandatory phase. `WorldRenderCompositionPhase` owns Region/environment, the mandatory
modern-renderer foundation, immutable terrain-worker inputs, and WB/texture/ modern-renderer foundation, immutable terrain-worker inputs, and WB/texture/
sampler construction; every Phase-4 GL constructor has retryable prefix sampler construction; every Phase-4 GL constructor has retryable prefix
ownership. `GameWindow` is 3,522 raw lines after I.5. I.6a moves interaction ownership. `GameWindow` is 3,522 raw lines after I.5. I.6 moves interaction,
and retained-UI construction into `InteractionRetainedUiCompositionPhase`; retained UI, live presentation, and landblock publication into ordered
typed exact-owner sources now bridge the later session, selection, radar, composition phases. Typed exact-owner sources bridge later session,
view-plane, diagnostics, inventory, and automation owners without callbacks to selection, radar, view-plane, diagnostics, inventory, hydration, and automation
the window. The old item-use and selection wrapper methods are deleted. owners without callbacks to the window. The old item-use and selection wrapper
`GameWindow` is 3,246 raw lines after I.6a. I.6bL remain active, with live methods are deleted. `GameWindow` is 2,840 raw lines after I.6. I.7L remain
presentation and landblock-publication composition next. The App gate passes active, with session, streaming, and hydration composition next. The App gate
3,401 tests / 3 intentional skips and the complete Release suite passes 7,773 passes 3,407 tests / 3 intentional skips and the complete Release suite passes
tests / 5 intentional skips. The solution retains only the 17 warnings tracked 7,779 tests / 5 intentional skips. The clean solution build retains only the
by #228; the I.6a behavior, architecture, and adversarial reviews are clean. 17 test-project warnings tracked by #228; the I.6 behavior, architecture, and
adversarial reviews are clean.
### 4.4 Exit criteria ### 4.4 Exit criteria

View file

@ -97,12 +97,15 @@ runtime-target binding without replay, and one current/toon settings owner.
`GameWindow` is 3,663 lines / 162 fields / 37 methods at G. Checkpoint H adds `GameWindow` is 3,663 lines / 162 fields / 37 methods at G. Checkpoint H adds
sole terrain-atlas and sky-shader roots, one retained Host/runtime lease, an sole terrain-atlas and sky-shader roots, one retained Host/runtime lease, an
atomic update/render frame-root pair, prepared portal fallback/transfer, and atomic update/render frame-root pair, prepared portal fallback/transfer, and
checked retryable GL leaf construction and state mutation. `GameWindow` is now checked retryable GL leaf construction and state mutation. Checkpoint I.1I.6
3,689 lines / 162 fields / 37 methods. Ordered composition, shutdown, canonical now provide the executable ordered startup pipeline through retained UI and
soak snapshots, and closeout checkpoints IL remain. Checkpoint H passes 61 live presentation. The canonical live runtime/world, presentation/effect
focused ownership tests and 3,236 App tests / 3 intentional skips; the complete controllers, selection/radar, landblock publishers, and portal/sky/particle
Release suite passes 7,606 tests / 5 skips and all three final corrected-diff resources have transactional phase ownership and exact-owner late bindings.
reviews are clean. `GameWindow` is now 2,840 raw lines. I.7I.9, shutdown, canonical soak
snapshots, and closeout checkpoints JL 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.
This is a behavior-preserving structural program. Severe regressions still get This is a behavior-preserving structural program. Severe regressions still get
root-cause fixes in separate commits; ordinary feature work resumes with M4 root-cause fixes in separate commits; ordinary feature work resumes with M4

View file

@ -53,12 +53,15 @@ startup application, and inert late target binding. `GameWindow` is 3,663
lines / 162 fields / 37 methods at G. Checkpoint H adds sole terrain/sky lines / 162 fields / 37 methods at G. Checkpoint H adds sole terrain/sky
resource roots, one retained Host/runtime lease, an atomic update/render root resource roots, one retained Host/runtime lease, an atomic update/render root
pair, a prepared portal fallback/transfer transaction, and checked retryable GL pair, a prepared portal fallback/transfer transaction, and checked retryable GL
leaf construction. `GameWindow` is 3,689 lines / 162 fields / 37 methods. leaf construction. Checkpoint I.1I.6 now provide the executable ordered
Checkpoints IL remain active. Issue #232 startup pipeline through retained UI and live presentation. The canonical live
tracks process-residency variance in the soak without loosening its leak runtime/world, presentation/effect controllers, selection/radar, landblock
threshold. Checkpoint H passes 61 focused ownership tests and 3,236 App tests / publishers, and portal/sky/particle resources have transactional phase
3 skips; the complete Release suite passes 7,606 tests / 5 skips and all three ownership and exact-owner late bindings. `GameWindow` is 2,840 raw lines.
final corrected-diff reviews are clean. I.7I.9 and checkpoints JL 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.
Carried: Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus

View file

@ -271,15 +271,17 @@ ledger only after the complete pipeline is live.
tests / 3 intentional skips; the complete Release suite passes 7,745 tests / tests / 3 intentional skips; the complete Release suite passes 7,745 tests /
5 intentional skips. Production App builds with zero warnings/errors; the 5 intentional skips. Production App builds with zero warnings/errors; the
solution retains only the 17 warnings tracked by #228. solution retains only the 17 warnings tracked by #228.
- I.6 (retained UI plus live-presentation composition) is active. Its detailed - I.6 (retained UI plus live-presentation composition) is complete. Its detailed
two-commit ownership and failure-test plan is two-commit ownership and failure-test plan is
[`2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md`](2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md). [`2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md`](2026-07-22-gamewindow-slice-8-checkpoint-i6-ui-live-presentation.md).
I.6a is complete: Phase 5 now composes interaction and retained UI through Phase 5 now composes interaction and retained UI through typed dependencies,
typed dependencies, exact-owner late bindings, and transactional rollback. exact-owner late bindings, and transactional rollback. Phase 6 now composes
`GameWindow.cs` is 3,246 raw lines; the App gate passes 3,401 tests / 3 the canonical live runtime/world, presentation/effect controllers,
intentional skips and the complete Release suite passes 7,773 tests / 5 selection/radar, landblock publishers, and portal/sky/particle resources
intentional skips. I.6b live presentation and landblock publication is the under the same ownership contract. `GameWindow.cs` is 2,840 raw lines; the
active cut. 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.
Checkpoint I remains active until I.9 closes the complete nine-phase pipeline. Checkpoint I remains active until I.9 closes the complete nine-phase pipeline.
## 6. Automated acceptance ## 6. Automated acceptance

View file

@ -1,6 +1,6 @@
# GameWindow Slice 8 — Checkpoint I.6 UI and Live Presentation # GameWindow Slice 8 — Checkpoint I.6 UI and Live Presentation
**Status:** I.6a complete; I.6b active **Status:** Complete; I.7 active
**Parent:** `2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md` **Parent:** `2026-07-22-gamewindow-slice-8-checkpoint-i-ordered-composition.md`
@ -195,6 +195,27 @@ The fallback slot and final owner never co-own the tunnel.
- App Release build/tests, full Release build/tests, divergence/source audit; - App Release build/tests, full Release build/tests, divergence/source audit;
- three independent self-review passes as in I.6a. - three independent self-review passes as in I.6a.
### 3.5 I.6b completion evidence
I.6b moved the complete Phase-6 body into
`LivePresentationCompositionPhase`. The phase now owns the canonical live
runtime/world identity, live presentation and effect controllers, WB draw and
landblock publication owners, selection/radar authorities, optional paperdoll,
and portal/sky/particle resources. Named exact-owner leases replace the former
anonymous Phase-6 subscriptions. A deferred landblock-loaded sink preserves
the Phase-7 hydration edge without capturing a future window field.
Construction uses transactional acquisition ownership throughout, including
portal-fallback and sky-shader lifetime rollback. The focused binding,
rollback, retry, stale-release, and source-boundary tests pass. The App Release
gate passes 3,407 tests / 3 intentional skips, the complete Release suite
passes 7,779 tests / 5 intentional skips, and the clean solution build retains
only the 17 test-project warnings tracked by #228. `GameWindow.cs` is 2,840
raw lines after I.6b.
Behavior/fidelity, architecture/ownership, and adversarial failure reviews
found no remaining actionable issue. I.7 session, streaming, and hydration
composition is the next active cut.
## 4. Production cutover rules ## 4. Production cutover rules
- Each subcommit leaves `OnLoad` calling the new phase and leaves later phases - Each subcommit leaves `OnLoad` calling the new phase and leaves later phases

View file

@ -58,6 +58,31 @@ internal sealed class AnimationHookRegistrationSet : IDisposable,
_entries.Add(new Entry(sink)); _entries.Add(new Entry(sink));
} }
public IDisposable RegisterOwned(IAnimationHookSink sink)
{
ArgumentNullException.ThrowIfNull(sink);
ObjectDisposedException.ThrowIf(_closed, this);
if (_entries.Any(entry =>
!entry.Removed && ReferenceEquals(entry.Sink, sink)))
{
throw new InvalidOperationException(
"The animation-hook sink is already registered and cannot be adopted twice.");
}
_register(sink);
_entries.Add(new Entry(sink));
return new Binding(this, sink);
}
private void UnregisterOwned(IAnimationHookSink sink)
{
Entry? entry = _entries.LastOrDefault(candidate =>
!candidate.Removed && ReferenceEquals(candidate.Sink, sink));
if (entry is null)
return;
_unregister(entry.Sink);
entry.Removed = true;
}
public void Dispose() public void Dispose()
{ {
_closed = true; _closed = true;
@ -102,4 +127,25 @@ internal sealed class AnimationHookRegistrationSet : IDisposable,
"Animation-hook registration cleanup remains incomplete.", "Animation-hook registration cleanup remains incomplete.",
failures); failures);
} }
private sealed class Binding : IDisposable
{
private AnimationHookRegistrationSet? _owner;
private readonly IAnimationHookSink _sink;
public Binding(AnimationHookRegistrationSet owner, IAnimationHookSink sink)
{
_owner = owner;
_sink = sink;
}
public void Dispose()
{
AnimationHookRegistrationSet? owner = _owner;
if (owner is null)
return;
owner.UnregisterOwned(_sink);
_owner = null;
}
}
} }

View file

@ -0,0 +1,938 @@
using System.Numerics;
using AcDream.App.Diagnostics;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Sky;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
using AcDream.App.Streaming;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.World;
using AcDream.Core.Audio;
using AcDream.Core.Items;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Core.Plugins;
using AcDream.Core.Rendering;
using AcDream.Core.Selection;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Composition;
internal sealed record LivePresentationDependencies(
RuntimeOptions Options,
GL Gl,
IWindow Window,
object DatLock,
RuntimeSettingsController Settings,
HostQuiescenceGate HostQuiescence,
PhysicsEngine PhysicsEngine,
PhysicsDataCache PhysicsDataCache,
WorldGameState WorldGameState,
WorldEvents WorldEvents,
SelectionState Selection,
ClientObjectTable Objects,
LiveEntityRuntimeSlot RuntimeSlot,
DeferredLiveEntityMotionRuntimeBindings MotionBindings,
DeferredEntityEffectAdvanceSource EffectAdvance,
EntityEffectPoseRegistry EffectPoses,
RemotePhysicsUpdater RemotePhysicsUpdater,
LocalPlayerShadowState LocalPlayerShadow,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> AnimatedEntities,
AnimationPresentationDiagnostics AnimationDiagnostics,
EntityClassificationCache ClassificationCache,
TranslucencyFadeManager TranslucencyFades,
RetailAlphaQueue RetailAlphaQueue,
CellVisibility CellVisibility,
LiveWorldOriginState WorldOrigin,
LocalPlayerIdentityState PlayerIdentity,
LocalPlayerControllerSlot PlayerController,
PointerPositionState PointerPosition,
PlayerApproachCompletionState PlayerApproachCompletions,
GameRenderResourceLifetime RenderResourceLifetime,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
AnimationHookRouter HookRouter,
IRenderFrameDiagnosticLog RenderDiagnosticLog,
WorldTimeService WorldTime,
DeferredCanonicalWorldEntityCountSource? DevWorldEntities,
DeferredRenderFrameDiagnosticsSource? DevFrameDiagnostics,
DeferredRenderFrameDiagnosticsSource UiFrameDiagnostics,
Action<string>? Toast);
internal sealed record LivePresentationResult(
DeferredLiveEntityRuntimeComponentLifecycle ComponentLifecycle,
LiveEntityMotionRuntimeController MotionRuntime,
DeferredLiveEntityParentAcceptance ParentAcceptance,
EntitySpawnAdapter EntitySpawnAdapter,
EntityScriptActivator EntityScriptActivator,
RetailStaticAnimatingObjectScheduler StaticAnimationScheduler,
GpuWorldState WorldState,
LiveEntityRuntime LiveEntities,
ProjectileController ProjectileController,
LiveEntityProjectionWithdrawalController ProjectionWithdrawal,
LiveEntityLightController Lights,
LiveEntityAnimationScheduler AnimationScheduler,
LiveEntityAnimationPresenter AnimationPresenter,
EquippedChildRenderController EquippedChildren,
EntityEffectController EntityEffects,
LiveEntityPresentationController Presentation,
RemoteTeleportController RemoteTeleport,
WbDrawDispatcher DrawDispatcher,
RetailSelectionScene SelectionScene,
WorldSelectionQuery SelectionQuery,
SelectionInteractionController SelectionInteractions,
RetainedUiGameplayBinding? RetainedGameplay,
PaperdollViewportRenderer? PaperdollRenderer,
PaperdollFramePresenter? PaperdollPresenter,
WbFrustum EnvCellFrustum,
EnvCellRenderer EnvCellRenderer,
LandblockPresentationPipeline LandblockPipeline,
ClipFrame ClipFrame,
PortalDepthMaskRenderer PortalDepthMask,
SkyRenderer SkyRenderer,
ParticleRenderer ParticleRenderer,
RenderFrameDiagnosticsController FrameDiagnostics,
LivePresentationRuntimeBindings RuntimeBindings,
DeferredLiveEntityLandblockLoadedSink LandblockLoaded);
internal interface IGameWindowLivePresentationPublication
{
void PublishLivePresentation(LivePresentationResult result);
}
internal enum LivePresentationCompositionPoint
{
CanonicalRuntimeCreated,
CanonicalRuntimeBound,
MotionRuntimeBound,
ProjectionVisibilityBound,
CorePresentationCreated,
EffectRoutingBound,
SelectionAndRadarBound,
RetainedGameplayBound,
PaperdollCreated,
EnvironmentCellsCreated,
LandblockPipelineCreated,
PortalResourcesCreated,
SkyAndParticlesCreated,
DiagnosticsBound,
ResultPublished,
}
internal sealed class LivePresentationCompositionPhase
: ILivePresentationCompositionPhase<
HostInputCameraResult,
ContentEffectsAudioResult,
WorldRenderResult,
InteractionRetainedUiResult,
LivePresentationResult>
{
private readonly LivePresentationDependencies _dependencies;
private readonly IGameWindowLivePresentationPublication _publication;
private readonly Action<LivePresentationCompositionPoint>? _faultInjection;
public LivePresentationCompositionPhase(
LivePresentationDependencies dependencies,
IGameWindowLivePresentationPublication publication,
Action<LivePresentationCompositionPoint>? faultInjection = null)
{
_dependencies = dependencies
?? throw new ArgumentNullException(nameof(dependencies));
_publication = publication
?? throw new ArgumentNullException(nameof(publication));
_faultInjection = faultInjection;
}
public LivePresentationResult Compose(
HostInputCameraResult host,
ContentEffectsAudioResult content,
WorldRenderResult world,
InteractionRetainedUiResult interaction)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(world);
ArgumentNullException.ThrowIfNull(interaction);
return ComposeCore(host, content, world, interaction);
}
private LivePresentationResult ComposeCore(
HostInputCameraResult host,
ContentEffectsAudioResult content,
WorldRenderResult world,
InteractionRetainedUiResult interaction)
{
LivePresentationDependencies d = _dependencies;
WorldRenderFoundation foundation = world.Foundation;
var scope = new CompositionAcquisitionScope();
LivePresentationRuntimeBindings? bindings = null;
bool bindingsOwnedByScope = false;
try
{
var componentLifecycle =
new DeferredLiveEntityRuntimeComponentLifecycle();
var wbSpawnAdapter = new LandblockSpawnAdapter(foundation.MeshAdapter);
AnimationSequencer SequencerFactory(WorldEntity entity)
{
Setup? setup = content.Dats.Get<Setup>(entity.SourceGfxObjOrSetupId);
if (setup is not null)
{
uint motionTableId = (uint)setup.DefaultMotionTable;
if (motionTableId != 0
&& content.Dats.Get<MotionTable>(motionTableId) is { } motionTable)
{
return new AnimationSequencer(
setup,
motionTable,
content.AnimationLoader);
}
return new AnimationSequencer(
setup,
new MotionTable(),
content.AnimationLoader);
}
return new AnimationSequencer(
new Setup(),
new MotionTable(),
NullAnimationLoader.Instance);
}
var entitySpawnAdapter = new EntitySpawnAdapter(
foundation.TextureCache,
SequencerFactory,
foundation.MeshAdapter);
EntityEffectController? entityEffects = null;
LiveEntityRuntime? liveEntities = null;
var staticRootCommitter = new StaticLiveRootCommitter(
d.RuntimeSlot,
d.PhysicsEngine.ShadowObjects,
d.WorldOrigin,
d.EffectPoses);
var staticResidency = new LiveStaticAnimationResidency(d.RuntimeSlot);
var staticAnimationScheduler =
new RetailStaticAnimatingObjectScheduler(
content.AnimationLoader,
content.AnimationHookFrames.Capture,
d.EffectPoses.Publish,
staticResidency.IsResident,
(entity, body) => _ = staticRootCommitter.Commit(entity, body),
staticResidency.ProjectionVersion);
ScriptActivationInfo? ResolveActivation(WorldEntity entity)
{
try
{
Setup? setup = content.Dats.Get<Setup>(
entity.SourceGfxObjOrSetupId);
if (setup is null)
return null;
uint scriptId = setup.DefaultScript.DataId;
if (entity.IndexedPartTransforms.Count == 0)
{
var indexed = IndexedSetupPartPoseBuilder.Build(setup, entity);
entity.SetIndexedPartPoses(indexed.Poses, indexed.Available);
}
bool usesStaticAnimationWorkset = entity.ServerGuid == 0
|| (liveEntities?.TryGetRecord(
entity.ServerGuid,
out LiveEntityRecord liveRecord) == true
&& (liveRecord.FinalPhysicsState
& PhysicsStateFlags.Static) != 0);
return new ScriptActivationInfo(
scriptId,
entity.IndexedPartTransforms,
EntityEffectProfile.CreateDatStatic(setup),
entity.IndexedPartAvailable,
setup,
(uint)setup.DefaultAnimation,
usesStaticAnimationWorkset);
}
catch
{
return null;
}
}
var entityScriptActivator = new EntityScriptActivator(
content.ScriptRunner,
content.ParticleSink,
d.EffectPoses,
ResolveActivation,
(ownerId, entity, profile) =>
entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile),
ownerId =>
{
entityEffects?.OnDatStaticEntityRemoved(ownerId);
content.LightingSink.UnregisterOwner(ownerId);
d.TranslucencyFades.ClearEntity(ownerId);
},
(entity, info) => staticAnimationScheduler.Register(entity, info),
staticAnimationScheduler.Unregister,
(entity, info) => staticAnimationScheduler.Rebind(entity, info));
var worldState = new GpuWorldState(
wbSpawnAdapter,
d.ClassificationCache.InvalidateLandblock,
entityScriptActivator);
bindings = new LivePresentationRuntimeBindings();
if (d.DevWorldEntities is { } devWorldEntities)
{
bindings.Adopt(
"developer world-entity source",
devWorldEntities.BindOwned(worldState));
}
liveEntities = new LiveEntityRuntime(
worldState,
new CompositeLiveEntityResourceLifecycle(
new(
entity => _ = entitySpawnAdapter.OnCreate(entity),
entity => _ = entitySpawnAdapter.OnRemove(entity)),
new(
entityScriptActivator.OnCreate,
entityScriptActivator.OnRemove)),
componentLifecycle);
var liveRuntimeLease = scope.Own(
"canonical live-entity runtime",
liveEntities,
static runtime => runtime.Clear());
Fault(LivePresentationCompositionPoint.CanonicalRuntimeCreated);
bindings.Adopt(
"canonical live-runtime slot",
d.RuntimeSlot.BindOwned(liveEntities));
Fault(LivePresentationCompositionPoint.CanonicalRuntimeBound);
var selectionInteractionSource =
new DeferredSelectionInteractionSource();
var motionRuntime = new LiveEntityMotionRuntimeController(
liveEntities,
d.PhysicsDataCache,
() => selectionInteractionSource.Current,
d.Selection,
d.WorldOrigin);
bindings.Adopt(
"live motion runtime",
d.MotionBindings.BindOwned(motionRuntime));
Fault(LivePresentationCompositionPoint.MotionRuntimeBound);
Action<LiveEntityRecord, bool> wbVisibility = (record, visible) =>
{
if (record.WorldEntity is { } entity)
entitySpawnAdapter.SetPresentationResident(entity, visible);
};
bindings.BindProjectionVisibility(
liveEntities,
wbVisibility,
"WB projection visibility");
Action<LiveEntityRecord, bool> particleVisibility = (record, visible) =>
{
if (record.WorldEntity is { } entity)
content.ParticleSink.SetEntityPresentationVisible(entity.Id, visible);
};
bindings.BindProjectionVisibility(
liveEntities,
particleVisibility,
"particle projection visibility");
Fault(LivePresentationCompositionPoint.ProjectionVisibilityBound);
var projectileController = new ProjectileController(
liveEntities,
d.PhysicsEngine,
new DatProjectileSetupResolver(content.Dats, d.DatLock),
new EntityRootPosePublisher(d.EffectPoses),
d.WorldOrigin)
{
DiagnosticSink = message =>
Console.Error.WriteLine($"projectile: {message}"),
};
var projectionWithdrawal =
new LiveEntityProjectionWithdrawalController(
liveEntities,
projectileController,
d.WorldGameState,
d.WorldEvents,
d.PhysicsEngine.ShadowObjects,
d.EffectPoses,
d.LocalPlayerShadow);
var lightsLease = scope.Acquire(
"live-entity lights",
() => new LiveEntityLightController(
liveEntities,
d.EffectPoses,
content.LightingSink,
setupId => content.Dats.Get<Setup>(setupId)),
static value => value.Dispose());
var ordinaryPhysicsUpdater = new LiveEntityOrdinaryPhysicsUpdater(
d.PhysicsEngine,
d.MotionBindings.GetSetupCylinder);
var animationScheduler = new LiveEntityAnimationScheduler(
liveEntities,
d.PlayerIdentity,
d.RemotePhysicsUpdater,
ordinaryPhysicsUpdater,
projectileController,
new EntityRootPosePublisher(d.EffectPoses),
new AnimationHookCaptureSink(content.AnimationHookFrames));
var animationPresenter = new LiveEntityAnimationPresenter(
liveEntities,
staticAnimationScheduler,
d.EffectPoses,
new LiveAnimationPresentationContext(
liveEntities,
d.PlayerIdentity,
d.PlayerController),
d.AnimationDiagnostics,
d.Options.HidePartIndex);
var parentAcceptance = new DeferredLiveEntityParentAcceptance();
var equippedLease = scope.Acquire(
"equipped-child renderer",
() => new EquippedChildRenderController(
content.Dats,
d.DatLock,
d.Objects,
liveEntities,
d.EffectPoses,
parentAcceptance.TryAccept,
(childRecord, positionVersion, projectionVersion) =>
projectionWithdrawal.WithdrawExact(
childRecord,
positionVersion,
projectionVersion,
d.PlayerIdentity.ServerGuid)),
static value => value.Dispose());
Fault(LivePresentationCompositionPoint.CorePresentationCreated);
var tableResolver = new PhysicsScriptTableResolver(
id => content.Dats.Get<PhysicsScriptTable>(id));
entityEffects = new EntityEffectController(
liveEntities,
content.ScriptRunner,
tableResolver,
d.EffectPoses,
(parentLocalId, partIndex) =>
equippedLease.Resource.FindChildLocalIdAtPart(
parentLocalId,
partIndex),
equippedLease.Resource.FindParentLocalId,
ownerId => content.Audio?.EntitySoundTables.Remove(ownerId),
(ownerId, soundTableDid) =>
{
content.Audio?.EntitySoundTables.Remove(ownerId);
if (soundTableDid is { } did)
content.Audio?.EntitySoundTables.Set(ownerId, did);
});
bindings.Adopt(
"entity-effect advance",
d.EffectAdvance.BindOwned(entityEffects));
entityEffects.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}");
var partArrayLifecycle = new LiveEntityPartArrayLifecycle(
d.AnimatedEntities);
var presentationLease = scope.Acquire(
"live-entity presentation",
() => new LiveEntityPresentationController(
liveEntities,
d.PhysicsEngine.ShadowObjects,
entityEffects.PlayTypedFromHiddenTransition,
equippedLease.Resource.SetDirectChildrenNoDraw,
d.MotionBindings.ClearTargetForHiddenEntity,
d.WorldOrigin.GetCenter,
partArrayLifecycle.HandleEnterWorld),
static value => value.Dispose());
var remoteShadowPlacement = new RemoteShadowPlacementSynchronizer(
d.RemotePhysicsUpdater,
d.WorldOrigin);
var remoteTeleportPresentation =
new RemoteTeleportPlacementPresentation(presentationLease.Resource);
var remoteTeleportLease = scope.Acquire(
"remote teleport",
() => new RemoteTeleportController(
d.PhysicsEngine,
liveEntities,
d.MotionBindings.GetSetupCylinder,
d.WorldOrigin.CellLocalForSeed,
remoteShadowPlacement.Sync,
remoteTeleportPresentation.Complete,
remoteTeleportPresentation.Begin),
static value => value.Dispose());
bindings.BindProjectionPoseReady(
equippedLease.Resource,
lightsLease.Resource.OnAttachedPoseReady);
bindings.Adopt(
"entity-effect animation hooks",
content.HookRegistrations.RegisterOwned(entityEffects));
Fault(LivePresentationCompositionPoint.EffectRoutingBound);
return CompletePresentation(
host,
content,
world,
interaction,
componentLifecycle,
motionRuntime,
parentAcceptance,
entitySpawnAdapter,
entityScriptActivator,
staticAnimationScheduler,
worldState,
liveEntities,
projectileController,
projectionWithdrawal,
lightsLease,
animationScheduler,
animationPresenter,
equippedLease,
entityEffects,
presentationLease,
remoteTeleportLease,
selectionInteractionSource,
bindings,
scope,
ref bindingsOwnedByScope,
liveRuntimeLease);
}
catch (Exception failure)
{
if (bindings is not null && !bindingsOwnedByScope)
{
scope.Own(
"live-presentation runtime bindings",
bindings,
static value => value.Dispose());
bindingsOwnedByScope = true;
}
scope.RollbackAndThrow(failure);
throw new System.Diagnostics.UnreachableException();
}
}
private LivePresentationResult CompletePresentation(
HostInputCameraResult host,
ContentEffectsAudioResult content,
WorldRenderResult world,
InteractionRetainedUiResult interaction,
DeferredLiveEntityRuntimeComponentLifecycle componentLifecycle,
LiveEntityMotionRuntimeController motionRuntime,
DeferredLiveEntityParentAcceptance parentAcceptance,
EntitySpawnAdapter entitySpawnAdapter,
EntityScriptActivator entityScriptActivator,
RetailStaticAnimatingObjectScheduler staticAnimationScheduler,
GpuWorldState worldState,
LiveEntityRuntime liveEntities,
ProjectileController projectileController,
LiveEntityProjectionWithdrawalController projectionWithdrawal,
CompositionAcquisitionScope.CompositionAcquisitionLease<LiveEntityLightController> lightsLease,
LiveEntityAnimationScheduler animationScheduler,
LiveEntityAnimationPresenter animationPresenter,
CompositionAcquisitionScope.CompositionAcquisitionLease<EquippedChildRenderController> equippedLease,
EntityEffectController entityEffects,
CompositionAcquisitionScope.CompositionAcquisitionLease<LiveEntityPresentationController> presentationLease,
CompositionAcquisitionScope.CompositionAcquisitionLease<RemoteTeleportController> remoteTeleportLease,
DeferredSelectionInteractionSource selectionInteractionSource,
LivePresentationRuntimeBindings bindings,
CompositionAcquisitionScope scope,
ref bool bindingsOwnedByScope,
CompositionAcquisitionScope.CompositionAcquisitionLease<LiveEntityRuntime> liveRuntimeLease)
{
LivePresentationDependencies d = _dependencies;
WorldRenderFoundation foundation = world.Foundation;
var selectionScene = new RetailSelectionScene(
new RetailSelectionGeometryCache(content.Dats, d.DatLock));
var dispatcherLease = scope.Acquire(
"WB draw dispatcher",
() => new WbDrawDispatcher(
d.Gl,
foundation.MeshShader,
foundation.TextureCache,
foundation.MeshAdapter,
entitySpawnAdapter,
foundation.Bindless,
d.ClassificationCache,
d.TranslucencyFades,
selectionScene,
d.RetailAlphaQueue),
static value => value.Dispose());
var selectionQuery = new WorldSelectionQuery(
liveEntities,
d.Objects,
selectionScene,
() => d.PlayerIdentity.ServerGuid,
interaction.LateBindings.SelectionCamera.Snapshot,
() => new Vector2(
d.PointerPosition.X,
d.PointerPosition.Y),
() => d.PlayerController.Controller is { } player
? new PlayerInteractionPose(player.CellId, player.Position)
: null,
d.MotionBindings.GetSetupCylinder,
setupId =>
{
lock (d.DatLock)
{
if (!content.Dats.TryGet<Setup>(setupId, out Setup? setup)
|| setup.SelectionSphere is not { } sphere)
{
return null;
}
return (sphere.Origin, sphere.Radius);
}
});
var radarSnapshotProvider = new RadarSnapshotProvider(
d.Objects,
() => liveEntities.WorldEntities,
() => liveEntities.Snapshots,
playerGuid: () => d.PlayerIdentity.ServerGuid,
playerYawRadians: () => d.PlayerController.Controller?.Yaw ?? 0f,
playerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
selectedGuid: () => d.Selection.SelectedObjectId,
coordinatesOnRadar: () => d.Settings.Gameplay.CoordinatesOnRadar,
uiLocked: () => d.Settings.Gameplay.LockUI,
playerEntities: () => liveEntities.MaterializedWorldEntities,
spatialQuery: () => worldState);
bindings.Adopt(
"radar snapshot",
interaction.LateBindings.Radar.Bind(radarSnapshotProvider));
var selectionInteractions = new SelectionInteractionController(
d.Selection,
selectionQuery,
interaction.ItemInteraction,
new WorldSessionSelectionInteractionTransport(
() => interaction.LateBindings.Session.CurrentSession),
new PlayerInteractionMovementSink(
() => d.PlayerController.Controller,
d.PlayerApproachCompletions),
d.Toast,
d.PlayerApproachCompletions);
selectionInteractionSource.Bind(selectionInteractions);
bindings.Adopt(
"world selection",
interaction.LateBindings.Selection.Bind(
selectionQuery,
selectionInteractions));
Fault(LivePresentationCompositionPoint.SelectionAndRadarBound);
CompositionAcquisitionScope.CompositionAcquisitionLease<
RetainedUiGameplayBinding>? retainedGameplayLease = null;
if (interaction.RetainedUi is { } retainedUi)
{
retainedGameplayLease = scope.Acquire(
"retained gameplay binding",
() => RetainedUiGameplayBinding.Create(
retainedUi.Host.Root,
(item, x, y) =>
selectionInteractions.PlaceDraggedItem(item, x, y),
d.HostQuiescence),
static value => value.Dispose());
retainedGameplayLease.Resource.Attach();
}
Fault(LivePresentationCompositionPoint.RetainedGameplayBound);
dispatcherLease.Resource.AlphaToCoverage =
d.Settings.ResolvedQuality.AlphaToCoverage;
CompositionAcquisitionScope.CompositionAcquisitionLease<
PaperdollViewportRenderer>? paperdollLease = null;
PaperdollFramePresenter? paperdollPresenter = null;
if (interaction.RetainedUi?.Runtime.PaperdollViewportWidget is { } viewport
&& interaction.RetainedUi.Runtime.InventoryFrame is { } inventoryFrame)
{
paperdollLease = scope.Acquire(
"paperdoll viewport",
() => new PaperdollViewportRenderer(
d.Gl,
dispatcherLease.Resource,
foundation.SceneLighting,
foundation.TextureCache),
static value => value.Dispose());
IUiViewportRenderer? previousRenderer = viewport.Renderer;
viewport.Renderer = paperdollLease.Resource;
bindings.AdoptRelease(
"paperdoll viewport target",
() =>
{
if (ReferenceEquals(viewport.Renderer, paperdollLease.Resource))
viewport.Renderer = previousRenderer;
});
paperdollPresenter = new PaperdollFramePresenter(
paperdollLease.Resource,
new RetailPaperdollFrameView(
viewport,
new PaperdollInventoryVisibility(inventoryFrame)),
new RetailPaperdollDollFactory(
new LivePaperdollEntityLookup(liveEntities),
d.PlayerIdentity,
new RetailPaperdollPoseApplicator(
content.Dats,
content.AnimationLoader,
d.DatLock)));
}
Fault(LivePresentationCompositionPoint.PaperdollCreated);
var envCellFrustum = new WbFrustum();
var envCellLease = scope.Acquire(
"environment-cell renderer",
() => new EnvCellRenderer(
d.Gl,
foundation.MeshAdapter.MeshManager!,
envCellFrustum),
static value => value.Dispose());
envCellLease.Resource.Initialize(foundation.MeshShader);
Fault(LivePresentationCompositionPoint.EnvironmentCellsCreated);
var landblockRenderPublisher = new LandblockRenderPublisher(
(landblockId, meshData, origin) =>
foundation.Terrain.AddLandblockWithMesh(
landblockId,
meshData,
origin),
foundation.Terrain.RemoveLandblock,
d.CellVisibility,
worldState,
envCellLease.Resource.CommitLandblock,
build => EnvCellMeshPreparationScheduler.Schedule(
build,
foundation.MeshAdapter.MeshManager!),
envCellLease.Resource.RemoveLandblock);
var landblockPhysicsPublisher = new LandblockPhysicsPublisher(
d.PhysicsEngine,
world.TerrainBuild.HeightTable);
var landblockStaticPublisher =
new LandblockStaticPresentationPublisher(
content.LightingSink,
d.TranslucencyFades,
d.WorldGameState,
d.WorldEvents);
var landblockRetirementOwner =
new LandblockPresentationRetirementOwner(
landblockRenderPublisher,
landblockPhysicsPublisher,
landblockStaticPublisher,
content.LightingSink,
d.TranslucencyFades);
var landblockLoaded = new DeferredLiveEntityLandblockLoadedSink();
var landblockPipeline = new LandblockPresentationPipeline(
landblockRenderPublisher,
landblockPhysicsPublisher,
landblockStaticPublisher,
worldState,
landblockRetirementOwner,
landblockLoaded.OnLandblockLoaded,
landblockRenderPublisher.PrepareAfterRenderPins);
Fault(LivePresentationCompositionPoint.LandblockPipelineCreated);
var clipFrameLease = scope.Acquire(
"portal clip frame",
ClipFrame.NoClip,
static value => value.Dispose());
var portalDepthLease = scope.Acquire(
"portal depth mask",
() => new PortalDepthMaskRenderer(d.Gl),
static value => value.Dispose());
PortalTunnelPresentation portalTunnel;
try
{
portalTunnel = d.PortalTunnelFallback.AcquirePrepared(
() => PortalTunnelPresentation.CreateRequired(
d.Gl,
content.Dats,
content.AnimationLoader,
d.HookRouter,
dispatcherLease.Resource,
foundation.SceneLighting,
foundation.MeshAdapter),
static tunnel => tunnel.PrepareResources());
}
catch (Exception acquisitionFailure)
{
try
{
d.PortalTunnelFallback.ReleaseFallback();
}
catch (Exception cleanupFailure)
{
throw new AggregateException(
"Portal-tunnel construction and fallback rollback both failed.",
acquisitionFailure,
cleanupFailure);
}
throw;
}
var portalTunnelLease = scope.Own(
"portal tunnel fallback",
portalTunnel,
_ => d.PortalTunnelFallback.ReleaseFallback());
Fault(LivePresentationCompositionPoint.PortalResourcesCreated);
AcDream.App.Rendering.Shader skyShader =
d.RenderResourceLifetime.AcquireSkyShader(
() => new AcDream.App.Rendering.Shader(
d.Gl,
Path.Combine(foundation.ShadersDirectory, "sky.vert"),
Path.Combine(foundation.ShadersDirectory, "sky.frag")));
var skyShaderLease = scope.Own(
"sky shader lifetime",
skyShader,
_ => d.RenderResourceLifetime.ReleaseSkyShader());
var skyLease = scope.Acquire(
"sky renderer",
() => new SkyRenderer(
d.Gl,
content.Dats,
skyShader,
foundation.TextureCache,
foundation.Samplers),
static value => value.Dispose());
var particleLease = scope.Acquire(
"particle renderer",
() => new ParticleRenderer(
d.Gl,
foundation.ShadersDirectory,
content.ParticleSystem,
foundation.TextureCache,
content.Dats,
foundation.MeshAdapter,
d.RetailAlphaQueue),
static value => value.Dispose());
Fault(LivePresentationCompositionPoint.SkyAndParticlesCreated);
IRenderFrameResourceDiagnosticsSource? resourceDiagnostics =
d.Options.UiProbeDump
? new RuntimeRenderFrameResourceDiagnosticsSource(
content.ParticleSystem,
content.ParticleSink,
dispatcherLease.Resource,
envCellLease.Resource,
particleLease.Resource,
interaction.RetainedUi?.Host.TextRenderer,
portalDepthLease.Resource,
clipFrameLease.Resource,
foundation.Terrain,
foundation.SceneLighting,
foundation.MeshAdapter,
foundation.TextureCache)
: null;
var frameDiagnostics = new RenderFrameDiagnosticsController(
new RuntimeRenderFrameTitleFactsSource(
worldState,
d.AnimatedEntities,
d.WorldTime),
new SilkRenderFrameTitleSink(d.Window),
d.RenderDiagnosticLog,
d.Options.UiProbeDump,
resourceDiagnostics);
if (d.DevFrameDiagnostics is { } devFrameDiagnostics)
{
bindings.Adopt(
"developer frame diagnostics",
devFrameDiagnostics.BindOwned(frameDiagnostics));
}
bindings.Adopt(
"retained-UI frame diagnostics",
d.UiFrameDiagnostics.BindOwned(frameDiagnostics));
Fault(LivePresentationCompositionPoint.DiagnosticsBound);
var bindingsLease = scope.Own(
"live-presentation runtime bindings",
bindings,
static value => value.Dispose());
bindingsOwnedByScope = true;
var result = new LivePresentationResult(
componentLifecycle,
motionRuntime,
parentAcceptance,
entitySpawnAdapter,
entityScriptActivator,
staticAnimationScheduler,
worldState,
liveEntities,
projectileController,
projectionWithdrawal,
lightsLease.Resource,
animationScheduler,
animationPresenter,
equippedLease.Resource,
entityEffects,
presentationLease.Resource,
remoteTeleportLease.Resource,
dispatcherLease.Resource,
selectionScene,
selectionQuery,
selectionInteractions,
retainedGameplayLease?.Resource,
paperdollLease?.Resource,
paperdollPresenter,
envCellFrustum,
envCellLease.Resource,
landblockPipeline,
clipFrameLease.Resource,
portalDepthLease.Resource,
skyLease.Resource,
particleLease.Resource,
frameDiagnostics,
bindings,
landblockLoaded);
_publication.PublishLivePresentation(result);
liveRuntimeLease.Transfer();
lightsLease.Transfer();
equippedLease.Transfer();
presentationLease.Transfer();
remoteTeleportLease.Transfer();
dispatcherLease.Transfer();
retainedGameplayLease?.Transfer();
paperdollLease?.Transfer();
envCellLease.Transfer();
clipFrameLease.Transfer();
portalDepthLease.Transfer();
portalTunnelLease.Transfer();
skyLease.Transfer();
skyShaderLease.Transfer();
particleLease.Transfer();
bindingsLease.Transfer();
Fault(LivePresentationCompositionPoint.ResultPublished);
scope.Complete();
return result;
}
private void Fault(LivePresentationCompositionPoint point) =>
_faultInjection?.Invoke(point);
private sealed class NullAnimationLoader : IAnimationLoader
{
public static NullAnimationLoader Instance { get; } = new();
public Animation? LoadAnimation(uint id) => null;
}
private sealed class DeferredSelectionInteractionSource
{
public SelectionInteractionController? Current { get; private set; }
public void Bind(SelectionInteractionController value)
{
ArgumentNullException.ThrowIfNull(value);
if (Current is not null)
{
throw new InvalidOperationException(
"Live motion selection interactions are already bound.");
}
Current = value;
}
}
}

View file

@ -0,0 +1,97 @@
using AcDream.App.Rendering;
using AcDream.App.World;
namespace AcDream.App.Composition;
/// <summary>
/// Exact-owner leases installed by live-presentation composition. Successful
/// releases are removed immediately, so a failed cleanup retries only its
/// remaining suffix and never replays completed detach operations.
/// </summary>
internal sealed class LivePresentationRuntimeBindings : 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 AdoptRelease(string name, Action release) =>
Adopt(name, new DelegateBinding(release));
public void BindProjectionVisibility(
LiveEntityRuntime source,
Action<LiveEntityRecord, bool> handler,
string name)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(handler);
source.ProjectionVisibilityChanged += handler;
Adopt(name, new DelegateBinding(
() => source.ProjectionVisibilityChanged -= handler));
}
public void BindProjectionPoseReady(
EquippedChildRenderController source,
Action<uint> handler)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(handler);
source.ProjectionPoseReady += handler;
Adopt("equipped-child projection pose", new DelegateBinding(
() => source.ProjectionPoseReady -= 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(
$"Live-presentation binding '{name}' did not detach.",
failure));
}
}
if (failures is not null)
{
throw new AggregateException(
"Live-presentation binding cleanup remains incomplete.",
failures);
}
}
private sealed class DelegateBinding : IDisposable
{
private Action? _release;
public DelegateBinding(Action release) =>
_release = release ?? throw new ArgumentNullException(nameof(release));
public void Dispose()
{
Action? release = _release;
if (release is null)
return;
release();
_release = null;
}
}
}

View file

@ -39,6 +39,18 @@ internal sealed class DeferredLiveEntityMotionRuntimeBindings
_target = target; _target = target;
} }
public IDisposable BindOwned(ILiveEntityMotionRuntimeBindings target)
{
Bind(target);
return new Binding(this, target);
}
private void Unbind(ILiveEntityMotionRuntimeBindings expected)
{
if (ReferenceEquals(_target, expected))
_target = null;
}
private ILiveEntityMotionRuntimeBindings Target => private ILiveEntityMotionRuntimeBindings Target =>
_target ?? throw new InvalidOperationException( _target ?? throw new InvalidOperationException(
"Live entity motion runtime bindings were used before binding."); "Live entity motion runtime bindings were used before binding.");
@ -61,4 +73,21 @@ internal sealed class DeferredLiveEntityMotionRuntimeBindings
public IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid) => public IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid) =>
Target.ResolvePhysicsHost(serverGuid); Target.ResolvePhysicsHost(serverGuid);
private sealed class Binding : IDisposable
{
private DeferredLiveEntityMotionRuntimeBindings? _owner;
private readonly ILiveEntityMotionRuntimeBindings _expected;
public Binding(
DeferredLiveEntityMotionRuntimeBindings owner,
ILiveEntityMotionRuntimeBindings expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
} }

View file

@ -103,6 +103,12 @@ internal sealed class DeferredCanonicalWorldEntityCountSource
_target = target; _target = target;
} }
public IDisposable BindOwned(GpuWorldState target)
{
Bind(target);
return new Binding(this, target);
}
public void Unbind(GpuWorldState target) public void Unbind(GpuWorldState target)
{ {
ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(target);
@ -115,6 +121,23 @@ internal sealed class DeferredCanonicalWorldEntityCountSource
_deactivated = true; _deactivated = true;
_target = null; _target = null;
} }
private sealed class Binding : IDisposable
{
private DeferredCanonicalWorldEntityCountSource? _owner;
private readonly GpuWorldState _target;
public Binding(
DeferredCanonicalWorldEntityCountSource owner,
GpuWorldState target)
{
_owner = owner;
_target = target;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_target);
}
} }
internal interface IDevToolsPlayerModeCommands internal interface IDevToolsPlayerModeCommands

View file

@ -20,7 +20,8 @@ public sealed class GameWindow :
IGameWindowContentEffectsAudioPublication, IGameWindowContentEffectsAudioPublication,
IGameWindowSettingsDevToolsPublication, IGameWindowSettingsDevToolsPublication,
IGameWindowWorldRenderPublication, IGameWindowWorldRenderPublication,
IGameWindowInteractionRetainedUiPublication IGameWindowInteractionRetainedUiPublication,
IGameWindowLivePresentationPublication
{ {
private static double ClientTimerNow() => private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp() System.Diagnostics.Stopwatch.GetTimestamp()
@ -97,6 +98,7 @@ public sealed class GameWindow :
_debugVmRenderFacts = new(); _debugVmRenderFacts = new();
private AcDream.App.Rendering.RenderFrameDiagnosticsController? private AcDream.App.Rendering.RenderFrameDiagnosticsController?
_renderFrameDiagnostics; _renderFrameDiagnostics;
private LivePresentationRuntimeBindings? _livePresentationBindings;
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
@ -924,6 +926,76 @@ public sealed class GameWindow :
} }
} }
void IGameWindowLivePresentationPublication.PublishLivePresentation(
LivePresentationResult result)
{
ArgumentNullException.ThrowIfNull(result);
if (_liveEntities is not null
|| _wbEntitySpawnAdapter is not null
|| _entityScriptActivator is not null
|| _staticAnimationScheduler is not null
|| _wbDrawDispatcher is not null
|| _retailSelectionScene is not null
|| _worldSelectionQuery is not null
|| _selectionInteractions is not null
|| _retainedUiGameplayBinding is not null
|| _equippedChildRenderer is not null
|| _entityEffects is not null
|| _liveEntityPresentation is not null
|| _remoteTeleportController is not null
|| _projectileController is not null
|| _liveEntityProjectionWithdrawal is not null
|| _liveEntityLights is not null
|| _liveAnimationScheduler is not null
|| _animationPresenter is not null
|| _paperdollViewportRenderer is not null
|| _paperdollFramePresenter is not null
|| _envCellRenderer is not null
|| _envCellFrustum is not null
|| _landblockPresentationPipeline is not null
|| _portalDepthMask is not null
|| _clipFrame is not null
|| _skyRenderer is not null
|| _particleRenderer is not null
|| _renderFrameDiagnostics is not null
|| _livePresentationBindings is not null)
{
throw new InvalidOperationException(
"The GameWindow composition shell already owns live presentation state.");
}
_wbEntitySpawnAdapter = result.EntitySpawnAdapter;
_entityScriptActivator = result.EntityScriptActivator;
_staticAnimationScheduler = result.StaticAnimationScheduler;
_worldState = result.WorldState;
_liveEntities = result.LiveEntities;
_projectileController = result.ProjectileController;
_liveEntityProjectionWithdrawal = result.ProjectionWithdrawal;
_liveEntityLights = result.Lights;
_liveAnimationScheduler = result.AnimationScheduler;
_animationPresenter = result.AnimationPresenter;
_equippedChildRenderer = result.EquippedChildren;
_entityEffects = result.EntityEffects;
_liveEntityPresentation = result.Presentation;
_remoteTeleportController = result.RemoteTeleport;
_wbDrawDispatcher = result.DrawDispatcher;
_retailSelectionScene = result.SelectionScene;
_worldSelectionQuery = result.SelectionQuery;
_selectionInteractions = result.SelectionInteractions;
_retainedUiGameplayBinding = result.RetainedGameplay;
_paperdollViewportRenderer = result.PaperdollRenderer;
_paperdollFramePresenter = result.PaperdollPresenter;
_envCellFrustum = result.EnvCellFrustum;
_envCellRenderer = result.EnvCellRenderer;
_landblockPresentationPipeline = result.LandblockPipeline;
_clipFrame = result.ClipFrame;
_portalDepthMask = result.PortalDepthMask;
_skyRenderer = result.SkyRenderer;
_particleRenderer = result.ParticleRenderer;
_renderFrameDiagnostics = result.FrameDiagnostics;
_livePresentationBindings = result.RuntimeBindings;
}
private static void PublishCompositionOwner<T>( private static void PublishCompositionOwner<T>(
ref T? destination, ref T? destination,
T value, T value,
@ -1124,532 +1196,52 @@ public sealed class GameWindow :
settingsDevTools, settingsDevTools,
worldRender); worldRender);
AcDream.App.World.DeferredLiveEntityParentAcceptance? parentAcceptance = null; LivePresentationResult livePresentation =
// Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag new LivePresentationCompositionPhase(
// and rebuild _worldState so it threads the adapter in. _worldState starts new LivePresentationDependencies(
// as an unadorned GpuWorldState (field initializer); here we replace it with _options,
// one that carries the adapter so AddLandblock/RemoveLandblock notify WB. platform.Graphics,
// Phase N.4 Task 17: also construct EntitySpawnAdapter for server-spawned _window!,
// per-instance content under the same flag. _datLock,
// N.5 mandatory path: spawn adapters + dispatcher always construct. _runtimeSettings,
// _wbMeshAdapter, _meshShader, _textureCache, and _bindlessSupport are _hostQuiescence,
// all guaranteed non-null here (startup throws above if any are missing). _physicsEngine,
var liveEntityComponentLifecycle =
new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle();
AcDream.App.Physics.LiveEntityMotionRuntimeController?
liveEntityMotionRuntime = null;
{
var wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter!);
// Sequencer factory: look up Setup + MotionTable from dats and build
// an AnimationSequencer. Falls back to a no-op sequencer when the
// entity has no motion table (static props, etc.). Uses _animLoader
// which is initialised earlier in OnLoad; it is non-null here.
var capturedDats = _dats;
var capturedAnimLoader = _animLoader;
AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
{
if (capturedDats is not null && capturedAnimLoader is not null)
{
var setup = capturedDats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
uint mtableId = (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
if (mtable is not null)
return new AcDream.Core.Physics.AnimationSequencer(setup, mtable, capturedAnimLoader);
}
// Setup exists but no motion table — no-op sequencer.
return new AcDream.Core.Physics.AnimationSequencer(
setup,
new DatReaderWriter.DBObjs.MotionTable(),
capturedAnimLoader);
}
}
// Complete fallback: empty setup + empty motion table + null loader.
return new AcDream.Core.Physics.AnimationSequencer(
new DatReaderWriter.DBObjs.Setup(),
new DatReaderWriter.DBObjs.MotionTable(),
new NullAnimLoader());
}
var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache!, SequencerFactory, _wbMeshAdapter!);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
// Phase C.1.5a/b: construct EntityScriptActivator so static entities
// (server-spawned AND dat-hydrated) fire Setup.DefaultScript through
// the PhysicsScriptRunner on enter-world. C.1.5b adds per-part
// transforms from the entity's exact current MeshRefs so
// multi-emitter scripts distribute across mesh parts (closes #56).
// Animated frames replace this snapshot through EntityEffectPoseRegistry.
// _scriptRunner and
// _particleSink are initialised earlier in OnLoad (line ~1083); both
// are non-null here. The resolver lambda captures _dats and swallows
// dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale.
AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null;
var staticLiveRootCommitter = new StaticLiveRootCommitter(
_liveEntityRuntimeSlot,
_physicsEngine.ShadowObjects,
_liveWorldOrigin,
_effectPoses);
var staticAnimationResidency = new LiveStaticAnimationResidency(
_liveEntityRuntimeSlot);
_staticAnimationScheduler = new RetailStaticAnimatingObjectScheduler(
capturedAnimLoader!,
_animationHookFrames!.Capture,
_effectPoses.Publish,
staticAnimationResidency.IsResident,
(entity, body) =>
_ = staticLiveRootCommitter.Commit(entity, body),
staticAnimationResidency.ProjectionVersion);
AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e)
{
try
{
DatReaderWriter.DBObjs.Setup? setup =
capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(
e.SourceGfxObjOrSetupId);
if (setup is null) return null;
uint scriptId = setup.DefaultScript.DataId;
if (e.IndexedPartTransforms.Count == 0)
{
var indexed = AcDream.App.Rendering.Vfx.IndexedSetupPartPoseBuilder.Build(
setup,
e);
e.SetIndexedPartPoses(indexed.Poses, indexed.Available);
}
IReadOnlyList<System.Numerics.Matrix4x4> parts = e.IndexedPartTransforms;
IReadOnlyList<bool> availability = e.IndexedPartAvailable;
var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup);
bool usesStaticAnimationWorkset = e.ServerGuid == 0
|| (_liveEntities?.TryGetRecord(
e.ServerGuid,
out LiveEntityRecord liveRecord) == true
&& (liveRecord.FinalPhysicsState
& AcDream.Core.Physics.PhysicsStateFlags.Static) != 0);
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(
scriptId,
parts,
profile,
availability,
setup,
(uint)setup.DefaultAnimation,
usesStaticAnimationWorkset);
}
catch
{
return null;
}
}
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
_scriptRunner!,
_particleSink!,
_effectPoses,
ResolveActivation,
(ownerId, entity, profile) =>
entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile),
ownerId =>
{
entityEffects?.OnDatStaticEntityRemoved(ownerId);
_lightingSink?.UnregisterOwner(ownerId);
_translucencyFades.ClearEntity(ownerId);
},
(entity, info) => _staticAnimationScheduler.Register(entity, info),
ownerId => _staticAnimationScheduler.Unregister(ownerId),
(entity, info) => _staticAnimationScheduler.Rebind(entity, info));
_entityScriptActivator = entityScriptActivator;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
// so Tier 1 cache entries get swept on LB demote (Near to Far) and unload.
// Per spec §5.3 W3b. The callback receives the canonical landblock id
// matching the LandblockHint stored at Populate time.
_worldState = new AcDream.App.Streaming.GpuWorldState(
wbSpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
entityScriptActivator: entityScriptActivator);
_devToolsComposition?.LateBindings.WorldEntities.Bind(_worldState);
_liveEntities = new AcDream.App.World.LiveEntityRuntime(
_worldState,
new AcDream.App.World.CompositeLiveEntityResourceLifecycle(
new(
entity => wbEntitySpawnAdapter.OnCreate(entity),
entity => wbEntitySpawnAdapter.OnRemove(entity)),
new(
entityScriptActivator.OnCreate,
entityScriptActivator.OnRemove)),
liveEntityComponentLifecycle);
_liveEntityRuntimeSlot.Bind(_liveEntities);
liveEntityMotionRuntime =
new AcDream.App.Physics.LiveEntityMotionRuntimeController(
_liveEntities,
_physicsDataCache, _physicsDataCache,
() => _selectionInteractions,
_selection,
_liveWorldOrigin);
_liveEntityMotionBindings.Bind(liveEntityMotionRuntime);
_liveEntities.ProjectionVisibilityChanged += (record, visible) =>
{
if (record.WorldEntity is { } entity)
wbEntitySpawnAdapter.SetPresentationResident(entity, visible);
};
_liveEntities.ProjectionVisibilityChanged += (record, visible) =>
{
if (record.WorldEntity is { } entity)
_particleSink!.SetEntityPresentationVisible(entity.Id, visible);
};
_projectileController = new AcDream.App.Physics.ProjectileController(
_liveEntities,
_physicsEngine,
new AcDream.App.Physics.DatProjectileSetupResolver(_dats!, _datLock),
new EntityRootPosePublisher(_effectPoses),
_liveWorldOrigin)
{
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
};
_liveEntityProjectionWithdrawal =
new AcDream.App.World.LiveEntityProjectionWithdrawalController(
_liveEntities,
_projectileController,
_worldGameState, _worldGameState,
_worldEvents, _worldEvents,
_physicsEngine.ShadowObjects,
_effectPoses,
_localPlayerShadow);
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
_liveEntities,
_effectPoses,
_lightingSink!,
setupId => _dats!.Get<DatReaderWriter.DBObjs.Setup>(setupId));
var ordinaryPhysicsUpdater =
new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater(
_physicsEngine,
_liveEntityMotionBindings.GetSetupCylinder);
_liveAnimationScheduler = new LiveEntityAnimationScheduler(
_liveEntities,
_localPlayerIdentity,
_remotePhysicsUpdater,
ordinaryPhysicsUpdater,
_projectileController,
new EntityRootPosePublisher(_effectPoses),
new AnimationHookCaptureSink(_animationHookFrames!));
_animationPresenter = new LiveEntityAnimationPresenter(
_liveEntities,
_staticAnimationScheduler,
_effectPoses,
new LiveAnimationPresentationContext(
_liveEntities,
_localPlayerIdentity,
_playerControllerSlot),
_animationDiagnostics,
_options.HidePartIndex);
parentAcceptance = new AcDream.App.World.DeferredLiveEntityParentAcceptance();
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!,
_datLock,
Objects,
_liveEntities,
_effectPoses,
parentAcceptance.TryAccept,
(childRecord, positionVersion, projectionVersion) =>
_liveEntityProjectionWithdrawal!.WithdrawExact(
childRecord,
positionVersion,
projectionVersion,
_playerServerGuid));
var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver(
id => _dats!.Get<DatReaderWriter.DBObjs.PhysicsScriptTable>(id));
entityEffects = new AcDream.App.Rendering.Vfx.EntityEffectController(
_liveEntities,
_scriptRunner!,
tableResolver,
_effectPoses,
(parentLocalId, partIndex) =>
_equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex),
childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId),
ownerId => _entitySoundTables?.Remove(ownerId),
(ownerId, soundTableDid) =>
{
_entitySoundTables?.Remove(ownerId);
if (soundTableDid is { } did)
_entitySoundTables?.Set(ownerId, did);
});
_entityEffects = entityEffects;
_entityEffectAdvance.Bind(entityEffects);
entityEffects.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}");
var partArrayLifecycle =
new AcDream.App.Rendering.LiveEntityPartArrayLifecycle(
_animatedEntities);
_liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController(
_liveEntities,
_physicsEngine.ShadowObjects,
entityEffects.PlayTypedFromHiddenTransition,
_equippedChildRenderer.SetDirectChildrenNoDraw,
_liveEntityMotionBindings.ClearTargetForHiddenEntity,
_liveWorldOrigin.GetCenter,
handlePartArrayEnterWorld: partArrayLifecycle.HandleEnterWorld);
var remoteShadowPlacement =
new AcDream.App.Physics.RemoteShadowPlacementSynchronizer(
_remotePhysicsUpdater,
_liveWorldOrigin);
var remoteTeleportPresentation =
new AcDream.App.Physics.RemoteTeleportPlacementPresentation(
_liveEntityPresentation);
_remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController(
_physicsEngine,
_liveEntities,
_liveEntityMotionBindings.GetSetupCylinder,
_liveWorldOrigin.CellLocalForSeed,
remoteShadowPlacement.Sync,
remoteTeleportPresentation.Complete,
remoteTeleportPresentation.Begin);
_equippedChildRenderer.ProjectionPoseReady += guid =>
_liveEntityLights.OnAttachedPoseReady(guid);
_hookRegistrations!.Register(entityEffects);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
_classificationCache, _translucencyFades,
_retailSelectionScene ??= new AcDream.App.Rendering.Selection.RetailSelectionScene(
new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache(
_dats!, _datLock)),
_retailAlphaQueue);
_worldSelectionQuery ??= new AcDream.App.Interaction.WorldSelectionQuery(
_liveEntities,
Objects,
_retailSelectionScene,
() => _localPlayerIdentity.ServerGuid,
interactionUi.LateBindings.SelectionCamera.Snapshot,
() => new System.Numerics.Vector2(
_pointerPosition.X,
_pointerPosition.Y),
() => _playerControllerSlot.Controller is { } player
? new AcDream.App.Interaction.PlayerInteractionPose(
player.CellId,
player.Position)
: null,
_liveEntityMotionBindings.GetSetupCylinder,
setupId =>
{
lock (_datLock)
{
if (!contentEffectsAudio.Dats.TryGet<DatReaderWriter.DBObjs.Setup>(
setupId,
out var setup)
|| setup.SelectionSphere is not { } sphere)
{
return null;
}
return (sphere.Origin, sphere.Radius);
}
});
var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
() => _liveEntities.WorldEntities,
() => _liveEntities.Snapshots,
playerGuid: () => _localPlayerIdentity.ServerGuid,
playerYawRadians: () => _playerControllerSlot.Controller?.Yaw ?? 0f,
playerCellId: () => _playerControllerSlot.Controller?.CellId ?? 0u,
selectedGuid: () => _selection.SelectedObjectId,
coordinatesOnRadar: () =>
_runtimeSettings.Gameplay.CoordinatesOnRadar,
uiLocked: () => _runtimeSettings.Gameplay.LockUI,
playerEntities: () => _liveEntities.MaterializedWorldEntities,
spatialQuery: () => _worldState);
interactionUi.LateBindings.AdoptLateOwnerBinding(
"radar snapshot",
interactionUi.LateBindings.Radar.Bind(radarSnapshotProvider));
if (_itemInteractionController is { } itemInteractions)
{
_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(
_selection, _selection,
_worldSelectionQuery, Objects,
itemInteractions, _liveEntityRuntimeSlot,
new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport( _liveEntityMotionBindings,
() => interactionUi.LateBindings.Session.CurrentSession), _entityEffectAdvance,
new AcDream.App.Interaction.PlayerInteractionMovementSink( _effectPoses,
() => _playerControllerSlot.Controller, _remotePhysicsUpdater,
_playerApproachCompletions), _localPlayerShadow,
compositionToast,
_playerApproachCompletions);
interactionUi.LateBindings.AdoptLateOwnerBinding(
"world selection",
interactionUi.LateBindings.Selection.Bind(
_worldSelectionQuery,
_selectionInteractions));
}
if (_uiHost is { } retainedHost
&& _selectionInteractions is { } selectionInteractions)
{
_retainedUiGameplayBinding =
AcDream.App.Input.RetainedUiGameplayBinding.Create(
retainedHost.Root,
(item, x, y) =>
selectionInteractions.PlaceDraggedItem(item, x, y),
_hostQuiescence);
_retainedUiGameplayBinding.Attach();
}
// A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage =
_runtimeSettings.ResolvedQuality.AlphaToCoverage;
// Slice 2: now that the dispatcher exists, build the paperdoll doll RTT renderer and hand it to
// the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only
// blits the texture; the pre-UI hook drives the 3-D pass (see OnRender).
if (_retailUiRuntime?.PaperdollViewportWidget is { } paperdollViewport
&& _retailUiRuntime.InventoryFrame is { } paperdollInventoryFrame
&& _sceneLightingUbo is not null)
{
_paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer(
_gl, _wbDrawDispatcher, _sceneLightingUbo, _textureCache!);
paperdollViewport.Renderer = _paperdollViewportRenderer;
_paperdollFramePresenter =
new AcDream.App.Rendering.PaperdollFramePresenter(
_paperdollViewportRenderer,
new AcDream.App.Rendering.RetailPaperdollFrameView(
paperdollViewport,
new AcDream.App.Rendering.PaperdollInventoryVisibility(
paperdollInventoryFrame)),
new AcDream.App.Rendering.RetailPaperdollDollFactory(
new AcDream.App.Rendering.LivePaperdollEntityLookup(
_liveEntities),
_localPlayerIdentity,
new AcDream.App.Rendering.RetailPaperdollPoseApplicator(
_dats!,
_animLoader!,
_datLock)));
}
// Phase A8: EnvCellRenderer init. Shares _meshShader (mesh_modern.{vert,frag})
// with the dispatcher — both consume the same global mesh buffer (VAO/IBO)
// from ObjectMeshManager.GlobalBuffer.
_envCellFrustum = new AcDream.App.Rendering.Wb.WbFrustum();
_envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer(
_gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum);
_envCellRenderer.Initialize(_meshShader!);
var landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher(
publishTerrain: (landblockId, meshData, origin) =>
_terrain!.AddLandblockWithMesh(landblockId, meshData, origin),
removeTerrain: landblockId => _terrain!.RemoveLandblock(landblockId),
cellVisibility: _cellVisibility,
worldState: _worldState,
commitEnvCells: _envCellRenderer.CommitLandblock,
prepareEnvCells: build =>
AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(
build,
_wbMeshAdapter.MeshManager!),
removeEnvCells: _envCellRenderer.RemoveLandblock);
var landblockPhysicsPublisher =
new AcDream.App.Streaming.LandblockPhysicsPublisher(
_physicsEngine,
_heightTable!);
var landblockStaticPresentationPublisher =
new AcDream.App.Streaming.LandblockStaticPresentationPublisher(
_lightingSink!,
_translucencyFades,
_worldGameState,
_worldEvents);
var landblockRetirementOwner =
new AcDream.App.Streaming.LandblockPresentationRetirementOwner(
landblockRenderPublisher,
landblockPhysicsPublisher,
landblockStaticPresentationPublisher,
_lightingSink!,
_translucencyFades);
_landblockPresentationPipeline =
new AcDream.App.Streaming.LandblockPresentationPipeline(
landblockRenderPublisher,
landblockPhysicsPublisher,
landblockStaticPresentationPublisher,
_worldState,
landblockRetirementOwner,
onLandblockLoaded: loadedLandblockId =>
_liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
ensureEnvCellMeshes:
landblockRenderPublisher.PrepareAfterRenderPins);
_clipFrame ??= ClipFrame.NoClip();
// T1: invisible portal depth writes (seal/punch) — retail
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
_portalTunnelFallback.AcquirePrepared(
() => AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired(
_gl,
_dats!,
_animLoader!,
_hookRouter,
_wbDrawDispatcher,
_sceneLightingUbo!,
_wbMeshAdapter!),
static portalTunnel => portalTunnel.PrepareResources());
// Publish the presentation before touching the mesh-reference
// backend. Partial acquisition remains reachable by staged
// shutdown instead of becoming constructor-local leaked state.
}
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
// with depth writes off + far plane 1e6 so celestial meshes
// never clip. Shares the TextureCache with the static pipeline.
var skyShader = _renderResourceLifetime.AcquireSkyShader(
() => new Shader(
_gl,
Path.Combine(shadersDir, "sky.vert"),
Path.Combine(shadersDir, "sky.frag")));
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
_gl,
contentEffectsAudio.Dats,
skyShader,
_textureCache!,
_samplerCache!);
// Phase G.1 particle renderer — renders rain / snow / spell auras
// spawned into the shared ParticleSystem. Mode-1/no-degrade GfxObjs
// retain their authored mesh; Always2D degrade modes use billboards.
// Weather uses AttachLocal emitters so its volume follows the player.
_particleRenderer = new ParticleRenderer(
_gl,
shadersDir,
contentEffectsAudio.ParticleSystem,
_textureCache,
contentEffectsAudio.Dats,
_wbMeshAdapter,
_retailAlphaQueue);
AcDream.App.Rendering.IRenderFrameResourceDiagnosticsSource? resourceDiagnostics =
_options.UiProbeDump
? new AcDream.App.Rendering.RuntimeRenderFrameResourceDiagnosticsSource(
_particleSystem,
_particleSink,
_wbDrawDispatcher,
_envCellRenderer,
_particleRenderer,
_uiHost?.TextRenderer,
_portalDepthMask,
_clipFrame,
_terrain,
_sceneLightingUbo,
_wbMeshAdapter,
_textureCache)
: null;
_renderFrameDiagnostics =
new AcDream.App.Rendering.RenderFrameDiagnosticsController(
new AcDream.App.Rendering.RuntimeRenderFrameTitleFactsSource(
_worldState,
_animatedEntities, _animatedEntities,
WorldTime), _animationDiagnostics,
new AcDream.App.Rendering.SilkRenderFrameTitleSink(_window!), _classificationCache,
_renderDiagnosticLog, _translucencyFades,
_options.UiProbeDump, _retailAlphaQueue,
resourceDiagnostics); _cellVisibility,
_devToolsComposition?.LateBindings.FrameDiagnostics.Bind( _liveWorldOrigin,
_renderFrameDiagnostics); _localPlayerIdentity,
interactionUi.LateBindings.AdoptLateOwnerBinding( _playerControllerSlot,
"render-frame diagnostics", _pointerPosition,
_uiFrameDiagnostics.BindOwned(_renderFrameDiagnostics)); _playerApproachCompletions,
_renderResourceLifetime,
_portalTunnelFallback,
_hookRouter,
_renderDiagnosticLog,
WorldTime,
settingsDevTools.DevTools?.LateBindings.WorldEntities,
settingsDevTools.DevTools?.LateBindings.FrameDiagnostics,
_uiFrameDiagnostics,
compositionToast),
this).Compose(
hostInputCamera,
contentEffectsAudio,
worldRender,
interactionUi);
// Apply radii from the same immutable quality snapshot used for the // Apply radii from the same immutable quality snapshot used for the
// window's MSAA, terrain anisotropy, and dispatcher A2C setup. // window's MSAA, terrain anisotropy, and dispatcher A2C setup.
@ -1792,7 +1384,7 @@ public sealed class GameWindow :
_liveSessionController); _liveSessionController);
var liveEntityTeardown = var liveEntityTeardown =
new AcDream.App.World.LiveEntityRuntimeTeardownController( new AcDream.App.World.LiveEntityRuntimeTeardownController(
_liveEntities, livePresentation.LiveEntities,
_liveEntityPresentation!, _liveEntityPresentation!,
_entityEffects!, _entityEffects!,
_remoteTeleportController!, _remoteTeleportController!,
@ -1807,23 +1399,23 @@ public sealed class GameWindow :
_liveEntityLights!, _liveEntityLights!,
_classificationCache, _classificationCache,
_localPlayerIdentity); _localPlayerIdentity);
liveEntityComponentLifecycle.Bind(liveEntityTeardown); livePresentation.ComponentLifecycle.Bind(liveEntityTeardown);
var liveEntityDeletion = var liveEntityDeletion =
new AcDream.App.World.LiveEntityDeletionController( new AcDream.App.World.LiveEntityDeletionController(
_liveEntities, livePresentation.LiveEntities,
Objects, Objects,
liveEntityTeardown, liveEntityTeardown,
_localPlayerIdentity, _localPlayerIdentity,
_options.DumpLiveSpawns ? Console.WriteLine : null); _options.DumpLiveSpawns ? Console.WriteLine : null);
_liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController( _liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController(
_liveEntities, livePresentation.LiveEntities,
Objects, Objects,
_datLock, _datLock,
projectionMaterializer, projectionMaterializer,
new AcDream.App.World.LiveEntityRelationshipProjection( new AcDream.App.World.LiveEntityRelationshipProjection(
_equippedChildRenderer), livePresentation.EquippedChildren),
new AcDream.App.World.LiveEntityReadyPublisher( new AcDream.App.World.LiveEntityReadyPublisher(
_liveEntities, livePresentation.LiveEntities,
_entityEffects!, _entityEffects!,
_liveEntityPresentation!), _liveEntityPresentation!),
originCoordinator, originCoordinator,
@ -1832,9 +1424,12 @@ public sealed class GameWindow :
_localPlayerIdentity, _localPlayerIdentity,
liveEntityDeletion, liveEntityDeletion,
_options.DumpLiveSpawns ? Console.WriteLine : null); _options.DumpLiveSpawns ? Console.WriteLine : null);
livePresentation.RuntimeBindings.Adopt(
"landblock-loaded hydration",
livePresentation.LandblockLoaded.Bind(_liveEntityHydration));
_liveEntityNetworkUpdates = _liveEntityNetworkUpdates =
new AcDream.App.Physics.LiveEntityNetworkUpdateController( new AcDream.App.Physics.LiveEntityNetworkUpdateController(
_liveEntities, livePresentation.LiveEntities,
Objects, Objects,
_liveEntityHydration, _liveEntityHydration,
_entityEffects!, _entityEffects!,
@ -1849,7 +1444,7 @@ public sealed class GameWindow :
_remoteMovementObservations, _remoteMovementObservations,
_remotePhysicsUpdater, _remotePhysicsUpdater,
_remoteInboundMotion, _remoteInboundMotion,
liveEntityMotionRuntime!, livePresentation.MotionRuntime,
_physicsEngine, _physicsEngine,
_dats!, _dats!,
_animLoader!, _animLoader!,
@ -1865,7 +1460,7 @@ public sealed class GameWindow :
localPhysicsTimestamps.Publish, localPhysicsTimestamps.Publish,
_movementTruthDiagnostics); _movementTruthDiagnostics);
_liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController( _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
_liveEntities, livePresentation.LiveEntities,
_localPlayerIdentity, _localPlayerIdentity,
liveEntityDeletion); liveEntityDeletion);
_liveEntitySessionEvents = new AcDream.App.Net.LiveEntitySessionController( _liveEntitySessionEvents = new AcDream.App.Net.LiveEntitySessionController(
@ -1874,9 +1469,10 @@ public sealed class GameWindow :
_liveEntityNetworkUpdates, _liveEntityNetworkUpdates,
_localPlayerTeleportSink, _localPlayerTeleportSink,
_entityEffects!); _entityEffects!);
parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection); livePresentation.ParentAcceptance.Bind(
_liveEntityHydration.TryAcceptParentForProjection);
networkUpdateBridge.Bind(_liveEntityNetworkUpdates); networkUpdateBridge.Bind(_liveEntityNetworkUpdates);
_equippedChildRenderer.EntityReady += candidate => livePresentation.EquippedChildren.EntityReady += candidate =>
_liveEntityHydration.OnEntityReady(candidate); _liveEntityHydration.OnEntityReady(candidate);
_liveEntityHydration.AppearanceApplied += guid => _liveEntityHydration.AppearanceApplied += guid =>
{ {
@ -1940,7 +1536,7 @@ public sealed class GameWindow :
_streamingController!, _streamingController!,
new AcDream.App.Streaming.LiveProjectionRescueRebucketter( new AcDream.App.Streaming.LiveProjectionRescueRebucketter(
_worldState, _worldState,
_liveEntities)); livePresentation.LiveEntities));
var liveEffectFrame = new AcDream.App.Update.LiveEffectFrameController( var liveEffectFrame = new AcDream.App.Update.LiveEffectFrameController(
_translucencyFades, _translucencyFades,
_animationHookFrames!, _animationHookFrames!,
@ -1961,7 +1557,7 @@ public sealed class GameWindow :
_liveEntityLights!); _liveEntityLights!);
_localPlayerAnimation = _localPlayerAnimation =
new AcDream.App.Input.LocalPlayerAnimationController( new AcDream.App.Input.LocalPlayerAnimationController(
_liveEntities, livePresentation.LiveEntities,
_localPlayerIdentity, _localPlayerIdentity,
_animatedEntities, _animatedEntities,
_animationPresenter, _animationPresenter,
@ -1969,14 +1565,14 @@ public sealed class GameWindow :
_localPlayerShadowSynchronizer = _localPlayerShadowSynchronizer =
new AcDream.App.Physics.LocalPlayerShadowSynchronizer( new AcDream.App.Physics.LocalPlayerShadowSynchronizer(
_physicsEngine, _physicsEngine,
_liveEntities, livePresentation.LiveEntities,
_localPlayerIdentity, _localPlayerIdentity,
_liveWorldOrigin, _liveWorldOrigin,
_localPlayerShadow); _localPlayerShadow);
var localPlayerProjection = var localPlayerProjection =
new AcDream.App.Input.LocalPlayerProjectionController( new AcDream.App.Input.LocalPlayerProjectionController(
new AcDream.App.Input.LiveLocalPlayerProjectionRuntime( new AcDream.App.Input.LiveLocalPlayerProjectionRuntime(
_liveEntities, livePresentation.LiveEntities,
_localPlayerIdentity, _localPlayerIdentity,
_liveWorldOrigin, _liveWorldOrigin,
_localPlayerShadowSynchronizer)); _localPlayerShadowSynchronizer));
@ -1988,7 +1584,7 @@ public sealed class GameWindow :
_chaseCameraInput, _chaseCameraInput,
_movementInput, _movementInput,
_inputCapture, _inputCapture,
_liveEntities, livePresentation.LiveEntities,
_localPlayerIdentity, _localPlayerIdentity,
_playerHostSlot, _playerHostSlot,
localPlayerProjection, localPlayerProjection,
@ -2002,11 +1598,11 @@ public sealed class GameWindow :
_inboundEntityEvents, _inboundEntityEvents,
localPlayerFrame, localPlayerFrame,
_selectionInteractions, _selectionInteractions,
_liveEntities, livePresentation.LiveEntities,
_localPlayerIdentity, _localPlayerIdentity,
_liveWorldOrigin, _liveWorldOrigin,
_liveAnimationScheduler, _liveAnimationScheduler,
_staticAnimationScheduler, livePresentation.StaticAnimationScheduler,
_animationPresenter, _animationPresenter,
_animatedEntities, _animatedEntities,
_equippedChildRenderer!, _equippedChildRenderer!,
@ -2018,7 +1614,7 @@ public sealed class GameWindow :
_chaseCameraInput, _chaseCameraInput,
_cameraController!, _cameraController!,
_physicsEngine, _physicsEngine,
_liveEntities, livePresentation.LiveEntities,
_localPlayerIdentity, _localPlayerIdentity,
_liveWorldOrigin, _liveWorldOrigin,
_liveEntityMotionBindings, _liveEntityMotionBindings,
@ -2040,7 +1636,7 @@ public sealed class GameWindow :
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
new AcDream.App.Input.LivePlayerModeAutoEntryContext( new AcDream.App.Input.LivePlayerModeAutoEntryContext(
_liveSessionController, _liveSessionController,
_liveEntities, livePresentation.LiveEntities,
_localPlayerIdentity, _localPlayerIdentity,
_worldReveal _worldReveal
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
@ -2051,7 +1647,7 @@ public sealed class GameWindow :
_localPlayerTeleport = _portalTunnelFallback.Transfer( _localPlayerTeleport = _portalTunnelFallback.Transfer(
portalTunnel => new AcDream.App.Streaming.LocalPlayerTeleportController( portalTunnel => new AcDream.App.Streaming.LocalPlayerTeleportController(
new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority( new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority(
_liveEntities, livePresentation.LiveEntities,
_localPlayerIdentity), _localPlayerIdentity),
_gameplayInputFrame, _gameplayInputFrame,
_playerModeController, _playerModeController,
@ -2063,7 +1659,7 @@ public sealed class GameWindow :
_worldReveal, _worldReveal,
new AcDream.App.Streaming.LocalPlayerTeleportPlacement( new AcDream.App.Streaming.LocalPlayerTeleportPlacement(
_physicsEngine, _physicsEngine,
_liveEntities, livePresentation.LiveEntities,
_localPlayerIdentity, _localPlayerIdentity,
_playerControllerSlot, _playerControllerSlot,
_playerHostSlot, _playerHostSlot,
@ -2323,7 +1919,7 @@ public sealed class GameWindow :
_worldSelectionQuery!)); _worldSelectionQuery!));
var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator( var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(
new AcDream.App.Update.LiveEntityTeardownFramePhase( new AcDream.App.Update.LiveEntityTeardownFramePhase(
_liveEntities), livePresentation.LiveEntities),
new AcDream.App.Update.ConsoleUpdateFrameFailureSink(), new AcDream.App.Update.ConsoleUpdateFrameFailureSink(),
_updateFrameClock, _updateFrameClock,
new AcDream.App.Update.PhysicsScriptClockPublisher(_scriptRunner!), new AcDream.App.Update.PhysicsScriptClockPublisher(_scriptRunner!),
@ -3039,6 +2635,15 @@ public sealed class GameWindow :
]), ]),
new ResourceShutdownStage("effect dispatch edges", new ResourceShutdownStage("effect dispatch edges",
[ [
new("live-presentation bindings", () =>
{
LivePresentationRuntimeBindings? bindings =
_livePresentationBindings;
if (bindings is null)
return;
bindings.Dispose();
_livePresentationBindings = null;
}),
new("entity-effect advance source", () => new("entity-effect advance source", () =>
{ {
if (_entityEffects is { } effects) if (_entityEffects is { } effects)
@ -3232,15 +2837,4 @@ public sealed class GameWindow :
_window = null; _window = null;
} }
/// <summary>
/// Fallback <see cref="AcDream.Core.Physics.IAnimationLoader"/> for the
/// <see cref="AcDream.App.Rendering.Wb.EntitySpawnAdapter"/> sequencer
/// factory when neither <c>_dats</c> nor the entity's setup is available.
/// Returns null for all animation lookups so the sequencer silently has
/// no data (same behaviour as a new empty Setup).
/// </summary>
private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader
{
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
}
} }

View file

@ -33,6 +33,12 @@ internal sealed class DeferredEntityEffectAdvanceSource : IEntityEffectAdvanceSo
} }
} }
public IDisposable BindOwned(IEntityEffectAdvanceSource target)
{
Bind(target);
return new Binding(this, target);
}
public void Unbind(IEntityEffectAdvanceSource target) public void Unbind(IEntityEffectAdvanceSource target)
{ {
ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(target);
@ -60,4 +66,21 @@ internal sealed class DeferredEntityEffectAdvanceSource : IEntityEffectAdvanceSo
|| _target?.CanAdvanceOwner(ownerLocalId) != false; || _target?.CanAdvanceOwner(ownerLocalId) != false;
} }
} }
private sealed class Binding : IDisposable
{
private DeferredEntityEffectAdvanceSource? _owner;
private readonly IEntityEffectAdvanceSource _target;
public Binding(
DeferredEntityEffectAdvanceSource owner,
IEntityEffectAdvanceSource target)
{
_owner = owner;
_target = target;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_target);
}
} }

View file

@ -0,0 +1,66 @@
namespace AcDream.App.World;
internal interface ILiveEntityLandblockLoadedSink
{
void OnLandblockLoaded(uint landblockId);
}
/// <summary>
/// Phase-6 bridge for the Phase-7 hydration owner. The presentation pipeline
/// can publish a loaded landblock without capturing a future nullable owner.
/// </summary>
internal sealed class DeferredLiveEntityLandblockLoadedSink
: ILiveEntityLandblockLoadedSink
{
private ILiveEntityLandblockLoadedSink? _target;
private bool _deactivated;
public void OnLandblockLoaded(uint landblockId)
{
if (!_deactivated)
_target?.OnLandblockLoaded(landblockId);
}
public IDisposable Bind(ILiveEntityLandblockLoadedSink target)
{
ArgumentNullException.ThrowIfNull(target);
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null)
{
throw new InvalidOperationException(
"Live-entity landblock hydration is already bound.");
}
_target = target;
return new Binding(this, target);
}
public void Deactivate()
{
_deactivated = true;
_target = null;
}
private void Unbind(ILiveEntityLandblockLoadedSink expected)
{
if (ReferenceEquals(_target, expected))
_target = null;
}
private sealed class Binding : IDisposable
{
private DeferredLiveEntityLandblockLoadedSink? _owner;
private readonly ILiveEntityLandblockLoadedSink _expected;
public Binding(
DeferredLiveEntityLandblockLoadedSink owner,
ILiveEntityLandblockLoadedSink expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}

View file

@ -137,7 +137,7 @@ internal interface ILiveEntityWorldOriginCoordinator
/// Retail anchors: <c>SmartBox::HandleCreateObject @ 0x00454C80</c> and /// Retail anchors: <c>SmartBox::HandleCreateObject @ 0x00454C80</c> and
/// <c>ACCObjectMaint::CreateObject @ 0x00558870</c>. /// <c>ACCObjectMaint::CreateObject @ 0x00558870</c>.
/// </summary> /// </summary>
internal sealed class LiveEntityHydrationController internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoadedSink
{ {
private readonly LiveEntityRuntime _runtime; private readonly LiveEntityRuntime _runtime;
private readonly ClientObjectTable _objects; private readonly ClientObjectTable _objects;

View file

@ -21,4 +21,31 @@ internal sealed class LiveEntityRuntimeSlot : ILiveEntityRuntimeSource
throw new InvalidOperationException("The live entity runtime is already bound."); throw new InvalidOperationException("The live entity runtime is already bound.");
Current = runtime; Current = runtime;
} }
public IDisposable BindOwned(LiveEntityRuntime runtime)
{
Bind(runtime);
return new Binding(this, runtime);
}
private void Unbind(LiveEntityRuntime expected)
{
if (ReferenceEquals(Current, expected))
Current = null;
}
private sealed class Binding : IDisposable
{
private LiveEntityRuntimeSlot? _owner;
private readonly LiveEntityRuntime _expected;
public Binding(LiveEntityRuntimeSlot owner, LiveEntityRuntime expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
} }

View file

@ -64,6 +64,36 @@ public sealed class AnimationHookRegistrationSetTests
registrations.Register(new Sink("late"))); registrations.Register(new Sink("late")));
} }
[Fact]
public void OwnedRegistrationReleasesExactlyAndRetriesItsOwnFailure()
{
int adds = 0;
int removes = 0;
bool fail = true;
var sink = new Sink("owned");
var registrations = new AnimationHookRegistrationSet(
_ => adds++,
_ =>
{
removes++;
if (fail)
throw new InvalidOperationException("retry");
});
IDisposable binding = registrations.RegisterOwned(sink);
Assert.Throws<InvalidOperationException>(() =>
registrations.RegisterOwned(sink));
Assert.Throws<InvalidOperationException>(binding.Dispose);
fail = false;
binding.Dispose();
binding.Dispose();
registrations.Dispose();
Assert.Equal(1, adds);
Assert.Equal(2, removes);
Assert.True(registrations.IsCleanupComplete);
}
private sealed class Sink(string name) : IAnimationHookSink private sealed class Sink(string name) : IAnimationHookSink
{ {
public string Name { get; } = name; public string Name { get; } = name;

View file

@ -0,0 +1,200 @@
using AcDream.App.Composition;
using AcDream.App.Physics;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
namespace AcDream.App.Tests.Composition;
public sealed class LivePresentationCompositionTests
{
[Fact]
public void RuntimeBindingsReleaseInReverseAndRetryOnlyFailedEdges()
{
var calls = new List<string>();
var bindings = new LivePresentationRuntimeBindings();
var first = new RetryBinding("first", calls, failures: 0);
var second = new RetryBinding("second", calls, failures: 1);
bindings.Adopt("first", first);
bindings.Adopt("second", second);
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 CanonicalRuntimeSlotUsesExactOwnerBinding()
{
var slot = new LiveEntityRuntimeSlot();
LiveEntityRuntime first = Runtime();
LiveEntityRuntime second = Runtime();
IDisposable stale = slot.BindOwned(first);
Assert.Same(first, slot.Current);
Assert.Throws<InvalidOperationException>(() => slot.BindOwned(second));
stale.Dispose();
IDisposable current = slot.BindOwned(second);
stale.Dispose();
Assert.Same(second, slot.Current);
current.Dispose();
Assert.Null(slot.Current);
}
[Fact]
public void MotionBindingCannotBeClearedByAStaleOwner()
{
var source = new DeferredLiveEntityMotionRuntimeBindings();
var first = new MotionRuntime(1f);
var second = new MotionRuntime(2f);
var entity = new WorldEntity
{
Id = 1u,
SourceGfxObjOrSetupId = 1u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
IDisposable stale = source.BindOwned(first);
Assert.Equal(1f, source.GetSetupCylinder(1u, entity).Radius);
Assert.Throws<InvalidOperationException>(() => source.BindOwned(second));
stale.Dispose();
IDisposable current = source.BindOwned(second);
stale.Dispose();
Assert.Equal(2f, source.GetSetupCylinder(1u, entity).Radius);
current.Dispose();
Assert.Throws<InvalidOperationException>(() =>
source.GetSetupCylinder(1u, entity));
}
[Fact]
public void LandblockLoadedBridgeIsInertUntilBoundAndDeactivationIsTerminal()
{
var source = new DeferredLiveEntityLandblockLoadedSink();
var first = new LandblockSink();
var second = new LandblockSink();
source.OnLandblockLoaded(1u);
IDisposable stale = source.Bind(first);
source.OnLandblockLoaded(2u);
Assert.Equal([2u], first.Landblocks);
Assert.Throws<InvalidOperationException>(() => source.Bind(second));
stale.Dispose();
IDisposable current = source.Bind(second);
stale.Dispose();
source.OnLandblockLoaded(3u);
Assert.Equal([3u], second.Landblocks);
source.Deactivate();
current.Dispose();
source.OnLandblockLoaded(4u);
Assert.Equal([3u], second.Landblocks);
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
}
[Fact]
public void GameWindowUsesLivePhaseAndContainsNoPhaseSixConstructionBody()
{
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",
"LivePresentationComposition.cs"));
Assert.Contains("new LivePresentationCompositionPhase(", window);
Assert.DoesNotContain("new AcDream.App.World.LiveEntityRuntime(", window);
Assert.DoesNotContain("new AcDream.App.Rendering.Wb.WbDrawDispatcher(", window);
Assert.DoesNotContain("new AcDream.App.Streaming.LandblockRenderPublisher(", window);
Assert.DoesNotContain("_portalTunnelFallback.AcquirePrepared(", window);
Assert.Contains("new LiveEntityRuntime(", phase);
Assert.Contains("new WbDrawDispatcher(", phase);
Assert.Contains("new LandblockRenderPublisher(", phase);
Assert.Contains("d.PortalTunnelFallback.AcquirePrepared(", phase);
}
private static LiveEntityRuntime Runtime() => new(
new GpuWorldState(),
new DelegateLiveEntityResourceLifecycle(static _ => { }, static _ => { }));
private sealed class MotionRuntime(float radius)
: ILiveEntityMotionRuntimeBindings
{
public (float Radius, float Height) GetSetupCylinder(
uint serverGuid,
WorldEntity entity) => (radius, 1f);
public bool RouteServerMoveTo(
MovementManager movement,
uint cellId,
WorldSession.EntityMotionUpdate update) => false;
public void StickToObjectFromWire(
IPhysicsObjHost? host,
uint targetGuid)
{
}
public void ClearTargetForHiddenEntity(uint serverGuid)
{
}
public IPhysicsObjHost? ResolvePhysicsHost(
uint serverGuid) => null;
}
private sealed class LandblockSink : ILiveEntityLandblockLoadedSink
{
public List<uint> Landblocks { get; } = [];
public void OnLandblockLoaded(uint landblockId) => Landblocks.Add(landblockId);
}
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)
{
_failures--;
throw new InvalidOperationException("retry");
}
}
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -93,7 +93,7 @@ public sealed class GameWindowRenderLeafCompositionTests
foreach (string identifier in removed) foreach (string identifier in removed)
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal); Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source); Assert.Contains("new PaperdollFramePresenter(", LivePresentationSource());
string settingsComposition = File.ReadAllText(Path.Combine( string settingsComposition = File.ReadAllText(Path.Combine(
FindRepoRoot(), FindRepoRoot(),
"src", "src",
@ -193,13 +193,13 @@ public sealed class GameWindowRenderLeafCompositionTests
[Fact] [Fact]
public void PaperdollComposition_SkipsEitherMissingOptionalUiSurface() public void PaperdollComposition_SkipsEitherMissingOptionalUiSurface()
{ {
string source = GameWindowSource(); string source = LivePresentationSource();
AssertAppearsInOrder( AssertAppearsInOrder(
source, source,
"PaperdollViewportWidget is { } paperdollViewport", "PaperdollViewportWidget is { } viewport",
"InventoryFrame is { } paperdollInventoryFrame", "InventoryFrame is { } inventoryFrame",
"new AcDream.App.Rendering.PaperdollFramePresenter("); "new PaperdollFramePresenter(");
Assert.DoesNotContain("Paperdoll inventory frame is required.", source); Assert.DoesNotContain("Paperdoll inventory frame is required.", source);
} }
@ -223,6 +223,13 @@ public sealed class GameWindowRenderLeafCompositionTests
"Rendering", "Rendering",
"GameWindow.cs")); "GameWindow.cs"));
private static string LivePresentationSource() => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
private static string Source(string fileName) => File.ReadAllText(Path.Combine( private static string Source(string fileName) => File.ReadAllText(Path.Combine(
FindRepoRoot(), FindRepoRoot(),
"src", "src",

View file

@ -61,11 +61,9 @@ public sealed class GameWindowSlice8BoundaryTests
"InteractionRetainedUiResult interactionUi =", "InteractionRetainedUiResult interactionUi =",
"new InteractionRetainedUiCompositionPhase(", "new InteractionRetainedUiCompositionPhase(",
"this).Compose(", "this).Compose(",
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(", "LivePresentationResult livePresentation =",
"_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(", "new LivePresentationCompositionPhase(",
"_retainedUiGameplayBinding =", "this).Compose(",
"AcDream.App.Input.RetainedUiGameplayBinding.Create(",
"_retainedUiGameplayBinding.Attach();",
"var renderFrameOrchestrator =", "var renderFrameOrchestrator =",
"var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(", "var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
"_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);", "_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);",
@ -193,11 +191,11 @@ public sealed class GameWindowSlice8BoundaryTests
"AcDream.App.Input.GameplayInputActionRouter.Create(")); "AcDream.App.Input.GameplayInputActionRouter.Create("));
Assert.Equal(1, CountOccurrences(load, "_gameplayInputActions.Attach();")); Assert.Equal(1, CountOccurrences(load, "_gameplayInputActions.Attach();"));
Assert.Equal(1, CountOccurrences( Assert.Equal(1, CountOccurrences(
load, LivePresentationSource(),
"AcDream.App.Input.RetainedUiGameplayBinding.Create(")); "RetainedUiGameplayBinding.Create("));
Assert.Equal(1, CountOccurrences( Assert.Equal(1, CountOccurrences(
load, LivePresentationSource(),
"_retainedUiGameplayBinding.Attach();")); "retainedGameplayLease.Resource.Attach();"));
AssertAppearsInOrder( AssertAppearsInOrder(
shutdown, shutdown,
"_liveCombatModeCommands.Deactivate", "_liveCombatModeCommands.Deactivate",
@ -377,10 +375,9 @@ public sealed class GameWindowSlice8BoundaryTests
source, source,
"private ResourceShutdownTransaction CreateShutdownTransaction()", "private ResourceShutdownTransaction CreateShutdownTransaction()",
"private void OnFocusChanged(bool focused)"); "private void OnFocusChanged(bool focused)");
string dispose = Slice( int disposeStart = source.IndexOf("public void Dispose()", StringComparison.Ordinal);
source, Assert.True(disposeStart >= 0);
"public void Dispose()", string dispose = source[disposeStart..];
"private sealed class NullAnimLoader");
string[] stages = string[] stages =
[ [
@ -474,14 +471,18 @@ public sealed class GameWindowSlice8BoundaryTests
"AcDream.App", "AcDream.App",
"Composition", "Composition",
"WorldRenderComposition.cs")); "WorldRenderComposition.cs"));
string livePhase = LivePresentationSource();
AssertAppearsInOrder(
livePhase,
"d.PortalTunnelFallback.AcquirePrepared(",
"static tunnel => tunnel.PrepareResources());",
"d.RenderResourceLifetime.AcquireSkyShader(",
"() => new SkyRenderer(");
AssertAppearsInOrder( AssertAppearsInOrder(
load, load,
"new WorldRenderCompositionPhase(", "new WorldRenderCompositionPhase(",
"_portalTunnelFallback.AcquirePrepared(", "new LivePresentationCompositionPhase(",
"static portalTunnel => portalTunnel.PrepareResources());",
"_renderResourceLifetime.AcquireSkyShader(",
"_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(",
"_portalTunnelFallback.Transfer(", "_portalTunnelFallback.Transfer(",
"_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);"); "_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);");
AssertAppearsInOrder( AssertAppearsInOrder(
@ -566,6 +567,13 @@ public sealed class GameWindowSlice8BoundaryTests
"Rendering", "Rendering",
"GameWindow.cs")); "GameWindow.cs"));
private static string LivePresentationSource() => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
private static string FindRepoRoot() private static string FindRepoRoot()
{ {
DirectoryInfo? directory = new(AppContext.BaseDirectory); DirectoryInfo? directory = new(AppContext.BaseDirectory);

View file

@ -241,6 +241,12 @@ public sealed class LandblockBuildOriginTests
"AcDream.App", "AcDream.App",
"Rendering", "Rendering",
"GameWindow.cs")); "GameWindow.cs"));
string livePresentationSource = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
string buildSource = File.ReadAllText(Path.Combine( string buildSource = File.ReadAllText(Path.Combine(
root, root,
"src", "src",
@ -307,16 +313,16 @@ public sealed class LandblockBuildOriginTests
gameWindowSource, gameWindowSource,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
"new AcDream.App.Streaming.LandblockRenderPublisher(", "new LandblockRenderPublisher(",
gameWindowSource, livePresentationSource,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
"new AcDream.App.Streaming.LandblockPhysicsPublisher(", "new LandblockPhysicsPublisher(",
gameWindowSource, livePresentationSource,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
"new AcDream.App.Streaming.LandblockStaticPresentationPublisher(", "new LandblockStaticPresentationPublisher(",
gameWindowSource, livePresentationSource,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.DoesNotContain("applyTerrain:", gameWindowSource, StringComparison.Ordinal); Assert.DoesNotContain("applyTerrain:", gameWindowSource, StringComparison.Ordinal);
Assert.DoesNotContain("demoteNearLayer:", gameWindowSource, StringComparison.Ordinal); Assert.DoesNotContain("demoteNearLayer:", gameWindowSource, StringComparison.Ordinal);

View file

@ -158,6 +158,12 @@ public sealed class GameWindowLiveEntityCompositionTests
string source = File.ReadAllText(Path.Combine( string source = File.ReadAllText(Path.Combine(
FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs")); FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs"));
string livePresentation = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
Assert.DoesNotContain( Assert.DoesNotContain(
"new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(() =>", "new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(() =>",
source, source,
@ -166,7 +172,7 @@ public sealed class GameWindowLiveEntityCompositionTests
"new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() =>", "new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() =>",
source, source,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("new AcDream.App.Physics.DatProjectileSetupResolver", source); Assert.Contains("new DatProjectileSetupResolver", livePresentation);
} }
[Fact] [Fact]

View file

@ -337,6 +337,12 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App", "AcDream.App",
"Rendering", "Rendering",
"GameWindow.cs")); "GameWindow.cs"));
string livePresentation = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
Assert.Contains("new AcDream.App.Update.LiveObjectFrameController(", source); Assert.Contains("new AcDream.App.Update.LiveObjectFrameController(", source);
Assert.Contains("new AcDream.App.Update.LiveSpatialPresentationReconciler(", source); Assert.Contains("new AcDream.App.Update.LiveSpatialPresentationReconciler(", source);
@ -771,6 +777,12 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App", "AcDream.App",
"Rendering", "Rendering",
"GameWindow.cs")); "GameWindow.cs"));
string livePresentation = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
Assert.DoesNotContain("PublishLocalPhysicsTimestamps", source, Assert.DoesNotContain("PublishLocalPhysicsTimestamps", source,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.DoesNotContain("LoginWorldReady", source, Assert.DoesNotContain("LoginWorldReady", source,
@ -783,9 +795,9 @@ public sealed class UpdateFrameOrchestratorTests
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.DoesNotContain("OnPlayScriptReceived", source, Assert.DoesNotContain("OnPlayScriptReceived", source,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("_liveWorldOrigin.GetCenter", source, Assert.Contains("d.WorldOrigin.GetCenter", livePresentation,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("_liveWorldOrigin.CellLocalForSeed", source, Assert.Contains("d.WorldOrigin.CellLocalForSeed", livePresentation,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains("_liveEntitySessionEvents.CreateSink()", source, Assert.Contains("_liveEntitySessionEvents.CreateSink()", source,
StringComparison.Ordinal); StringComparison.Ordinal);