diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 66e9e207..c4f3c771 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -153,6 +153,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements;
private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher;
private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher;
+ private AcDream.App.Streaming.LandblockStaticPresentationPublisher?
+ _landblockStaticPresentationPublisher;
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
private AcDream.App.Streaming.StreamingController? _streamingController;
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
@@ -2194,7 +2196,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_translucencyFades.ClearEntity(ownerId);
},
(entity, info) => _staticAnimationScheduler.Register(entity, info),
- ownerId => _staticAnimationScheduler.Unregister(ownerId));
+ ownerId => _staticAnimationScheduler.Unregister(ownerId),
+ (entity, info) => _staticAnimationScheduler.Rebind(entity, info));
_entityScriptActivator = entityScriptActivator;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
@@ -2429,6 +2432,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
new AcDream.App.Streaming.LandblockPhysicsPublisher(
_physicsEngine,
_heightTable!);
+ _landblockStaticPresentationPublisher =
+ new AcDream.App.Streaming.LandblockStaticPresentationPublisher(
+ _lightingSink!,
+ _translucencyFades,
+ _worldGameState,
+ _worldEvents);
_clipFrame ??= ClipFrame.NoClip();
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
@@ -2520,6 +2529,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
});
_streamer.Start();
+ var landblockPresentationPipeline =
+ new AcDream.App.Streaming.LandblockPresentationPipeline(
+ _landblockRenderPublisher!,
+ _landblockPhysicsPublisher!,
+ _landblockStaticPresentationPublisher!,
+ _worldState,
+ onLandblockLoaded: loadedLandblockId =>
+ _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
+ ensureEnvCellMeshes:
+ _landblockRenderPublisher!.PrepareAfterRenderPins,
+ retirementCoordinator: _landblockRetirements);
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(
new AcDream.App.Streaming.LandblockBuildRequest(
@@ -2542,7 +2562,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
onLandblockLoaded: loadedLandblockId =>
_liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
ensureEnvCellMeshes: _landblockRenderPublisher!.PrepareAfterRenderPins,
- retirementCoordinator: _landblockRetirements);
+ retirementCoordinator: _landblockRetirements,
+ presentationPipeline: landblockPresentationPipeline);
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
_worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
@@ -3793,19 +3814,28 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
ticket.RunForEachEntity(
AcDream.App.Streaming.LandblockRetirementStage.EntityLighting,
entity => !staticOnly || entity.ServerGuid == 0,
- entity => _lightingSink?.UnregisterOwner(entity.Id));
+ entity =>
+ {
+ if (entity.ServerGuid == 0)
+ _landblockStaticPresentationPublisher!.RemoveLighting(entity);
+ else
+ _lightingSink?.UnregisterOwner(entity.Id);
+ });
ticket.RunForEachEntity(
AcDream.App.Streaming.LandblockRetirementStage.EntityTranslucency,
entity => !staticOnly || entity.ServerGuid == 0,
- entity => _translucencyFades.ClearEntity(entity.Id));
+ entity =>
+ {
+ if (entity.ServerGuid == 0)
+ _landblockStaticPresentationPublisher!.RemoveTranslucency(entity);
+ else
+ _translucencyFades.ClearEntity(entity.Id);
+ });
ticket.RunForEachEntity(
AcDream.App.Streaming.LandblockRetirementStage.PluginProjection,
static entity => entity.ServerGuid == 0,
- entity =>
- {
- _worldGameState.RemoveById(entity.Id);
- _worldEvents.ForgetEntity(entity.Id);
- });
+ entity => _landblockStaticPresentationPublisher!
+ .RemovePluginProjection(entity));
if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full)
{
diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs
index 3c4ca4ea..ab813a51 100644
--- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs
+++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs
@@ -78,7 +78,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
internal int Count => _owners.Count;
- public void Register(WorldEntity entity, ScriptActivationInfo info)
+ public bool Register(WorldEntity entity, ScriptActivationInfo info)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(info);
@@ -86,13 +86,13 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|| info.Setup is not { } setup
|| info.DefaultAnimationId == 0)
{
- return;
+ return false;
}
if (_owners.TryGetValue(entity.Id, out Owner? retained))
{
if (ReferenceEquals(retained.Entity, entity))
- return;
+ return true;
throw new InvalidOperationException(
$"DAT-static animation owner 0x{entity.Id:X8} is already registered.");
}
@@ -110,7 +110,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
new MotionTable(),
_animationLoader);
if (!sequencer.HasCurrentNode)
- return;
+ return false;
}
int partCount = setup.Parts.Count;
@@ -131,6 +131,63 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
SurfaceOverrides = surfaces,
PartAvailable = available,
});
+ return true;
+ }
+
+ ///
+ /// Rebinds the retained retail static-animation workset member to a fresh
+ /// landblock snapshot without reconstructing its PartArray sequencer or
+ /// restarting Setup.DefaultAnimation.
+ ///
+ public void Rebind(WorldEntity entity, ScriptActivationInfo info)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ ArgumentNullException.ThrowIfNull(info);
+ if (!_owners.TryGetValue(entity.Id, out Owner? owner))
+ {
+ throw new InvalidOperationException(
+ $"DAT-static animation owner 0x{entity.Id:X8} is not registered.");
+ }
+ if (ReferenceEquals(owner.Entity, entity))
+ return;
+ if (entity.ServerGuid != 0
+ || !info.UsesStaticAnimationWorkset
+ || info.Setup is null
+ || info.DefaultAnimationId == 0)
+ {
+ throw new InvalidOperationException(
+ $"DAT-static animation owner 0x{entity.Id:X8} cannot be rebound to incompatible Setup data.");
+ }
+
+ int partCount = owner.Setup.Parts.Count;
+ uint[] gfxIds = BuildPartGfxIds(owner.Setup, entity);
+ bool[] available = new bool[partCount];
+ if (info.PartAvailability is { } supplied)
+ {
+ for (int i = 0; i < partCount && i < supplied.Count; i++)
+ available[i] = supplied[i];
+ }
+
+ // Compose publishes the owner's retained lists directly. Detach the
+ // displaced immutable snapshot before those lists continue advancing
+ // under the replacement entity.
+ WorldEntity displaced = owner.Entity;
+ if (ReferenceEquals(displaced.MeshRefs, owner.MeshRefs))
+ displaced.MeshRefs = owner.MeshRefs.ToArray();
+ if (ReferenceEquals(displaced.IndexedPartTransforms, owner.PartPoses)
+ || ReferenceEquals(displaced.IndexedPartAvailable, owner.PartAvailable))
+ {
+ displaced.SetIndexedPartPoses(
+ owner.PartPoses.ToArray(),
+ owner.PartAvailable.ToArray());
+ }
+
+ owner.Entity = entity;
+ owner.PartGfxIds = gfxIds;
+ owner.SurfaceOverrides = MatchSurfaceOverrides(entity, gfxIds, available);
+ owner.PartAvailable = available;
+ owner.HasPreparedLivePartFrames = false;
+ owner.PreparedLivePartFrames.Clear();
}
///
diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
index f314c9fd..d998be06 100644
--- a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
+++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs
@@ -40,6 +40,12 @@ public sealed class EntityScriptActivator
public bool ScriptsStopped;
public bool EmittersStopped;
public bool PoseRemoved;
+ public WorldEntity? PendingReplacement;
+ public ScriptActivationInfo? PendingReplacementInfo;
+ public bool ReplacementPosePublished;
+ public bool ReplacementEffectOwnerUpdated;
+ public bool ReplacementAnimationOwnerUpdated;
+ public bool ReplacementAnchorUpdated;
}
private readonly PhysicsScriptRunner _scriptRunner;
@@ -48,8 +54,9 @@ public sealed class EntityScriptActivator
private readonly Func _resolver;
private readonly Action? _registerDatStaticEffectOwner;
private readonly Action? _unregisterDatStaticEffectOwner;
- private readonly Action? _registerDatStaticAnimationOwner;
+ private readonly Func? _registerDatStaticAnimationOwner;
private readonly Action? _unregisterDatStaticAnimationOwner;
+ private readonly Action? _rebindDatStaticAnimationOwner;
private readonly Dictionary _staticOwners = new();
public EntityScriptActivator(
@@ -59,8 +66,9 @@ public sealed class EntityScriptActivator
Func resolver,
Action? registerDatStaticEffectOwner = null,
Action? unregisterDatStaticEffectOwner = null,
- Action? registerDatStaticAnimationOwner = null,
- Action? unregisterDatStaticAnimationOwner = null)
+ Func? registerDatStaticAnimationOwner = null,
+ Action? unregisterDatStaticAnimationOwner = null,
+ Action? rebindDatStaticAnimationOwner = null)
{
_scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner));
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
@@ -70,6 +78,116 @@ public sealed class EntityScriptActivator
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
_registerDatStaticAnimationOwner = registerDatStaticAnimationOwner;
_unregisterDatStaticAnimationOwner = unregisterDatStaticAnimationOwner;
+ _rebindDatStaticAnimationOwner = rebindDatStaticAnimationOwner;
+ }
+
+ ///
+ /// Rebinds a retained DAT-static logical owner to the fresh immutable
+ /// snapshot produced by a same-landblock
+ /// rehydrate. This is not a retail create edge: default Setup scripts and
+ /// existing emitters keep their logical lifetime and are never replayed.
+ ///
+ public void OnRebindStatic(WorldEntity previous, WorldEntity replacement)
+ {
+ ArgumentNullException.ThrowIfNull(previous);
+ ArgumentNullException.ThrowIfNull(replacement);
+ if (previous.ServerGuid != 0
+ || replacement.ServerGuid != 0
+ || previous.Id == 0
+ || previous.Id != replacement.Id)
+ {
+ throw new ArgumentException(
+ "DAT-static rebind requires the same nonzero local ID.",
+ nameof(replacement));
+ }
+
+ uint key = previous.Id;
+ if (!_staticOwners.TryGetValue(key, out StaticOwnerState? state))
+ return;
+ if (ReferenceEquals(state.Entity, replacement))
+ return;
+ if (!ReferenceEquals(state.Entity, previous))
+ {
+ throw new InvalidOperationException(
+ $"DAT-static owner 0x{key:X8} does not match the retained incarnation.");
+ }
+ if (state.ReleaseStarted)
+ {
+ throw new InvalidOperationException(
+ $"DAT-static owner 0x{key:X8} cannot rebind while teardown is pending.");
+ }
+
+ if (state.PendingReplacement is null)
+ {
+ ScriptActivationInfo replacementInfo = _resolver(replacement)
+ ?? throw new InvalidOperationException(
+ $"DAT-static owner 0x{key:X8} lost its Setup activation data during rebind.");
+ ValidateLogicalRebind(state, replacement, replacementInfo);
+ state.PendingReplacement = replacement;
+ state.PendingReplacementInfo = replacementInfo;
+ }
+ else if (!ReferenceEquals(state.PendingReplacement, replacement))
+ {
+ throw new InvalidOperationException(
+ $"DAT-static owner 0x{key:X8} already has another rebind pending.");
+ }
+
+ ScriptActivationInfo info = state.PendingReplacementInfo!;
+ if (state.PosePublished && !state.ReplacementPosePublished)
+ {
+ _poses.Publish(replacement, info.PartTransforms, info.PartAvailability);
+ state.ReplacementPosePublished = true;
+ }
+ if (state.EffectOwnerRegistered && !state.ReplacementEffectOwnerUpdated)
+ {
+ _registerDatStaticEffectOwner!(key, replacement, info.EffectProfile!);
+ state.ReplacementEffectOwnerUpdated = true;
+ }
+ if (state.AnimationOwnerRegistered && !state.ReplacementAnimationOwnerUpdated)
+ {
+ (_rebindDatStaticAnimationOwner
+ ?? throw new InvalidOperationException(
+ "DAT-static animation ownership has no retained-owner rebind callback."))(
+ replacement,
+ info);
+ state.ReplacementAnimationOwnerUpdated = true;
+ }
+ if (!state.ReplacementAnchorUpdated)
+ {
+ _scriptRunner.SetOwnerAnchor(key, replacement.Position);
+ state.ReplacementAnchorUpdated = true;
+ }
+
+ state.Entity = replacement;
+ state.Info = info;
+ state.PendingReplacement = null;
+ state.PendingReplacementInfo = null;
+ state.ReplacementPosePublished = false;
+ state.ReplacementEffectOwnerUpdated = false;
+ state.ReplacementAnimationOwnerUpdated = false;
+ state.ReplacementAnchorUpdated = false;
+ }
+
+ private static void ValidateLogicalRebind(
+ StaticOwnerState state,
+ WorldEntity replacement,
+ ScriptActivationInfo replacementInfo)
+ {
+ ScriptActivationInfo retained = state.Info;
+ bool retainedAnimation = retained.UsesStaticAnimationWorkset
+ && retained.DefaultAnimationId != 0;
+ bool replacementAnimation = replacementInfo.UsesStaticAnimationWorkset
+ && replacementInfo.DefaultAnimationId != 0;
+ if (state.Entity.SourceGfxObjOrSetupId != replacement.SourceGfxObjOrSetupId
+ || retained.ScriptId != replacementInfo.ScriptId
+ || retainedAnimation != replacementAnimation
+ || (retainedAnimation
+ && retained.DefaultAnimationId != replacementInfo.DefaultAnimationId)
+ || (retained.EffectProfile is null) != (replacementInfo.EffectProfile is null))
+ {
+ throw new InvalidOperationException(
+ $"DAT-static owner 0x{state.Entity.Id:X8} changed Setup lifetime during a retained rebind.");
+ }
}
public void OnCreate(WorldEntity entity)
@@ -156,8 +274,8 @@ public sealed class EntityScriptActivator
&& info.DefaultAnimationId != 0
&& _registerDatStaticAnimationOwner is not null)
{
- _registerDatStaticAnimationOwner(entity, info);
- state.AnimationOwnerRegistered = true;
+ state.AnimationOwnerRegistered =
+ _registerDatStaticAnimationOwner(entity, info);
}
if (!state.ScriptStarted && info.ScriptId != 0)
diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index 354e8392..3a96f5d5 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -670,6 +670,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
RequiresActivation: false);
}
+ // Reconcile the only fallible logical-owner edge before consuming any
+ // pending live/render/tier maps. A retry then sees the exact pending
+ // suffix even when a resolver or owner callback fails partway through.
+ landblock = NormalizeLoadedLandblock(landblock);
+ if (_loaded.TryGetValue(landblock.LandblockId, out LoadedLandblock? displaced))
+ ReconcileRetainedStaticEntityScripts(displaced, landblock);
+
// If pending live entities have been waiting for this landblock,
// merge them into the render-thread-owned mutable entity bucket before
// storing. Subsequent live spawns append to that bucket in O(1).
@@ -681,11 +688,6 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
landblock = NormalizeLoadedLandblock(landblock, merged);
_pendingByLandblock.Remove(landblock.LandblockId);
}
- else
- {
- landblock = NormalizeLoadedLandblock(landblock);
- }
-
HashSet? mergedRenderIds = additionalRenderIds is null
? null
: new HashSet(additionalRenderIds);
@@ -703,7 +705,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
tier = LandblockStreamTier.Near;
}
- if (_loaded.Remove(landblock.LandblockId, out LoadedLandblock? displaced))
+ if (_loaded.Remove(landblock.LandblockId, out displaced))
{
RemoveLoadedLandblockFromFlatView(displaced);
_animatedIndexByLandblock.Remove(landblock.LandblockId);
@@ -717,6 +719,39 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
(IEnumerable?)mergedRenderIds ?? Array.Empty());
}
+ ///
+ /// A same-landblock rehydrate is an acdream streaming replacement, not a
+ /// retail logical create/delete edge. Retained static IDs rebind their
+ /// Setup-owned resources without replaying defaults; omitted IDs perform
+ /// the same exact teardown used by ordinary retirement.
+ ///
+ private void ReconcileRetainedStaticEntityScripts(
+ LoadedLandblock displaced,
+ LoadedLandblock replacement)
+ {
+ if (_entityScriptActivator is null)
+ return;
+
+ var replacementsById = new Dictionary();
+ for (int i = 0; i < replacement.Entities.Count; i++)
+ {
+ WorldEntity entity = replacement.Entities[i];
+ if (entity.ServerGuid == 0)
+ replacementsById.Add(entity.Id, entity);
+ }
+
+ for (int i = 0; i < displaced.Entities.Count; i++)
+ {
+ WorldEntity prior = displaced.Entities[i];
+ if (prior.ServerGuid != 0)
+ continue;
+ if (replacementsById.TryGetValue(prior.Id, out WorldEntity? retained))
+ _entityScriptActivator.OnRebindStatic(prior, retained);
+ else
+ _entityScriptActivator.OnRemove(prior);
+ }
+ }
+
internal void ActivateLandblockPresentation(
GpuLandblockSpatialPublication publication)
{
diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
index 02908273..c13e21ea 100644
--- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
+++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
@@ -24,6 +24,7 @@ public sealed class LandblockPhysicsPublication
internal object Owner { get; }
internal LandblockBuild Build { get; }
+ internal bool BeginCommitted { get; set; }
internal bool CompletionCommitted { get; set; }
public uint LandblockId => Build.Landblock.LandblockId;
@@ -127,6 +128,20 @@ public sealed class LandblockPhysicsPublisher
///
public LandblockPhysicsPublication BeginPublication(
LandblockRenderPublication renderPublication)
+ {
+ LandblockPhysicsPublication publication = PreparePublication(
+ renderPublication);
+ BeginPublication(publication);
+ return publication;
+ }
+
+ ///
+ /// Captures the exact render receipt before physics mutation begins. The
+ /// coordinator retains this receipt and can retry the idempotent exact
+ /// replacement when a cache/backend operation fails.
+ ///
+ public LandblockPhysicsPublication PreparePublication(
+ LandblockRenderPublication renderPublication)
{
ArgumentNullException.ThrowIfNull(renderPublication);
LandblockBuild build = renderPublication.Build;
@@ -134,7 +149,25 @@ public sealed class LandblockPhysicsPublisher
if (!IsFinite(origin))
throw new ArgumentOutOfRangeException(nameof(renderPublication));
+ return new LandblockPhysicsPublication(
+ _receiptOwner,
+ build,
+ origin);
+ }
+
+ ///
+ /// Advances the exact terrain/cell/building physics replacement from a
+ /// retained receipt.
+ ///
+ public void BeginPublication(LandblockPhysicsPublication publication)
+ {
+ ValidateReceipt(publication);
+ if (publication.BeginCommitted)
+ return;
+
long started = Stopwatch.GetTimestamp();
+ LandblockBuild build = publication.Build;
+ Vector3 origin = publication.Origin;
LoadedLandblock landblock = build.Landblock;
PhysicsDatBundle datBundle =
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
@@ -286,12 +319,9 @@ public sealed class LandblockPhysicsPublisher
_cellSurfaceCount += cellSurfaces.Count;
_portalPlaneCount += portalPlanes.Count;
_buildingCount += buildingCount;
+ publication.BeginCommitted = true;
_beginCount++;
_basePublishTicks += Stopwatch.GetTimestamp() - started;
- return new LandblockPhysicsPublication(
- _receiptOwner,
- build,
- origin);
}
///
@@ -304,11 +334,10 @@ public sealed class LandblockPhysicsPublisher
LandblockPhysicsPublication publication,
Action? beforeStaticCollision = null)
{
- ArgumentNullException.ThrowIfNull(publication);
- if (!ReferenceEquals(publication.Owner, _receiptOwner))
- throw new ArgumentException(
- "The physics publication receipt belongs to another publisher.",
- nameof(publication));
+ ValidateReceipt(publication);
+ if (!publication.BeginCommitted)
+ throw new InvalidOperationException(
+ "Physics publication cannot complete before its prefix commits.");
if (publication.CompletionCommitted)
return;
@@ -651,4 +680,15 @@ public sealed class LandblockPhysicsPublisher
float.IsFinite(value.X)
&& float.IsFinite(value.Y)
&& float.IsFinite(value.Z);
+
+ private void ValidateReceipt(LandblockPhysicsPublication publication)
+ {
+ ArgumentNullException.ThrowIfNull(publication);
+ if (!ReferenceEquals(publication.Owner, _receiptOwner))
+ {
+ throw new ArgumentException(
+ "The physics publication receipt belongs to another publisher.",
+ nameof(publication));
+ }
+ }
}
diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
index 5f815cb2..bc7585e4 100644
--- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
+++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
@@ -41,6 +41,9 @@ public sealed class LandblockPresentationPipeline
public required uint LandblockId;
public LandblockStreamTier Tier;
public bool PresentationCommitted;
+ public LandblockRenderPublication? RenderPublication;
+ public LandblockPhysicsPublication? PhysicsPublication;
+ public LandblockStaticPresentationPublication? StaticPublication;
public bool SpatialCommitted;
public GpuLandblockSpatialPublication? SpatialPublication;
public bool SpatialPresentationCommitted;
@@ -48,7 +51,11 @@ public sealed class LandblockPresentationPipeline
public bool LiveRecoveryCommitted;
}
- private readonly Action _publishBeforeSpatialCommit;
+ private readonly Action?
+ _publishBeforeSpatialCommit;
+ private readonly LandblockRenderPublisher? _renderPublisher;
+ private readonly LandblockPhysicsPublisher? _physicsPublisher;
+ private readonly LandblockStaticPresentationPublisher? _staticPublisher;
private readonly GpuWorldState _state;
private readonly Action? _onLandblockLoaded;
private readonly Action? _ensureEnvCellMeshes;
@@ -79,6 +86,37 @@ public sealed class LandblockPresentationPipeline
demoteNearLayer);
}
+ ///
+ /// Production constructor. The pipeline retains each concrete owner
+ /// receipt before mutation and resumes only the unfinished exact stage
+ /// after a failure.
+ ///
+ public LandblockPresentationPipeline(
+ LandblockRenderPublisher renderPublisher,
+ LandblockPhysicsPublisher physicsPublisher,
+ LandblockStaticPresentationPublisher staticPublisher,
+ GpuWorldState state,
+ Action? onLandblockLoaded = null,
+ Action? ensureEnvCellMeshes = null,
+ LandblockRetirementCoordinator? retirementCoordinator = null)
+ {
+ _renderPublisher = renderPublisher
+ ?? throw new ArgumentNullException(nameof(renderPublisher));
+ _physicsPublisher = physicsPublisher
+ ?? throw new ArgumentNullException(nameof(physicsPublisher));
+ _staticPublisher = staticPublisher
+ ?? throw new ArgumentNullException(nameof(staticPublisher));
+ _state = state ?? throw new ArgumentNullException(nameof(state));
+ _onLandblockLoaded = onLandblockLoaded;
+ _ensureEnvCellMeshes = ensureEnvCellMeshes;
+ // Until Slice 5F internalizes concrete retirement ownership, a
+ // concrete publication pipeline must share the production coordinator.
+ // A legacy no-op retirement suffix would detach spatial state while
+ // leaking the concrete render/physics/static owners.
+ _retirements = retirementCoordinator
+ ?? throw new ArgumentNullException(nameof(retirementCoordinator));
+ }
+
public int PendingRetirementCount => _retirements.PendingCount;
public int PendingPublicationCount => _publications.Count;
@@ -219,22 +257,60 @@ public sealed class LandblockPresentationPipeline
{
if (!transaction.PresentationCommitted)
{
- try
+ if (_renderPublisher is not null
+ && _physicsPublisher is not null
+ && _staticPublisher is not null)
{
- _publishBeforeSpatialCommit(transaction.Build, transaction.MeshData);
+ transaction.RenderPublication ??=
+ _renderPublisher.PreparePublication(
+ transaction.Build,
+ transaction.MeshData);
+ transaction.PhysicsPublication ??=
+ _physicsPublisher.PreparePublication(
+ transaction.RenderPublication);
+ transaction.StaticPublication ??=
+ _staticPublisher.PreparePublication(
+ transaction.PhysicsPublication);
+
+ // Prepare every immutable receipt before the first external
+ // mutation. Cross-owner identity/data errors therefore block
+ // the publication without leaving a committed prefix.
+ _renderPublisher.BeginPublication(transaction.RenderPublication);
+ _physicsPublisher.BeginPublication(transaction.PhysicsPublication);
+
+ // Retail establishes the building/visible-cell suffix before
+ // each static object publishes lights, collision, and scripts.
+ _renderPublisher.CompletePublication(
+ transaction.RenderPublication);
+
+ _staticPublisher.BeginPublication(transaction.StaticPublication);
+ _physicsPublisher.CompletePublication(
+ transaction.PhysicsPublication,
+ entity => _staticPublisher.PrepareEntityBeforeCollision(
+ transaction.StaticPublication,
+ entity));
+ _staticPublisher.CompletePublication(
+ transaction.StaticPublication);
}
- catch (Exception error)
+ else
{
- // The legacy GameWindow callback still spans several concrete
- // owners and cannot report a committed prefix. Until Slice-5
- // checkpoints C-E replace it with individually ledgered
- // publishers, its production wrapper conservatively reports a
- // committed mutation. Test/compatibility callbacks retain the
- // established strong-exception contract unless they explicitly
- // report otherwise.
- if (error is StreamingMutationException { MutationCommitted: true })
- _publications.Remove(result);
- throw;
+ try
+ {
+ (_publishBeforeSpatialCommit
+ ?? throw new InvalidOperationException(
+ "The presentation pipeline has no publication owners."))(
+ transaction.Build,
+ transaction.MeshData);
+ }
+ catch (Exception error)
+ {
+ // Compatibility callbacks cannot report an exact retained
+ // receipt. Preserve the established fail-fast rule only
+ // for callbacks that explicitly report committed mutation.
+ if (error is StreamingMutationException { MutationCommitted: true })
+ _publications.Remove(result);
+ throw;
+ }
}
transaction.PresentationCommitted = true;
}
diff --git a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs
index eb41ae7d..2e240ba3 100644
--- a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs
+++ b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs
@@ -20,17 +20,32 @@ public sealed class LandblockRenderPublication
internal LandblockRenderPublication(
object owner,
LandblockBuild build,
+ LandblockMeshData meshData,
Vector3 origin,
- Dictionary visibilityCells)
+ Dictionary visibilityCells,
+ Vector3 aabbMin,
+ Vector3 aabbMax)
{
Owner = owner;
Build = build;
+ MeshData = meshData;
Origin = origin;
_visibilityCells = new ReadOnlyDictionary(visibilityCells);
+ AabbMin = aabbMin;
+ AabbMax = aabbMax;
}
internal object Owner { get; }
internal LandblockBuild Build { get; }
+ internal LandblockMeshData MeshData { get; }
+ internal Vector3 AabbMin { get; }
+ internal Vector3 AabbMax { get; }
+ internal bool TerrainCommitted { get; set; }
+ internal bool VisibilityCommitted { get; set; }
+ internal bool AabbCommitted { get; set; }
+ internal bool BeginCommitted { get; set; }
+ internal bool BuildingRegistryCommitted { get; set; }
+ internal bool EnvCellsCommitted { get; set; }
internal bool CompletionCommitted { get; set; }
public uint LandblockId => Build.Landblock.LandblockId;
@@ -137,6 +152,22 @@ public sealed class LandblockRenderPublisher
public LandblockRenderPublication BeginPublication(
LandblockBuild build,
LandblockMeshData meshData)
+ {
+ LandblockRenderPublication publication = PreparePublication(
+ build,
+ meshData);
+ BeginPublication(publication);
+ return publication;
+ }
+
+ ///
+ /// Validates and captures the immutable render transaction before its
+ /// first external mutation. A coordinator retains this receipt when an
+ /// exact-replacement callback fails, then resumes the same publication.
+ ///
+ public LandblockRenderPublication PreparePublication(
+ LandblockBuild build,
+ LandblockMeshData meshData)
{
ArgumentNullException.ThrowIfNull(build);
ArgumentNullException.ThrowIfNull(meshData);
@@ -145,7 +176,6 @@ public sealed class LandblockRenderPublisher
"Render publication requires the build's captured origin.",
nameof(build));
- long beginStarted = Stopwatch.GetTimestamp();
uint landblockId = build.Landblock.LandblockId;
if (build.EnvCells is { } candidateEnvCells &&
candidateEnvCells.LandblockId != landblockId)
@@ -157,28 +187,64 @@ public sealed class LandblockRenderPublisher
Vector3 origin = ComputeOrigin(landblockId, build.Origin);
- long terrainStarted = Stopwatch.GetTimestamp();
- _publishTerrain(landblockId, meshData, origin);
- _terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted;
-
- var committedCells = new Dictionary();
+ var visibilityCells = new Dictionary();
if (build.EnvCells is { } envCells)
{
- _cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells);
foreach (LoadedCell cell in envCells.VisibilityCells)
- committedCells[cell.CellId] = cell;
+ visibilityCells[cell.CellId] = cell;
}
-
(Vector3 aabbMin, Vector3 aabbMax) = ComputeAabb(meshData, origin);
- _worldState.SetLandblockAabb(landblockId, aabbMin, aabbMax);
-
- _beginCount++;
- _beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted;
return new LandblockRenderPublication(
_receiptOwner,
build,
+ meshData,
origin,
- committedCells);
+ visibilityCells,
+ aabbMin,
+ aabbMax);
+ }
+
+ ///
+ /// Advances the render prefix from a retained receipt. Every operation is
+ /// exact replacement, so a callback failure can safely retry this stage.
+ ///
+ public void BeginPublication(LandblockRenderPublication publication)
+ {
+ ValidateReceipt(publication);
+ if (publication.BeginCommitted)
+ return;
+
+ long beginStarted = Stopwatch.GetTimestamp();
+ LandblockBuild build = publication.Build;
+ uint landblockId = publication.LandblockId;
+
+ if (!publication.TerrainCommitted)
+ {
+ long terrainStarted = Stopwatch.GetTimestamp();
+ _publishTerrain(landblockId, publication.MeshData, publication.Origin);
+ _terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted;
+ publication.TerrainCommitted = true;
+ }
+
+ if (!publication.VisibilityCommitted)
+ {
+ if (build.EnvCells is { } envCells)
+ _cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells);
+ publication.VisibilityCommitted = true;
+ }
+
+ if (!publication.AabbCommitted)
+ {
+ _worldState.SetLandblockAabb(
+ landblockId,
+ publication.AabbMin,
+ publication.AabbMax);
+ publication.AabbCommitted = true;
+ }
+
+ publication.BeginCommitted = true;
+ _beginCount++;
+ _beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted;
}
///
@@ -188,28 +254,35 @@ public sealed class LandblockRenderPublisher
///
public void CompletePublication(LandblockRenderPublication publication)
{
- ArgumentNullException.ThrowIfNull(publication);
- if (!ReferenceEquals(publication.Owner, _receiptOwner))
- throw new ArgumentException(
- "The render publication receipt belongs to another publisher.",
- nameof(publication));
+ ValidateReceipt(publication);
+ if (!publication.BeginCommitted)
+ throw new InvalidOperationException(
+ "Render publication cannot complete before its prefix commits.");
if (publication.CompletionCommitted)
return;
long started = Stopwatch.GetTimestamp();
LandblockBuild build = publication.Build;
uint landblockId = publication.LandblockId;
- if (build.Landblock.PhysicsDats?.Info is { } info)
+ if (!publication.BuildingRegistryCommitted)
{
- uint registryKey = landblockId & 0xFFFF0000u;
- _buildingRegistries[registryKey] = BuildingLoader.Build(
- info,
- landblockId,
- publication.VisibilityCells);
+ if (build.Landblock.PhysicsDats?.Info is { } info)
+ {
+ uint registryKey = landblockId & 0xFFFF0000u;
+ _buildingRegistries[registryKey] = BuildingLoader.Build(
+ info,
+ landblockId,
+ publication.VisibilityCells);
+ }
+ publication.BuildingRegistryCommitted = true;
}
- if (build.EnvCells is { } envCells)
- _commitEnvCells?.Invoke(envCells);
+ if (!publication.EnvCellsCommitted)
+ {
+ if (build.EnvCells is { } envCells)
+ _commitEnvCells?.Invoke(envCells);
+ publication.EnvCellsCommitted = true;
+ }
publication.CompletionCommitted = true;
_completeCount++;
@@ -287,4 +360,15 @@ public sealed class LandblockRenderPublisher
new Vector3(origin.X, origin.Y, zMin),
new Vector3(origin.X + 192f, origin.Y + 192f, zMax));
}
+
+ private void ValidateReceipt(LandblockRenderPublication publication)
+ {
+ ArgumentNullException.ThrowIfNull(publication);
+ if (!ReferenceEquals(publication.Owner, _receiptOwner))
+ {
+ throw new ArgumentException(
+ "The render publication receipt belongs to another publisher.",
+ nameof(publication));
+ }
+ }
}
diff --git a/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs b/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs
new file mode 100644
index 00000000..345369ab
--- /dev/null
+++ b/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs
@@ -0,0 +1,346 @@
+using AcDream.Core.Lighting;
+using AcDream.Core.Plugins;
+using AcDream.Core.Rendering;
+using AcDream.Core.World;
+using AcDream.Plugin.Abstractions;
+
+namespace AcDream.App.Streaming;
+
+///
+/// Retained receipt for the DAT-static presentation stages of one landblock
+/// publication. The physics publisher calls the light/translucency stage at
+/// the existing per-object point immediately before ordinary collision.
+///
+public sealed class LandblockStaticPresentationPublication
+{
+ private readonly IReadOnlyDictionary _entities;
+ private readonly IReadOnlyDictionary _snapshots;
+
+ internal LandblockStaticPresentationPublication(
+ object owner,
+ LandblockPhysicsPublication physicsPublication,
+ Dictionary entities,
+ Dictionary snapshots,
+ HashSet previouslyActiveIds)
+ {
+ Owner = owner;
+ PhysicsPublication = physicsPublication;
+ _entities = entities;
+ _snapshots = snapshots;
+ PreviouslyActiveIds = previouslyActiveIds;
+ }
+
+ internal object Owner { get; }
+ internal LandblockPhysicsPublication PhysicsPublication { get; }
+ internal HashSet PreviouslyActiveIds { get; }
+ internal int PriorCleanupCursor { get; set; }
+ internal int PluginCursor { get; set; }
+ internal bool BeginCommitted { get; set; }
+ internal bool CompletionCommitted { get; set; }
+
+ public uint LandblockId => PhysicsPublication.LandblockId;
+ public IReadOnlyDictionary Entities => _entities;
+ public IReadOnlyDictionary Snapshots => _snapshots;
+}
+
+public readonly record struct LandblockStaticPresentationDiagnostics(
+ long BeginCount,
+ long CompleteCount,
+ long LightReplacementCount,
+ long PluginSpawnCount,
+ long PluginRefreshCount,
+ long PluginRemovalCount,
+ int ActiveLandblockCount,
+ int ActiveEntityCount);
+
+///
+/// Update-thread owner of DAT-static lights, translucency lifetime, and plugin
+/// projection. Setup defaults remain activated exactly once at the existing
+/// post-spatial render-pin boundary owned by .
+///
+///
+/// Retail constructs each static CPhysicsObj from Setup defaults before
+/// CObjCell::init_objects (0x0052B420) inserts/refloods it. The light
+/// state follows CPhysicsObj::set_lights (0x0050FCF0); translucency
+/// starts from the new object's Setup/material state before later FPHooks.
+/// Plugin projection is an acdream adaptation and therefore emits at most one
+/// logical spawn per retained static ID, while reapply only refreshes current
+/// state.
+///
+public sealed class LandblockStaticPresentationPublisher
+{
+ private readonly object _receiptOwner = new();
+ private readonly LightingHookSink _lighting;
+ private readonly TranslucencyFadeManager _translucency;
+ private readonly WorldGameState _worldState;
+ private readonly WorldEvents _worldEvents;
+ private readonly Dictionary>
+ _activeByLandblock = new();
+ private readonly Dictionary _landblockByEntityId = new();
+
+ private long _beginCount;
+ private long _completeCount;
+ private long _lightReplacementCount;
+ private long _pluginSpawnCount;
+ private long _pluginRefreshCount;
+ private long _pluginRemovalCount;
+
+ public LandblockStaticPresentationPublisher(
+ LightingHookSink lighting,
+ TranslucencyFadeManager translucency,
+ WorldGameState worldState,
+ WorldEvents worldEvents)
+ {
+ _lighting = lighting ?? throw new ArgumentNullException(nameof(lighting));
+ _translucency = translucency
+ ?? throw new ArgumentNullException(nameof(translucency));
+ _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
+ _worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents));
+ }
+
+ public LandblockStaticPresentationDiagnostics Diagnostics => new(
+ _beginCount,
+ _completeCount,
+ _lightReplacementCount,
+ _pluginSpawnCount,
+ _pluginRefreshCount,
+ _pluginRemovalCount,
+ _activeByLandblock.Count,
+ _landblockByEntityId.Count);
+
+ public LandblockStaticPresentationPublication PreparePublication(
+ LandblockPhysicsPublication physicsPublication)
+ {
+ ArgumentNullException.ThrowIfNull(physicsPublication);
+
+ uint canonical = Canonicalize(physicsPublication.LandblockId);
+ _activeByLandblock.TryGetValue(
+ canonical,
+ out Dictionary? active);
+ var entities = new Dictionary();
+ var snapshots = new Dictionary();
+ foreach (WorldEntity entity in physicsPublication.Build.Landblock.Entities)
+ {
+ if (entity.ServerGuid != 0)
+ continue;
+ if (!entities.TryAdd(entity.Id, entity))
+ {
+ throw new InvalidOperationException(
+ $"Landblock 0x{canonical:X8} contains duplicate DAT-static ID " +
+ $"0x{entity.Id:X8}.");
+ }
+ if (_landblockByEntityId.TryGetValue(entity.Id, out uint owner)
+ && owner != canonical)
+ {
+ throw new InvalidOperationException(
+ $"DAT-static ID 0x{entity.Id:X8} is already owned by " +
+ $"landblock 0x{owner:X8}.");
+ }
+ if (active is not null
+ && active.TryGetValue(entity.Id, out WorldEntitySnapshot retained)
+ && retained.SourceId != entity.SourceGfxObjOrSetupId)
+ {
+ throw new InvalidOperationException(
+ $"Retained DAT-static ID 0x{entity.Id:X8} changed source " +
+ $"from 0x{retained.SourceId:X8} to " +
+ $"0x{entity.SourceGfxObjOrSetupId:X8}.");
+ }
+
+ snapshots.Add(entity.Id, Snapshot(entity));
+ }
+
+ HashSet previous = active is not null
+ ? active.Keys.ToHashSet()
+ : new HashSet();
+ return new LandblockStaticPresentationPublication(
+ _receiptOwner,
+ physicsPublication,
+ entities,
+ snapshots,
+ previous);
+ }
+
+ ///
+ /// Retires omitted prior projections and detaches prior concrete lights
+ /// before replacement collision. Retained IDs keep their SetLight latch
+ /// and FPHook translucency state because rehydrate is not a logical create.
+ ///
+ public void BeginPublication(LandblockStaticPresentationPublication publication)
+ {
+ ValidateReceipt(publication);
+ if (publication.BeginCommitted)
+ return;
+
+ uint[] priorIds = publication.PreviouslyActiveIds.Order().ToArray();
+ while (publication.PriorCleanupCursor < priorIds.Length)
+ {
+ uint id = priorIds[publication.PriorCleanupCursor];
+ bool retained = publication.Entities.ContainsKey(id);
+ _lighting.UnregisterOwner(id, forgetState: !retained);
+ if (!retained)
+ {
+ _translucency.ClearEntity(id);
+ _worldState.RemoveById(id);
+ _worldEvents.ForgetEntity(id);
+ _landblockByEntityId.Remove(id);
+ _pluginRemovalCount++;
+ }
+ publication.PriorCleanupCursor++;
+ }
+
+ publication.BeginCommitted = true;
+ _beginCount++;
+ }
+
+ ///
+ /// Exact replacement called by
+ /// immediately before registering this object's ordinary collision.
+ ///
+ public void PrepareEntityBeforeCollision(
+ LandblockStaticPresentationPublication publication,
+ WorldEntity entity)
+ {
+ ValidateReceipt(publication);
+ ArgumentNullException.ThrowIfNull(entity);
+ if (!publication.BeginCommitted)
+ throw new InvalidOperationException(
+ "Static presentation cannot prepare an entity before begin commits.");
+ if (entity.ServerGuid != 0)
+ return;
+ if (!publication.Entities.TryGetValue(entity.Id, out WorldEntity? expected)
+ || !ReferenceEquals(expected, entity))
+ {
+ throw new InvalidOperationException(
+ $"DAT-static entity 0x{entity.Id:X8} does not belong to this publication.");
+ }
+
+ bool retained = publication.PreviouslyActiveIds.Contains(entity.Id);
+ _lighting.UnregisterOwner(entity.Id, forgetState: !retained);
+ if (!retained)
+ _translucency.ClearEntity(entity.Id);
+
+ PhysicsDatBundle datBundle = publication.PhysicsPublication.Build.Landblock.PhysicsDats
+ ?? PhysicsDatBundle.Empty;
+ uint sourceId = entity.SourceGfxObjOrSetupId;
+ if ((sourceId & 0xFF000000u) == 0x02000000u
+ && datBundle.Setups.TryGetValue(sourceId, out var setup))
+ {
+ IReadOnlyList lights = LightInfoLoader.Load(
+ setup,
+ ownerId: entity.Id,
+ entityPosition: entity.Position,
+ entityRotation: entity.Rotation,
+ cellId: entity.ParentCellId ?? 0u);
+ for (int i = 0; i < lights.Count; i++)
+ _lighting.RegisterOwnedLight(lights[i]);
+ }
+ _lightReplacementCount++;
+ }
+
+ ///
+ /// Publishes the exact current plugin snapshot after collision/reflood.
+ /// Existing IDs refresh replay state without emitting another logical
+ /// spawn; new IDs emit once.
+ ///
+ public void CompletePublication(
+ LandblockStaticPresentationPublication publication)
+ {
+ ValidateReceipt(publication);
+ if (!publication.BeginCommitted
+ || !publication.PhysicsPublication.CompletionCommitted)
+ {
+ throw new InvalidOperationException(
+ "Static presentation requires completed physics publication.");
+ }
+ if (publication.CompletionCommitted)
+ return;
+
+ KeyValuePair[] snapshots = publication.Snapshots
+ .OrderBy(pair => pair.Key)
+ .ToArray();
+ while (publication.PluginCursor < snapshots.Length)
+ {
+ (uint id, WorldEntitySnapshot snapshot) = snapshots[publication.PluginCursor];
+ _worldState.Add(snapshot);
+ if (publication.PreviouslyActiveIds.Contains(id))
+ {
+ _worldEvents.UpsertCurrent(snapshot);
+ _pluginRefreshCount++;
+ }
+ else
+ {
+ _worldEvents.FireEntitySpawned(snapshot);
+ _pluginSpawnCount++;
+ }
+ _landblockByEntityId[id] = Canonicalize(publication.LandblockId);
+ publication.PluginCursor++;
+ }
+
+ uint canonical = Canonicalize(publication.LandblockId);
+ if (publication.Snapshots.Count == 0)
+ {
+ _activeByLandblock.Remove(canonical);
+ }
+ else
+ {
+ _activeByLandblock[canonical] = publication.Snapshots
+ .ToDictionary(pair => pair.Key, pair => pair.Value);
+ }
+ publication.CompletionCommitted = true;
+ _completeCount++;
+ }
+
+ public void RemoveLighting(WorldEntity entity)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ if (entity.ServerGuid == 0)
+ _lighting.UnregisterOwner(entity.Id);
+ }
+
+ public void RemoveTranslucency(WorldEntity entity)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ if (entity.ServerGuid == 0)
+ _translucency.ClearEntity(entity.Id);
+ }
+
+ public void RemovePluginProjection(WorldEntity entity)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ if (entity.ServerGuid != 0)
+ return;
+
+ _worldState.RemoveById(entity.Id);
+ _worldEvents.ForgetEntity(entity.Id);
+ if (_landblockByEntityId.Remove(entity.Id, out uint landblockId)
+ && _activeByLandblock.TryGetValue(
+ landblockId,
+ out Dictionary? active))
+ {
+ active.Remove(entity.Id);
+ if (active.Count == 0)
+ _activeByLandblock.Remove(landblockId);
+ }
+ _pluginRemovalCount++;
+ }
+
+ private void ValidateReceipt(LandblockStaticPresentationPublication publication)
+ {
+ ArgumentNullException.ThrowIfNull(publication);
+ if (!ReferenceEquals(publication.Owner, _receiptOwner))
+ {
+ throw new ArgumentException(
+ "The static presentation receipt belongs to another publisher.",
+ nameof(publication));
+ }
+ }
+
+ private static WorldEntitySnapshot Snapshot(WorldEntity entity) => new(
+ Id: entity.Id,
+ SourceId: entity.SourceGfxObjOrSetupId,
+ Position: entity.Position,
+ Rotation: entity.Rotation);
+
+ private static uint Canonicalize(uint landblockId) =>
+ (landblockId & 0xFFFF0000u) | 0xFFFFu;
+}
diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs
index 9805d96f..7edfbf09 100644
--- a/src/AcDream.App/Streaming/StreamingController.cs
+++ b/src/AcDream.App/Streaming/StreamingController.cs
@@ -174,21 +174,23 @@ public sealed class StreamingController
Action? clearPendingLoads = null,
Action? onLandblockLoaded = null,
Action? ensureEnvCellMeshes = null,
- LandblockRetirementCoordinator? retirementCoordinator = null)
+ LandblockRetirementCoordinator? retirementCoordinator = null,
+ LandblockPresentationPipeline? presentationPipeline = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_clearPendingLoads = clearPendingLoads;
_state = state;
- _presentation = new LandblockPresentationPipeline(
- applyTerrain,
- state,
- onLandblockLoaded,
- ensureEnvCellMeshes,
- retirementCoordinator,
- removeTerrain,
- demoteNearLayer);
+ _presentation = presentationPipeline
+ ?? new LandblockPresentationPipeline(
+ applyTerrain,
+ state,
+ onLandblockLoaded,
+ ensureEnvCellMeshes,
+ retirementCoordinator,
+ removeTerrain,
+ demoteNearLayer);
NearRadius = nearRadius;
FarRadius = farRadius;
}
diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs
index 7d3954ea..df37c5e7 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs
@@ -64,6 +64,42 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
Assert.Equal(2, posePublishes);
}
+ [Fact]
+ public void Rebind_RetainsSequencerAndPublishesIntoReplacementEntity()
+ {
+ var loader = new Loader();
+ loader.Add(AnimationId, TwoFrameAnimation());
+ var scheduler = new RetailStaticAnimatingObjectScheduler(
+ loader,
+ (_, _) => { },
+ (_, _, _) => { });
+ Setup setup = MakeSetup();
+ WorldEntity first = MakeEntity();
+ ScriptActivationInfo info = new(
+ ScriptId: 0,
+ PartTransforms: first.IndexedPartTransforms,
+ PartAvailability: first.IndexedPartAvailable,
+ Setup: setup,
+ DefaultAnimationId: AnimationId,
+ UsesStaticAnimationWorkset: true);
+ scheduler.Register(first, info);
+ scheduler.Tick(1f / 60f);
+ float phaseBeforeRebind = first.MeshRefs[0].PartTransform.Translation.X;
+
+ WorldEntity replacement = MakeEntity();
+ replacement.Position = new Vector3(4f, 5f, 6f);
+ scheduler.Rebind(replacement, info with
+ {
+ PartTransforms = replacement.IndexedPartTransforms,
+ PartAvailability = replacement.IndexedPartAvailable,
+ });
+ scheduler.Tick(1f / 60f);
+
+ Assert.Equal(1, scheduler.Count);
+ Assert.True(
+ replacement.MeshRefs[0].PartTransform.Translation.X > phaseBeforeRebind);
+ }
+
[Fact]
public void ShortStaticAnimFrame_RetainsCompleteTrailingPartState()
{
diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs
index 0965dd1b..65c76a20 100644
--- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs
@@ -282,6 +282,7 @@ public sealed class EntityScriptActivatorTests
registerCount++;
registeredEntity = entity;
Assert.Same(info, actual);
+ return true;
},
unregisterDatStaticAnimationOwner: ownerId =>
{
@@ -313,7 +314,11 @@ public sealed class EntityScriptActivatorTests
ScriptId: 0,
PartTransforms: Array.Empty(),
DefaultAnimationId: 0x03000001u),
- registerDatStaticAnimationOwner: (_, _) => registerCount++);
+ registerDatStaticAnimationOwner: (_, _) =>
+ {
+ registerCount++;
+ return true;
+ });
activator.OnCreate(MakeEntity(0x70000420u, Vector3.Zero));
@@ -335,7 +340,11 @@ public sealed class EntityScriptActivatorTests
PartTransforms: Array.Empty(),
DefaultAnimationId: 0x03000001u,
UsesStaticAnimationWorkset: true),
- registerDatStaticAnimationOwner: (_, _) => registerCount++,
+ registerDatStaticAnimationOwner: (_, _) =>
+ {
+ registerCount++;
+ return true;
+ },
unregisterDatStaticAnimationOwner: _ => unregisterCount++);
WorldEntity entity = MakeEntity(0x70000421u, Vector3.Zero);
@@ -378,6 +387,7 @@ public sealed class EntityScriptActivatorTests
animationAttempts++;
if (animationAttempts == 1)
throw new InvalidOperationException("animation acquire fault");
+ return true;
});
Assert.Throws(() => activator.OnCreate(entity));
@@ -424,7 +434,7 @@ public sealed class EntityScriptActivatorTests
if (effectReleaseAttempts == 1)
throw new InvalidOperationException("effect release fault");
},
- registerDatStaticAnimationOwner: (_, _) => { },
+ registerDatStaticAnimationOwner: (_, _) => true,
unregisterDatStaticAnimationOwner: _ =>
{
animationReleaseAttempts++;
@@ -465,6 +475,97 @@ public sealed class EntityScriptActivatorTests
Assert.Equal(first.Id, GameWindow.ParticleEntityKey(first));
}
+ [Fact]
+ public void DatStaticRebind_RetriesOnlyUnfinishedOwnersAndDoesNotReplayDefault()
+ {
+ var p = BuildPipeline(
+ (0xAAu, BuildScript((1.0, new CreateParticleHook { EmitterInfoId = 100 }))));
+ var setup = new DatReaderWriter.DBObjs.Setup();
+ ScriptActivationInfo info = new(
+ ScriptId: 0xAAu,
+ PartTransforms: Array.Empty(),
+ EffectProfile: EntityEffectProfile.CreateDatStatic(setup),
+ Setup: setup,
+ DefaultAnimationId: 0x03000001u,
+ UsesStaticAnimationWorkset: true);
+ int effectUpdates = 0;
+ int animationRegistrations = 0;
+ int animationRebindAttempts = 0;
+ var activator = new EntityScriptActivator(
+ p.Runner,
+ p.Sink,
+ p.Poses,
+ _ => info,
+ registerDatStaticEffectOwner: (_, _, _) => effectUpdates++,
+ registerDatStaticAnimationOwner: (_, _) =>
+ {
+ animationRegistrations++;
+ return true;
+ },
+ rebindDatStaticAnimationOwner: (_, _) =>
+ {
+ animationRebindAttempts++;
+ if (animationRebindAttempts == 1)
+ throw new InvalidOperationException("animation rebind fault");
+ });
+ WorldEntity first = MakeDatStatic(0x80A9B400u, Vector3.One);
+ WorldEntity replacement = MakeDatStatic(
+ first.Id,
+ new Vector3(7f, 8f, 9f));
+ activator.OnCreate(first);
+
+ Assert.Throws(() =>
+ activator.OnRebindStatic(first, replacement));
+ activator.OnRebindStatic(first, replacement);
+ activator.OnCreate(replacement);
+
+ Assert.Equal(1, p.Runner.ActiveOwnerCount);
+ Assert.Equal(2, effectUpdates);
+ Assert.Equal(1, animationRegistrations);
+ Assert.Equal(2, animationRebindAttempts);
+ Assert.True(p.Poses.TryGetRootPose(first.Id, out Matrix4x4 root));
+ Assert.Equal(replacement.Position, root.Translation);
+ p.Runner.Tick(1.1);
+ Assert.Single(p.Recording.Calls);
+ Assert.Equal(replacement.Position, p.Recording.Calls[0].Pos);
+ }
+
+ [Fact]
+ public void DatStaticRebind_MissingDefaultAnimationNeverClaimsWorksetOwner()
+ {
+ var p = BuildPipeline();
+ var setup = new DatReaderWriter.DBObjs.Setup();
+ ScriptActivationInfo info = new(
+ ScriptId: 0,
+ PartTransforms: Array.Empty(),
+ Setup: setup,
+ DefaultAnimationId: 0x03000001u,
+ UsesStaticAnimationWorkset: true);
+ int registerAttempts = 0;
+ int rebindAttempts = 0;
+ var activator = new EntityScriptActivator(
+ p.Runner,
+ p.Sink,
+ p.Poses,
+ _ => info,
+ registerDatStaticAnimationOwner: (_, _) =>
+ {
+ registerAttempts++;
+ return false;
+ },
+ rebindDatStaticAnimationOwner: (_, _) => rebindAttempts++);
+ WorldEntity first = MakeDatStatic(0x80A9B400u, Vector3.Zero);
+ WorldEntity replacement = MakeDatStatic(first.Id, Vector3.One);
+
+ activator.OnCreate(first);
+ activator.OnRebindStatic(first, replacement);
+ activator.OnCreate(replacement);
+
+ Assert.Equal(2, registerAttempts);
+ Assert.Equal(0, rebindAttempts);
+ Assert.True(p.Poses.TryGetRootPose(first.Id, out _));
+ }
+
[Fact]
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
{
diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs
index a210a5f6..a265ed2a 100644
--- a/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs
@@ -35,6 +35,7 @@ public sealed class GpuWorldStateActivatorTests
GpuWorldState State,
PhysicsScriptRunner Runner,
ParticleHookSink Sink,
+ EntityEffectPoseRegistry Poses,
RecordingSink Recording);
private static Pipeline BuildPipeline(uint scriptId)
@@ -57,7 +58,7 @@ public sealed class GpuWorldStateActivatorTests
_ => new ScriptActivationInfo(scriptId, Array.Empty()));
var state = new GpuWorldState(entityScriptActivator: activator);
- return new Pipeline(state, runner, sink, recording);
+ return new Pipeline(state, runner, sink, poses, recording);
}
private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities)
@@ -135,6 +136,45 @@ public sealed class GpuWorldStateActivatorTests
Assert.Equal(0, p.Runner.ActiveScriptCount);
}
+ [Fact]
+ public void SameIdLandblockRehydrate_RebindsOwnerWithoutReplayingDefaultScript()
+ {
+ var p = BuildPipeline(scriptId: 0xAAu);
+ WorldEntity first = DatHydrated(
+ id: 0x40A9B401u,
+ pos: new Vector3(1f, 2f, 3f));
+ WorldEntity replacement = DatHydrated(
+ id: first.Id,
+ pos: new Vector3(8f, 9f, 10f));
+
+ p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, first));
+ Assert.Equal(1, p.Runner.ActiveScriptCount);
+
+ p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, replacement));
+
+ Assert.Equal(1, p.Runner.ActiveScriptCount);
+ Assert.True(p.Poses.TryGetRootPose(first.Id, out Matrix4x4 root));
+ Assert.Equal(replacement.Position, root.Translation);
+ p.Runner.Tick(0.001f);
+ Assert.Single(p.Recording.Calls);
+ Assert.Equal(replacement.Position, p.Recording.Calls[0].Pos);
+ }
+
+ [Fact]
+ public void SameLandblockRehydrate_OmittedStaticRetiresScriptAndPose()
+ {
+ var p = BuildPipeline(scriptId: 0xAAu);
+ WorldEntity entity = DatHydrated(0x40A9B401u, Vector3.Zero);
+ p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, entity));
+ Assert.Equal(1, p.Runner.ActiveScriptCount);
+ Assert.True(p.Poses.TryGetRootPose(entity.Id, out _));
+
+ p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
+
+ Assert.Equal(0, p.Runner.ActiveScriptCount);
+ Assert.False(p.Poses.TryGetRootPose(entity.Id, out _));
+ }
+
[Fact]
public void AddEntitiesToExistingLandblock_FiresActivatorForEachPromoted()
{
diff --git a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs
new file mode 100644
index 00000000..e4d84e69
--- /dev/null
+++ b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs
@@ -0,0 +1,380 @@
+using System.Collections.Immutable;
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Wb;
+using AcDream.App.Rendering.Vfx;
+using AcDream.App.Streaming;
+using AcDream.Core.Lighting;
+using AcDream.Core.Physics;
+using AcDream.Core.Plugins;
+using AcDream.Core.Rendering;
+using AcDream.Core.Terrain;
+using AcDream.Core.Vfx;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+using Xunit;
+using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
+
+namespace AcDream.App.Tests.Streaming;
+
+public sealed class LandblockConcretePresentationPipelineTests
+{
+ private const uint LandblockId = 0xA9B4FFFFu;
+
+ [Fact]
+ public void Loaded_ConcreteOwnersPreserveRetailPrefixAndSpatialOrder()
+ {
+ var calls = new List();
+ ConcreteFixture fixture = Fixture(
+ calls,
+ commitEnvCells: _ => calls.Add("envcell"));
+ fixture.Events.EntitySpawned += _ =>
+ {
+ Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount);
+ Assert.False(fixture.State.IsLoaded(LandblockId));
+ calls.Add("plugin");
+ };
+ var pipeline = new LandblockPresentationPipeline(
+ fixture.Render,
+ fixture.Physics,
+ fixture.Static,
+ fixture.State,
+ onLandblockLoaded: _ => calls.Add("live-recovery"),
+ retirementCoordinator: fixture.Retirements);
+ LandblockBuild build = Build(Entity(0x80A9B401u));
+
+ pipeline.PublishLoaded(Result(build));
+
+ Assert.Equal(
+ ["terrain", "envcell", "plugin", "pin", "live-recovery"],
+ calls);
+ Assert.True(fixture.State.IsNearTier(LandblockId));
+ Assert.Equal(1, fixture.Render.Diagnostics.BeginCount);
+ Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount);
+ Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount);
+ }
+
+ [Fact]
+ public void RenderSuffixFailure_ResumesConcreteReceiptsWithoutReplayingPrefixes()
+ {
+ var calls = new List();
+ bool failEnvOnce = true;
+ ConcreteFixture fixture = Fixture(
+ calls,
+ commitEnvCells: _ =>
+ {
+ calls.Add("envcell");
+ if (failEnvOnce)
+ {
+ failEnvOnce = false;
+ throw new InvalidOperationException("injected envcell failure");
+ }
+ });
+ fixture.Events.EntitySpawned += _ => calls.Add("plugin");
+ var pipeline = new LandblockPresentationPipeline(
+ fixture.Render,
+ fixture.Physics,
+ fixture.Static,
+ fixture.State,
+ retirementCoordinator: fixture.Retirements);
+ LandblockBuild build = Build(Entity(0x80A9B401u));
+ LandblockStreamResult.Loaded result = Result(build);
+
+ Assert.Throws(() => pipeline.PublishLoaded(result));
+ Assert.False(fixture.State.IsLoaded(LandblockId));
+ Assert.True(pipeline.HasPendingPublication(result));
+ Assert.Equal(1, fixture.Render.Diagnostics.BeginCount);
+ Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount);
+ Assert.Equal(0, fixture.Physics.Diagnostics.CompleteCount);
+ Assert.Equal(0, fixture.Static.Diagnostics.BeginCount);
+
+ pipeline.ResumePublication(result);
+
+ Assert.Equal(1, calls.Count(call => call == "terrain"));
+ Assert.Equal(2, calls.Count(call => call == "envcell"));
+ Assert.Equal(1, calls.Count(call => call == "plugin"));
+ Assert.Equal(1, calls.Count(call => call == "pin"));
+ Assert.Equal(1, fixture.Render.Diagnostics.BeginCount);
+ Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount);
+ Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount);
+ Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount);
+ Assert.False(pipeline.HasPendingPublication(result));
+ Assert.True(fixture.State.IsLoaded(LandblockId));
+ }
+
+ [Fact]
+ public void CrossOwnerValidationFailure_BlocksBeforeAnyPresentationMutation()
+ {
+ var calls = new List();
+ ConcreteFixture fixture = Fixture(calls);
+ var pipeline = new LandblockPresentationPipeline(
+ fixture.Render,
+ fixture.Physics,
+ fixture.Static,
+ fixture.State,
+ retirementCoordinator: fixture.Retirements);
+ WorldEntity first = Entity(0x80A9B401u);
+ LandblockBuild build = Build(first, Entity(first.Id));
+ LandblockStreamResult.Loaded result = Result(build);
+
+ Assert.Throws(() => pipeline.PublishLoaded(result));
+
+ Assert.Empty(calls);
+ Assert.False(fixture.State.IsLoaded(LandblockId));
+ Assert.Equal(0, fixture.Engine.LandblockCount);
+ Assert.Equal(0, fixture.Render.Diagnostics.BeginCount);
+ Assert.Equal(0, fixture.Physics.Diagnostics.BeginCount);
+ Assert.Equal(0, fixture.Static.Diagnostics.BeginCount);
+ Assert.True(pipeline.HasPendingPublication(result));
+ Assert.Equal(1, pipeline.PendingPublicationCount);
+ }
+
+ [Fact]
+ public void RetainedIdSourceChange_BlocksBeforeReplacementMutation()
+ {
+ var calls = new List();
+ ConcreteFixture fixture = Fixture(calls);
+ var pipeline = new LandblockPresentationPipeline(
+ fixture.Render,
+ fixture.Physics,
+ fixture.Static,
+ fixture.State,
+ retirementCoordinator: fixture.Retirements);
+ WorldEntity first = Entity(0x80A9B401u);
+ pipeline.PublishLoaded(Result(Build(first)));
+ long renderBegins = fixture.Render.Diagnostics.BeginCount;
+ long physicsBegins = fixture.Physics.Diagnostics.BeginCount;
+ long staticBegins = fixture.Static.Diagnostics.BeginCount;
+ calls.Clear();
+ LandblockStreamResult.Loaded changed = Result(Build(
+ Entity(first.Id, sourceId: 0x01000002u)));
+
+ Assert.Throws(() =>
+ pipeline.PublishLoaded(changed));
+
+ Assert.Empty(calls);
+ Assert.Equal(renderBegins, fixture.Render.Diagnostics.BeginCount);
+ Assert.Equal(physicsBegins, fixture.Physics.Diagnostics.BeginCount);
+ Assert.Equal(staticBegins, fixture.Static.Diagnostics.BeginCount);
+ Assert.True(pipeline.HasPendingPublication(changed));
+ }
+
+ [Fact]
+ public void ConcreteConstructor_RequiresSharedRetirementCoordinator()
+ {
+ ConcreteFixture fixture = Fixture(new List());
+
+ Assert.Throws(() =>
+ new LandblockPresentationPipeline(
+ fixture.Render,
+ fixture.Physics,
+ fixture.Static,
+ fixture.State,
+ retirementCoordinator: null));
+ }
+
+ [Fact]
+ public void SameIdReapply_RealActivatorRebindsWithoutDefaultReplay()
+ {
+ ConcreteFixture fixture = Fixture(new List());
+ var pipeline = new LandblockPresentationPipeline(
+ fixture.Render,
+ fixture.Physics,
+ fixture.Static,
+ fixture.State,
+ retirementCoordinator: fixture.Retirements);
+ WorldEntity first = Entity(0x80A9B401u);
+ WorldEntity replacement = Entity(first.Id);
+
+ pipeline.PublishLoaded(Result(Build(first)));
+ Assert.Equal(1, fixture.Runner.ActiveOwnerCount);
+
+ pipeline.PublishLoaded(Result(Build(replacement)));
+
+ Assert.Equal(1, fixture.Runner.ActiveOwnerCount);
+ Assert.True(fixture.Poses.TryGetRootPose(first.Id, out _));
+ }
+
+ [Fact]
+ public void OmittedStaticReapply_RealActivatorBalancesScriptAndPoseOwners()
+ {
+ ConcreteFixture fixture = Fixture(new List());
+ var pipeline = new LandblockPresentationPipeline(
+ fixture.Render,
+ fixture.Physics,
+ fixture.Static,
+ fixture.State,
+ retirementCoordinator: fixture.Retirements);
+ WorldEntity entity = Entity(0x80A9B401u);
+ pipeline.PublishLoaded(Result(Build(entity)));
+ Assert.Equal(1, fixture.Runner.ActiveOwnerCount);
+ Assert.True(fixture.Poses.TryGetRootPose(entity.Id, out _));
+
+ pipeline.PublishLoaded(Result(Build()));
+
+ Assert.Equal(0, fixture.Runner.ActiveOwnerCount);
+ Assert.False(fixture.Poses.TryGetRootPose(entity.Id, out _));
+ }
+
+ private static ConcreteFixture Fixture(
+ List calls,
+ Action? commitEnvCells = null)
+ {
+ var poses = new EntityEffectPoseRegistry();
+ var particleSink = new ParticleHookSink(
+ new ParticleSystem(new EmitterDescRegistry()),
+ poses);
+ var script = new DatPhysicsScript();
+ script.ScriptData.Add(new PhysicsScriptData
+ {
+ StartTime = 10.0,
+ Hook = new CreateParticleHook { EmitterInfoId = 100u },
+ });
+ var runner = new PhysicsScriptRunner(
+ id => id == 0xAAu ? script : null,
+ new NullHookSink());
+ var activator = new EntityScriptActivator(
+ runner,
+ particleSink,
+ poses,
+ _ => new ScriptActivationInfo(0xAAu, Array.Empty()));
+ var state = new GpuWorldState(
+ new LandblockSpawnAdapter(new RecordingMeshAdapter(calls)),
+ entityScriptActivator: activator);
+ var cache = new PhysicsDataCache();
+ var engine = new PhysicsEngine { DataCache = cache };
+ var render = new LandblockRenderPublisher(
+ (_, _, _) => calls.Add("terrain"),
+ static _ => { },
+ new CellVisibility(),
+ state,
+ commitEnvCells: commitEnvCells);
+ var physics = new LandblockPhysicsPublisher(engine, new float[256]);
+ var lighting = new LightingHookSink(
+ new LightManager(),
+ new NullPoseSource());
+ var events = new WorldEvents();
+ var retirements = new LandblockRetirementCoordinator(
+ state,
+ static _ => { },
+ static _ => LandblockRetirementStage.None);
+ return new ConcreteFixture(
+ state,
+ engine,
+ render,
+ physics,
+ new LandblockStaticPresentationPublisher(
+ lighting,
+ new TranslucencyFadeManager(),
+ new WorldGameState(),
+ events),
+ events,
+ retirements,
+ runner,
+ poses);
+ }
+
+ private static LandblockStreamResult.Loaded Result(LandblockBuild build) =>
+ new(
+ LandblockId,
+ LandblockStreamTier.Near,
+ build,
+ EmptyMesh());
+
+ private static LandblockBuild Build(params WorldEntity[] entities)
+ {
+ uint cellId = (LandblockId & 0xFFFF0000u) | 0x0100u;
+ var shell = new EnvCellShellPlacement(
+ cellId,
+ 0x2_0000_0001UL,
+ 0x0D000001u,
+ 1,
+ ImmutableArray.Empty,
+ Vector3.Zero,
+ Quaternion.Identity,
+ Matrix4x4.Identity,
+ new WbBoundingBox(Vector3.Zero, Vector3.One),
+ new WbBoundingBox(Vector3.Zero, Vector3.One));
+ var envCells = new EnvCellLandblockBuild(
+ LandblockId,
+ Array.Empty(),
+ [shell]);
+ return new LandblockBuild(
+ new LoadedLandblock(
+ LandblockId,
+ new LandBlock
+ {
+ Terrain = new TerrainInfo[81],
+ Height = new byte[81],
+ },
+ entities,
+ PhysicsDatBundle.Empty),
+ envCells,
+ new LandblockBuildOrigin(0xA9, 0xB4));
+ }
+
+ private static WorldEntity Entity(
+ uint id,
+ uint sourceId = 0x01000001u) => new()
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = sourceId,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ };
+
+ private static LandblockMeshData EmptyMesh() => new(
+ Array.Empty(),
+ Array.Empty());
+
+ private sealed record ConcreteFixture(
+ GpuWorldState State,
+ PhysicsEngine Engine,
+ LandblockRenderPublisher Render,
+ LandblockPhysicsPublisher Physics,
+ LandblockStaticPresentationPublisher Static,
+ WorldEvents Events,
+ LandblockRetirementCoordinator Retirements,
+ PhysicsScriptRunner Runner,
+ EntityEffectPoseRegistry Poses);
+
+ private sealed class NullHookSink : IAnimationHookSink
+ {
+ public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook)
+ {
+ }
+ }
+
+ private sealed class RecordingMeshAdapter(List calls) : IWbMeshAdapter
+ {
+ public void IncrementRefCount(ulong id)
+ {
+ }
+
+ public void PinPreparedRenderData(ulong id) => calls.Add("pin");
+
+ public void DecrementRefCount(ulong id)
+ {
+ }
+ }
+
+ private sealed class NullPoseSource : IEntityEffectPoseSource
+ {
+ public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
+ {
+ rootWorld = default;
+ return false;
+ }
+
+ public bool TryGetPartPose(
+ uint localEntityId,
+ int partIndex,
+ out Matrix4x4 partLocal)
+ {
+ partLocal = default;
+ return false;
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs
index b82db487..6e7b5101 100644
--- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs
@@ -486,7 +486,8 @@ public sealed class LandblockPhysicsPublisherTests
MethodInfo begin = Assert.Single(
type.GetMethods(),
- method => method.Name == nameof(LandblockPhysicsPublisher.BeginPublication));
+ method => method.Name == nameof(LandblockPhysicsPublisher.BeginPublication)
+ && method.ReturnType == typeof(LandblockPhysicsPublication));
ParameterInfo parameter = Assert.Single(begin.GetParameters());
Assert.Equal(typeof(LandblockRenderPublication), parameter.ParameterType);
Assert.DoesNotContain(type.GetConstructors().SelectMany(constructor =>
diff --git a/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs
new file mode 100644
index 00000000..bc9e1abe
--- /dev/null
+++ b/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs
@@ -0,0 +1,309 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.Streaming;
+using AcDream.Core.Lighting;
+using AcDream.Core.Physics;
+using AcDream.Core.Plugins;
+using AcDream.Core.Rendering;
+using AcDream.Core.Terrain;
+using AcDream.Core.Vfx;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.App.Tests.Streaming;
+
+public sealed class LandblockStaticPresentationPublisherTests
+{
+ private const uint LandblockId = 0xA9B4FFFFu;
+ private const uint AdjacentLandblockId = 0xAAB4FFFFu;
+ private const uint SetupId = 0x02000042u;
+ private static readonly float[] HeightTable = new float[256];
+
+ [Fact]
+ public void Reapply_ReplacesLightsAndRefreshesPluginWithoutSpawnReplay()
+ {
+ var fixture = Fixture();
+ int spawnCount = 0;
+ fixture.Events.EntitySpawned += _ => spawnCount++;
+ WorldEntity first = Entity(0x80A9B401u, new Vector3(12f, 12f, 0f));
+ WorldEntity moved = Entity(first.Id, new Vector3(36f, 12f, 0f));
+
+ Publish(fixture, Build(LandblockId, [first], BundleWithLight()));
+ Assert.Equal(1, fixture.Lights.RegisteredCount);
+ Assert.Equal(1, spawnCount);
+ Assert.Equal(first.Position, Assert.Single(fixture.World.Entities).Position);
+
+ fixture.Lighting.SetOwnerLighting(first.Id, enabled: false);
+ fixture.Translucency.StartPartFade(first.Id, 0u, 0f, 0.6f, 0f);
+ fixture.Translucency.StartPartFade(first.Id, 1u, 0f, 1f, 2f);
+ fixture.Translucency.AdvanceAll(0.5f);
+ Assert.True(fixture.Translucency.TryGetCurrentValue(
+ first.Id,
+ 1u,
+ out float activeFadeBefore));
+ Publish(fixture, Build(LandblockId, [moved], BundleWithLight()));
+
+ Assert.Equal(1, fixture.Lights.RegisteredCount);
+ Assert.Equal(1, spawnCount);
+ Assert.Equal(moved.Position, Assert.Single(fixture.World.Entities).Position);
+ Assert.False(Assert.Single(fixture.Lighting.GetOwnedLights(first.Id)!).IsLit);
+ Assert.Equal(1, fixture.Lighting.RetainedOwnerStateCount);
+ Assert.True(fixture.Translucency.TryGetCurrentValue(
+ first.Id,
+ 0u,
+ out float settled));
+ Assert.Equal(0.6f, settled);
+ Assert.True(fixture.Translucency.TryGetCurrentValue(
+ first.Id,
+ 1u,
+ out float activeFadeAfter));
+ Assert.Equal(activeFadeBefore, activeFadeAfter);
+ Assert.Equal(1, fixture.Publisher.Diagnostics.PluginSpawnCount);
+ Assert.Equal(1, fixture.Publisher.Diagnostics.PluginRefreshCount);
+ Assert.Equal(2, fixture.Publisher.Diagnostics.LightReplacementCount);
+ }
+
+ [Fact]
+ public void Reapply_OmittedStaticRemovesLightTranslucencyAndPluginProjection()
+ {
+ var fixture = Fixture();
+ WorldEntity entity = Entity(0x80A9B401u, new Vector3(12f, 12f, 0f));
+ Publish(fixture, Build(LandblockId, [entity], BundleWithLight()));
+ fixture.Lighting.SetOwnerLighting(entity.Id, enabled: false);
+ fixture.Translucency.StartPartFade(entity.Id, 0u, 0f, 1f, 2f);
+
+ Publish(
+ fixture,
+ Build(LandblockId, Array.Empty(), PhysicsDatBundle.Empty));
+
+ Assert.Equal(0, fixture.Lights.RegisteredCount);
+ Assert.Equal(0, fixture.Lighting.RetainedOwnerStateCount);
+ Assert.False(fixture.Translucency.TryGetCurrentValue(entity.Id, 0u, out _));
+ Assert.Empty(fixture.World.Entities);
+ Assert.Equal(1, fixture.Publisher.Diagnostics.PluginRemovalCount);
+ Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveLandblockCount);
+ Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveEntityCount);
+ }
+
+ [Fact]
+ public void RetirementThenReload_EmitsNewLogicalPluginSpawn()
+ {
+ var fixture = Fixture();
+ int spawnCount = 0;
+ fixture.Events.EntitySpawned += _ => spawnCount++;
+ WorldEntity entity = Entity(0x80A9B401u, new Vector3(12f, 12f, 0f));
+ Publish(fixture, Build(LandblockId, [entity], BundleWithLight()));
+
+ fixture.Publisher.RemoveLighting(entity);
+ fixture.Publisher.RemoveTranslucency(entity);
+ fixture.Publisher.RemovePluginProjection(entity);
+ Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveEntityCount);
+
+ Publish(fixture, Build(LandblockId, [entity], BundleWithLight()));
+
+ Assert.Equal(2, spawnCount);
+ Assert.Equal(1, fixture.Lights.RegisteredCount);
+ Assert.Equal(1, fixture.Publisher.Diagnostics.ActiveEntityCount);
+ }
+
+ [Fact]
+ public void Prepare_RejectsDuplicateAndCrossLandblockStaticIdsBeforeMutation()
+ {
+ var fixture = Fixture();
+ WorldEntity first = Entity(0x80A9B401u, Vector3.Zero);
+ LandblockPhysicsPublication duplicatePhysics = PhysicsPrefix(
+ fixture,
+ Build(LandblockId, [first, Entity(first.Id, Vector3.One)]));
+
+ Assert.Throws(() =>
+ fixture.Publisher.PreparePublication(duplicatePhysics));
+ Assert.Empty(fixture.World.Entities);
+ Assert.Equal(0, fixture.Lights.RegisteredCount);
+
+ Publish(fixture, Build(LandblockId, [first]));
+ LandblockPhysicsPublication adjacentPhysics = PhysicsPrefix(
+ fixture,
+ Build(AdjacentLandblockId, [Entity(first.Id, new Vector3(192f, 0f, 0f))]));
+ Assert.Throws(() =>
+ fixture.Publisher.PreparePublication(adjacentPhysics));
+ Assert.Single(fixture.World.Entities);
+ }
+
+ [Fact]
+ public void LiveProjectionInBuild_IsIgnoredByDatStaticPresentation()
+ {
+ var fixture = Fixture();
+ WorldEntity live = Entity(7u, Vector3.Zero, serverGuid: 0x80000007u);
+
+ Publish(fixture, Build(LandblockId, [live], BundleWithLight()));
+
+ Assert.Empty(fixture.World.Entities);
+ Assert.Equal(0, fixture.Lights.RegisteredCount);
+ Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveEntityCount);
+ }
+
+ [Fact]
+ public void ForeignReceipt_IsRejectedWithoutChangingOwnerState()
+ {
+ var first = Fixture();
+ var second = Fixture();
+ LandblockPhysicsPublication physics = PhysicsPrefix(
+ first,
+ Build(LandblockId, [Entity(1u, Vector3.Zero)]));
+ LandblockStaticPresentationPublication receipt =
+ first.Publisher.PreparePublication(physics);
+
+ Assert.Throws(() =>
+ second.Publisher.BeginPublication(receipt));
+ Assert.Equal(0, second.Publisher.Diagnostics.BeginCount);
+ }
+
+ private static void Publish(FixtureState fixture, LandblockBuild build)
+ {
+ LandblockPhysicsPublication physics = PhysicsPrefix(fixture, build);
+ fixture.Render.CompletePublication(fixture.LastRender!);
+ LandblockStaticPresentationPublication presentation =
+ fixture.Publisher.PreparePublication(physics);
+ fixture.Publisher.BeginPublication(presentation);
+ fixture.Physics.CompletePublication(
+ physics,
+ entity => fixture.Publisher.PrepareEntityBeforeCollision(
+ presentation,
+ entity));
+ fixture.Publisher.CompletePublication(presentation);
+ }
+
+ private static LandblockPhysicsPublication PhysicsPrefix(
+ FixtureState fixture,
+ LandblockBuild build)
+ {
+ LandblockRenderPublication render = fixture.Render.BeginPublication(
+ build,
+ EmptyMesh());
+ fixture.LastRender = render;
+ LandblockPhysicsPublication physics =
+ fixture.Physics.PreparePublication(render);
+ fixture.Physics.BeginPublication(physics);
+ return physics;
+ }
+
+ private static FixtureState Fixture()
+ {
+ var cache = new PhysicsDataCache();
+ var engine = new PhysicsEngine { DataCache = cache };
+ var gpuState = new GpuWorldState();
+ var render = new LandblockRenderPublisher(
+ static (_, _, _) => { },
+ static _ => { },
+ new CellVisibility(),
+ gpuState);
+ var physics = new LandblockPhysicsPublisher(engine, HeightTable);
+ var lights = new LightManager();
+ var lighting = new LightingHookSink(lights, new NullPoseSource());
+ var translucency = new TranslucencyFadeManager();
+ var world = new WorldGameState();
+ var events = new WorldEvents();
+ return new FixtureState(
+ render,
+ physics,
+ new LandblockStaticPresentationPublisher(
+ lighting,
+ translucency,
+ world,
+ events),
+ lights,
+ lighting,
+ translucency,
+ world,
+ events);
+ }
+
+ private static PhysicsDatBundle BundleWithLight()
+ {
+ var setup = new Setup();
+ setup.Lights[0] = new LightInfo
+ {
+ ViewSpaceLocation = new Frame { Orientation = Quaternion.Identity },
+ Color = new ColorARGB
+ {
+ Red = 255,
+ Green = 255,
+ Blue = 255,
+ Alpha = 255,
+ },
+ Intensity = 1f,
+ Falloff = 8f,
+ };
+ return new PhysicsDatBundle(
+ new LandBlockInfo(),
+ new Dictionary(),
+ new Dictionary(),
+ new Dictionary { [SetupId] = setup },
+ new Dictionary());
+ }
+
+ private static LandblockBuild Build(
+ uint landblockId,
+ IReadOnlyList entities,
+ PhysicsDatBundle? bundle = null) => new(
+ new LoadedLandblock(
+ landblockId,
+ new LandBlock
+ {
+ Terrain = new TerrainInfo[81],
+ Height = new byte[81],
+ },
+ entities,
+ bundle ?? PhysicsDatBundle.Empty),
+ Origin: new LandblockBuildOrigin(0xA9, 0xB4));
+
+ private static WorldEntity Entity(
+ uint id,
+ Vector3 position,
+ uint serverGuid = 0u) => new()
+ {
+ Id = id,
+ ServerGuid = serverGuid,
+ SourceGfxObjOrSetupId = SetupId,
+ Position = position,
+ Rotation = Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ };
+
+ private static LandblockMeshData EmptyMesh() => new(
+ Array.Empty(),
+ Array.Empty());
+
+ private sealed record FixtureState(
+ LandblockRenderPublisher Render,
+ LandblockPhysicsPublisher Physics,
+ LandblockStaticPresentationPublisher Publisher,
+ LightManager Lights,
+ LightingHookSink Lighting,
+ TranslucencyFadeManager Translucency,
+ WorldGameState World,
+ WorldEvents Events)
+ {
+ public LandblockRenderPublication? LastRender { get; set; }
+ }
+
+ private sealed class NullPoseSource : IEntityEffectPoseSource
+ {
+ public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
+ {
+ rootWorld = default;
+ return false;
+ }
+
+ public bool TryGetPartPose(
+ uint localEntityId,
+ int partIndex,
+ out Matrix4x4 partLocal)
+ {
+ partLocal = default;
+ return false;
+ }
+ }
+}