refactor(streaming): compose landblock presentation transaction

Own render, physics, DAT-static presentation, and retained static-resource rebinds behind retryable receipts so a failed publication resumes its exact unfinished suffix without replaying retail create-time defaults.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-21 21:45:16 +02:00
parent e1110f7e54
commit ea0da8c8ae
15 changed files with 1744 additions and 89 deletions

View file

@ -153,6 +153,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements; private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements;
private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher; private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher;
private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher; private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher;
private AcDream.App.Streaming.LandblockStaticPresentationPublisher?
_landblockStaticPresentationPublisher;
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
private AcDream.App.Streaming.StreamingController? _streamingController; private AcDream.App.Streaming.StreamingController? _streamingController;
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
@ -2194,7 +2196,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_translucencyFades.ClearEntity(ownerId); _translucencyFades.ClearEntity(ownerId);
}, },
(entity, info) => _staticAnimationScheduler.Register(entity, info), (entity, info) => _staticAnimationScheduler.Register(entity, info),
ownerId => _staticAnimationScheduler.Unregister(ownerId)); ownerId => _staticAnimationScheduler.Unregister(ownerId),
(entity, info) => _staticAnimationScheduler.Rebind(entity, info));
_entityScriptActivator = entityScriptActivator; _entityScriptActivator = entityScriptActivator;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock // 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( new AcDream.App.Streaming.LandblockPhysicsPublisher(
_physicsEngine, _physicsEngine,
_heightTable!); _heightTable!);
_landblockStaticPresentationPublisher =
new AcDream.App.Streaming.LandblockStaticPresentationPublisher(
_lightingSink!,
_translucencyFades,
_worldGameState,
_worldEvents);
_clipFrame ??= ClipFrame.NoClip(); _clipFrame ??= ClipFrame.NoClip();
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
@ -2520,6 +2529,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
}); });
_streamer.Start(); _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( _streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad( enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(
new AcDream.App.Streaming.LandblockBuildRequest( new AcDream.App.Streaming.LandblockBuildRequest(
@ -2542,7 +2562,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
onLandblockLoaded: loadedLandblockId => onLandblockLoaded: loadedLandblockId =>
_liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
ensureEnvCellMeshes: _landblockRenderPublisher!.PrepareAfterRenderPins, ensureEnvCellMeshes: _landblockRenderPublisher!.PrepareAfterRenderPins,
retirementCoordinator: _landblockRetirements); retirementCoordinator: _landblockRetirements,
presentationPipeline: landblockPresentationPipeline);
// A.5 T22.5: apply max-completions from resolved quality. // A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
_worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator( _worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
@ -3793,19 +3814,28 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
ticket.RunForEachEntity( ticket.RunForEachEntity(
AcDream.App.Streaming.LandblockRetirementStage.EntityLighting, AcDream.App.Streaming.LandblockRetirementStage.EntityLighting,
entity => !staticOnly || entity.ServerGuid == 0, 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( ticket.RunForEachEntity(
AcDream.App.Streaming.LandblockRetirementStage.EntityTranslucency, AcDream.App.Streaming.LandblockRetirementStage.EntityTranslucency,
entity => !staticOnly || entity.ServerGuid == 0, 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( ticket.RunForEachEntity(
AcDream.App.Streaming.LandblockRetirementStage.PluginProjection, AcDream.App.Streaming.LandblockRetirementStage.PluginProjection,
static entity => entity.ServerGuid == 0, static entity => entity.ServerGuid == 0,
entity => entity => _landblockStaticPresentationPublisher!
{ .RemovePluginProjection(entity));
_worldGameState.RemoveById(entity.Id);
_worldEvents.ForgetEntity(entity.Id);
});
if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full) if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full)
{ {

View file

@ -78,7 +78,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
internal int Count => _owners.Count; internal int Count => _owners.Count;
public void Register(WorldEntity entity, ScriptActivationInfo info) public bool Register(WorldEntity entity, ScriptActivationInfo info)
{ {
ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(info); ArgumentNullException.ThrowIfNull(info);
@ -86,13 +86,13 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
|| info.Setup is not { } setup || info.Setup is not { } setup
|| info.DefaultAnimationId == 0) || info.DefaultAnimationId == 0)
{ {
return; return false;
} }
if (_owners.TryGetValue(entity.Id, out Owner? retained)) if (_owners.TryGetValue(entity.Id, out Owner? retained))
{ {
if (ReferenceEquals(retained.Entity, entity)) if (ReferenceEquals(retained.Entity, entity))
return; return true;
throw new InvalidOperationException( throw new InvalidOperationException(
$"DAT-static animation owner 0x{entity.Id:X8} is already registered."); $"DAT-static animation owner 0x{entity.Id:X8} is already registered.");
} }
@ -110,7 +110,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
new MotionTable(), new MotionTable(),
_animationLoader); _animationLoader);
if (!sequencer.HasCurrentNode) if (!sequencer.HasCurrentNode)
return; return false;
} }
int partCount = setup.Parts.Count; int partCount = setup.Parts.Count;
@ -131,6 +131,63 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram
SurfaceOverrides = surfaces, SurfaceOverrides = surfaces,
PartAvailable = available, PartAvailable = available,
}); });
return true;
}
/// <summary>
/// Rebinds the retained retail static-animation workset member to a fresh
/// landblock snapshot without reconstructing its PartArray sequencer or
/// restarting Setup.DefaultAnimation.
/// </summary>
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();
} }
/// <summary> /// <summary>

View file

@ -40,6 +40,12 @@ public sealed class EntityScriptActivator
public bool ScriptsStopped; public bool ScriptsStopped;
public bool EmittersStopped; public bool EmittersStopped;
public bool PoseRemoved; 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; private readonly PhysicsScriptRunner _scriptRunner;
@ -48,8 +54,9 @@ public sealed class EntityScriptActivator
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver; private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner; private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner;
private readonly Action<uint>? _unregisterDatStaticEffectOwner; private readonly Action<uint>? _unregisterDatStaticEffectOwner;
private readonly Action<WorldEntity, ScriptActivationInfo>? _registerDatStaticAnimationOwner; private readonly Func<WorldEntity, ScriptActivationInfo, bool>? _registerDatStaticAnimationOwner;
private readonly Action<uint>? _unregisterDatStaticAnimationOwner; private readonly Action<uint>? _unregisterDatStaticAnimationOwner;
private readonly Action<WorldEntity, ScriptActivationInfo>? _rebindDatStaticAnimationOwner;
private readonly Dictionary<uint, StaticOwnerState> _staticOwners = new(); private readonly Dictionary<uint, StaticOwnerState> _staticOwners = new();
public EntityScriptActivator( public EntityScriptActivator(
@ -59,8 +66,9 @@ public sealed class EntityScriptActivator
Func<WorldEntity, ScriptActivationInfo?> resolver, Func<WorldEntity, ScriptActivationInfo?> resolver,
Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null, Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null,
Action<uint>? unregisterDatStaticEffectOwner = null, Action<uint>? unregisterDatStaticEffectOwner = null,
Action<WorldEntity, ScriptActivationInfo>? registerDatStaticAnimationOwner = null, Func<WorldEntity, ScriptActivationInfo, bool>? registerDatStaticAnimationOwner = null,
Action<uint>? unregisterDatStaticAnimationOwner = null) Action<uint>? unregisterDatStaticAnimationOwner = null,
Action<WorldEntity, ScriptActivationInfo>? rebindDatStaticAnimationOwner = null)
{ {
_scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner)); _scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner));
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink)); _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
@ -70,6 +78,116 @@ public sealed class EntityScriptActivator
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner; _unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
_registerDatStaticAnimationOwner = registerDatStaticAnimationOwner; _registerDatStaticAnimationOwner = registerDatStaticAnimationOwner;
_unregisterDatStaticAnimationOwner = unregisterDatStaticAnimationOwner; _unregisterDatStaticAnimationOwner = unregisterDatStaticAnimationOwner;
_rebindDatStaticAnimationOwner = rebindDatStaticAnimationOwner;
}
/// <summary>
/// Rebinds a retained DAT-static logical owner to the fresh immutable
/// <see cref="WorldEntity"/> 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.
/// </summary>
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) public void OnCreate(WorldEntity entity)
@ -156,8 +274,8 @@ public sealed class EntityScriptActivator
&& info.DefaultAnimationId != 0 && info.DefaultAnimationId != 0
&& _registerDatStaticAnimationOwner is not null) && _registerDatStaticAnimationOwner is not null)
{ {
_registerDatStaticAnimationOwner(entity, info); state.AnimationOwnerRegistered =
state.AnimationOwnerRegistered = true; _registerDatStaticAnimationOwner(entity, info);
} }
if (!state.ScriptStarted && info.ScriptId != 0) if (!state.ScriptStarted && info.ScriptId != 0)

View file

@ -670,6 +670,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
RequiresActivation: false); 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, // If pending live entities have been waiting for this landblock,
// merge them into the render-thread-owned mutable entity bucket before // merge them into the render-thread-owned mutable entity bucket before
// storing. Subsequent live spawns append to that bucket in O(1). // storing. Subsequent live spawns append to that bucket in O(1).
@ -681,11 +688,6 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
landblock = NormalizeLoadedLandblock(landblock, merged); landblock = NormalizeLoadedLandblock(landblock, merged);
_pendingByLandblock.Remove(landblock.LandblockId); _pendingByLandblock.Remove(landblock.LandblockId);
} }
else
{
landblock = NormalizeLoadedLandblock(landblock);
}
HashSet<ulong>? mergedRenderIds = additionalRenderIds is null HashSet<ulong>? mergedRenderIds = additionalRenderIds is null
? null ? null
: new HashSet<ulong>(additionalRenderIds); : new HashSet<ulong>(additionalRenderIds);
@ -703,7 +705,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
tier = LandblockStreamTier.Near; tier = LandblockStreamTier.Near;
} }
if (_loaded.Remove(landblock.LandblockId, out LoadedLandblock? displaced)) if (_loaded.Remove(landblock.LandblockId, out displaced))
{ {
RemoveLoadedLandblockFromFlatView(displaced); RemoveLoadedLandblockFromFlatView(displaced);
_animatedIndexByLandblock.Remove(landblock.LandblockId); _animatedIndexByLandblock.Remove(landblock.LandblockId);
@ -717,6 +719,39 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
(IEnumerable<ulong>?)mergedRenderIds ?? Array.Empty<ulong>()); (IEnumerable<ulong>?)mergedRenderIds ?? Array.Empty<ulong>());
} }
/// <summary>
/// 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.
/// </summary>
private void ReconcileRetainedStaticEntityScripts(
LoadedLandblock displaced,
LoadedLandblock replacement)
{
if (_entityScriptActivator is null)
return;
var replacementsById = new Dictionary<uint, WorldEntity>();
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( internal void ActivateLandblockPresentation(
GpuLandblockSpatialPublication publication) GpuLandblockSpatialPublication publication)
{ {

View file

@ -24,6 +24,7 @@ public sealed class LandblockPhysicsPublication
internal object Owner { get; } internal object Owner { get; }
internal LandblockBuild Build { get; } internal LandblockBuild Build { get; }
internal bool BeginCommitted { get; set; }
internal bool CompletionCommitted { get; set; } internal bool CompletionCommitted { get; set; }
public uint LandblockId => Build.Landblock.LandblockId; public uint LandblockId => Build.Landblock.LandblockId;
@ -127,6 +128,20 @@ public sealed class LandblockPhysicsPublisher
/// </summary> /// </summary>
public LandblockPhysicsPublication BeginPublication( public LandblockPhysicsPublication BeginPublication(
LandblockRenderPublication renderPublication) LandblockRenderPublication renderPublication)
{
LandblockPhysicsPublication publication = PreparePublication(
renderPublication);
BeginPublication(publication);
return publication;
}
/// <summary>
/// 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.
/// </summary>
public LandblockPhysicsPublication PreparePublication(
LandblockRenderPublication renderPublication)
{ {
ArgumentNullException.ThrowIfNull(renderPublication); ArgumentNullException.ThrowIfNull(renderPublication);
LandblockBuild build = renderPublication.Build; LandblockBuild build = renderPublication.Build;
@ -134,7 +149,25 @@ public sealed class LandblockPhysicsPublisher
if (!IsFinite(origin)) if (!IsFinite(origin))
throw new ArgumentOutOfRangeException(nameof(renderPublication)); throw new ArgumentOutOfRangeException(nameof(renderPublication));
return new LandblockPhysicsPublication(
_receiptOwner,
build,
origin);
}
/// <summary>
/// Advances the exact terrain/cell/building physics replacement from a
/// retained receipt.
/// </summary>
public void BeginPublication(LandblockPhysicsPublication publication)
{
ValidateReceipt(publication);
if (publication.BeginCommitted)
return;
long started = Stopwatch.GetTimestamp(); long started = Stopwatch.GetTimestamp();
LandblockBuild build = publication.Build;
Vector3 origin = publication.Origin;
LoadedLandblock landblock = build.Landblock; LoadedLandblock landblock = build.Landblock;
PhysicsDatBundle datBundle = PhysicsDatBundle datBundle =
landblock.PhysicsDats ?? PhysicsDatBundle.Empty; landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
@ -286,12 +319,9 @@ public sealed class LandblockPhysicsPublisher
_cellSurfaceCount += cellSurfaces.Count; _cellSurfaceCount += cellSurfaces.Count;
_portalPlaneCount += portalPlanes.Count; _portalPlaneCount += portalPlanes.Count;
_buildingCount += buildingCount; _buildingCount += buildingCount;
publication.BeginCommitted = true;
_beginCount++; _beginCount++;
_basePublishTicks += Stopwatch.GetTimestamp() - started; _basePublishTicks += Stopwatch.GetTimestamp() - started;
return new LandblockPhysicsPublication(
_receiptOwner,
build,
origin);
} }
/// <summary> /// <summary>
@ -304,11 +334,10 @@ public sealed class LandblockPhysicsPublisher
LandblockPhysicsPublication publication, LandblockPhysicsPublication publication,
Action<WorldEntity>? beforeStaticCollision = null) Action<WorldEntity>? beforeStaticCollision = null)
{ {
ArgumentNullException.ThrowIfNull(publication); ValidateReceipt(publication);
if (!ReferenceEquals(publication.Owner, _receiptOwner)) if (!publication.BeginCommitted)
throw new ArgumentException( throw new InvalidOperationException(
"The physics publication receipt belongs to another publisher.", "Physics publication cannot complete before its prefix commits.");
nameof(publication));
if (publication.CompletionCommitted) if (publication.CompletionCommitted)
return; return;
@ -651,4 +680,15 @@ public sealed class LandblockPhysicsPublisher
float.IsFinite(value.X) float.IsFinite(value.X)
&& float.IsFinite(value.Y) && float.IsFinite(value.Y)
&& float.IsFinite(value.Z); && 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));
}
}
} }

View file

@ -41,6 +41,9 @@ public sealed class LandblockPresentationPipeline
public required uint LandblockId; public required uint LandblockId;
public LandblockStreamTier Tier; public LandblockStreamTier Tier;
public bool PresentationCommitted; public bool PresentationCommitted;
public LandblockRenderPublication? RenderPublication;
public LandblockPhysicsPublication? PhysicsPublication;
public LandblockStaticPresentationPublication? StaticPublication;
public bool SpatialCommitted; public bool SpatialCommitted;
public GpuLandblockSpatialPublication? SpatialPublication; public GpuLandblockSpatialPublication? SpatialPublication;
public bool SpatialPresentationCommitted; public bool SpatialPresentationCommitted;
@ -48,7 +51,11 @@ public sealed class LandblockPresentationPipeline
public bool LiveRecoveryCommitted; public bool LiveRecoveryCommitted;
} }
private readonly Action<LandblockBuild, LandblockMeshData> _publishBeforeSpatialCommit; private readonly Action<LandblockBuild, LandblockMeshData>?
_publishBeforeSpatialCommit;
private readonly LandblockRenderPublisher? _renderPublisher;
private readonly LandblockPhysicsPublisher? _physicsPublisher;
private readonly LandblockStaticPresentationPublisher? _staticPublisher;
private readonly GpuWorldState _state; private readonly GpuWorldState _state;
private readonly Action<uint>? _onLandblockLoaded; private readonly Action<uint>? _onLandblockLoaded;
private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes; private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
@ -79,6 +86,37 @@ public sealed class LandblockPresentationPipeline
demoteNearLayer); demoteNearLayer);
} }
/// <summary>
/// Production constructor. The pipeline retains each concrete owner
/// receipt before mutation and resumes only the unfinished exact stage
/// after a failure.
/// </summary>
public LandblockPresentationPipeline(
LandblockRenderPublisher renderPublisher,
LandblockPhysicsPublisher physicsPublisher,
LandblockStaticPresentationPublisher staticPublisher,
GpuWorldState state,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? 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 PendingRetirementCount => _retirements.PendingCount;
public int PendingPublicationCount => _publications.Count; public int PendingPublicationCount => _publications.Count;
@ -219,22 +257,60 @@ public sealed class LandblockPresentationPipeline
{ {
if (!transaction.PresentationCommitted) 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 try
// owners and cannot report a committed prefix. Until Slice-5 {
// checkpoints C-E replace it with individually ledgered (_publishBeforeSpatialCommit
// publishers, its production wrapper conservatively reports a ?? throw new InvalidOperationException(
// committed mutation. Test/compatibility callbacks retain the "The presentation pipeline has no publication owners."))(
// established strong-exception contract unless they explicitly transaction.Build,
// report otherwise. transaction.MeshData);
if (error is StreamingMutationException { MutationCommitted: true }) }
_publications.Remove(result); catch (Exception error)
throw; {
// 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; transaction.PresentationCommitted = true;
} }

View file

@ -20,17 +20,32 @@ public sealed class LandblockRenderPublication
internal LandblockRenderPublication( internal LandblockRenderPublication(
object owner, object owner,
LandblockBuild build, LandblockBuild build,
LandblockMeshData meshData,
Vector3 origin, Vector3 origin,
Dictionary<uint, LoadedCell> visibilityCells) Dictionary<uint, LoadedCell> visibilityCells,
Vector3 aabbMin,
Vector3 aabbMax)
{ {
Owner = owner; Owner = owner;
Build = build; Build = build;
MeshData = meshData;
Origin = origin; Origin = origin;
_visibilityCells = new ReadOnlyDictionary<uint, LoadedCell>(visibilityCells); _visibilityCells = new ReadOnlyDictionary<uint, LoadedCell>(visibilityCells);
AabbMin = aabbMin;
AabbMax = aabbMax;
} }
internal object Owner { get; } internal object Owner { get; }
internal LandblockBuild Build { 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; } internal bool CompletionCommitted { get; set; }
public uint LandblockId => Build.Landblock.LandblockId; public uint LandblockId => Build.Landblock.LandblockId;
@ -137,6 +152,22 @@ public sealed class LandblockRenderPublisher
public LandblockRenderPublication BeginPublication( public LandblockRenderPublication BeginPublication(
LandblockBuild build, LandblockBuild build,
LandblockMeshData meshData) LandblockMeshData meshData)
{
LandblockRenderPublication publication = PreparePublication(
build,
meshData);
BeginPublication(publication);
return publication;
}
/// <summary>
/// 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.
/// </summary>
public LandblockRenderPublication PreparePublication(
LandblockBuild build,
LandblockMeshData meshData)
{ {
ArgumentNullException.ThrowIfNull(build); ArgumentNullException.ThrowIfNull(build);
ArgumentNullException.ThrowIfNull(meshData); ArgumentNullException.ThrowIfNull(meshData);
@ -145,7 +176,6 @@ public sealed class LandblockRenderPublisher
"Render publication requires the build's captured origin.", "Render publication requires the build's captured origin.",
nameof(build)); nameof(build));
long beginStarted = Stopwatch.GetTimestamp();
uint landblockId = build.Landblock.LandblockId; uint landblockId = build.Landblock.LandblockId;
if (build.EnvCells is { } candidateEnvCells && if (build.EnvCells is { } candidateEnvCells &&
candidateEnvCells.LandblockId != landblockId) candidateEnvCells.LandblockId != landblockId)
@ -157,28 +187,64 @@ public sealed class LandblockRenderPublisher
Vector3 origin = ComputeOrigin(landblockId, build.Origin); Vector3 origin = ComputeOrigin(landblockId, build.Origin);
long terrainStarted = Stopwatch.GetTimestamp(); var visibilityCells = new Dictionary<uint, LoadedCell>();
_publishTerrain(landblockId, meshData, origin);
_terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted;
var committedCells = new Dictionary<uint, LoadedCell>();
if (build.EnvCells is { } envCells) if (build.EnvCells is { } envCells)
{ {
_cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells);
foreach (LoadedCell cell in envCells.VisibilityCells) foreach (LoadedCell cell in envCells.VisibilityCells)
committedCells[cell.CellId] = cell; visibilityCells[cell.CellId] = cell;
} }
(Vector3 aabbMin, Vector3 aabbMax) = ComputeAabb(meshData, origin); (Vector3 aabbMin, Vector3 aabbMax) = ComputeAabb(meshData, origin);
_worldState.SetLandblockAabb(landblockId, aabbMin, aabbMax);
_beginCount++;
_beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted;
return new LandblockRenderPublication( return new LandblockRenderPublication(
_receiptOwner, _receiptOwner,
build, build,
meshData,
origin, origin,
committedCells); visibilityCells,
aabbMin,
aabbMax);
}
/// <summary>
/// Advances the render prefix from a retained receipt. Every operation is
/// exact replacement, so a callback failure can safely retry this stage.
/// </summary>
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;
} }
/// <summary> /// <summary>
@ -188,28 +254,35 @@ public sealed class LandblockRenderPublisher
/// </summary> /// </summary>
public void CompletePublication(LandblockRenderPublication publication) public void CompletePublication(LandblockRenderPublication publication)
{ {
ArgumentNullException.ThrowIfNull(publication); ValidateReceipt(publication);
if (!ReferenceEquals(publication.Owner, _receiptOwner)) if (!publication.BeginCommitted)
throw new ArgumentException( throw new InvalidOperationException(
"The render publication receipt belongs to another publisher.", "Render publication cannot complete before its prefix commits.");
nameof(publication));
if (publication.CompletionCommitted) if (publication.CompletionCommitted)
return; return;
long started = Stopwatch.GetTimestamp(); long started = Stopwatch.GetTimestamp();
LandblockBuild build = publication.Build; LandblockBuild build = publication.Build;
uint landblockId = publication.LandblockId; uint landblockId = publication.LandblockId;
if (build.Landblock.PhysicsDats?.Info is { } info) if (!publication.BuildingRegistryCommitted)
{ {
uint registryKey = landblockId & 0xFFFF0000u; if (build.Landblock.PhysicsDats?.Info is { } info)
_buildingRegistries[registryKey] = BuildingLoader.Build( {
info, uint registryKey = landblockId & 0xFFFF0000u;
landblockId, _buildingRegistries[registryKey] = BuildingLoader.Build(
publication.VisibilityCells); info,
landblockId,
publication.VisibilityCells);
}
publication.BuildingRegistryCommitted = true;
} }
if (build.EnvCells is { } envCells) if (!publication.EnvCellsCommitted)
_commitEnvCells?.Invoke(envCells); {
if (build.EnvCells is { } envCells)
_commitEnvCells?.Invoke(envCells);
publication.EnvCellsCommitted = true;
}
publication.CompletionCommitted = true; publication.CompletionCommitted = true;
_completeCount++; _completeCount++;
@ -287,4 +360,15 @@ public sealed class LandblockRenderPublisher
new Vector3(origin.X, origin.Y, zMin), new Vector3(origin.X, origin.Y, zMin),
new Vector3(origin.X + 192f, origin.Y + 192f, zMax)); 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));
}
}
} }

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class LandblockStaticPresentationPublication
{
private readonly IReadOnlyDictionary<uint, WorldEntity> _entities;
private readonly IReadOnlyDictionary<uint, WorldEntitySnapshot> _snapshots;
internal LandblockStaticPresentationPublication(
object owner,
LandblockPhysicsPublication physicsPublication,
Dictionary<uint, WorldEntity> entities,
Dictionary<uint, WorldEntitySnapshot> snapshots,
HashSet<uint> previouslyActiveIds)
{
Owner = owner;
PhysicsPublication = physicsPublication;
_entities = entities;
_snapshots = snapshots;
PreviouslyActiveIds = previouslyActiveIds;
}
internal object Owner { get; }
internal LandblockPhysicsPublication PhysicsPublication { get; }
internal HashSet<uint> 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<uint, WorldEntity> Entities => _entities;
public IReadOnlyDictionary<uint, WorldEntitySnapshot> Snapshots => _snapshots;
}
public readonly record struct LandblockStaticPresentationDiagnostics(
long BeginCount,
long CompleteCount,
long LightReplacementCount,
long PluginSpawnCount,
long PluginRefreshCount,
long PluginRemovalCount,
int ActiveLandblockCount,
int ActiveEntityCount);
/// <summary>
/// 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 <see cref="GpuWorldState"/>.
/// </summary>
/// <remarks>
/// Retail constructs each static <c>CPhysicsObj</c> from Setup defaults before
/// <c>CObjCell::init_objects</c> (0x0052B420) inserts/refloods it. The light
/// state follows <c>CPhysicsObj::set_lights</c> (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.
/// </remarks>
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<uint, Dictionary<uint, WorldEntitySnapshot>>
_activeByLandblock = new();
private readonly Dictionary<uint, uint> _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<uint, WorldEntitySnapshot>? active);
var entities = new Dictionary<uint, WorldEntity>();
var snapshots = new Dictionary<uint, WorldEntitySnapshot>();
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<uint> previous = active is not null
? active.Keys.ToHashSet()
: new HashSet<uint>();
return new LandblockStaticPresentationPublication(
_receiptOwner,
physicsPublication,
entities,
snapshots,
previous);
}
/// <summary>
/// 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.
/// </summary>
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++;
}
/// <summary>
/// Exact replacement called by <see cref="LandblockPhysicsPublisher"/>
/// immediately before registering this object's ordinary collision.
/// </summary>
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<LightSource> 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++;
}
/// <summary>
/// Publishes the exact current plugin snapshot after collision/reflood.
/// Existing IDs refresh replay state without emitting another logical
/// spawn; new IDs emit once.
/// </summary>
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<uint, WorldEntitySnapshot>[] 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<uint, WorldEntitySnapshot>? 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;
}

View file

@ -174,21 +174,23 @@ public sealed class StreamingController
Action? clearPendingLoads = null, Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null, Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null, Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null) LandblockRetirementCoordinator? retirementCoordinator = null,
LandblockPresentationPipeline? presentationPipeline = null)
{ {
_enqueueLoad = enqueueLoad; _enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload; _enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions; _drainCompletions = drainCompletions;
_clearPendingLoads = clearPendingLoads; _clearPendingLoads = clearPendingLoads;
_state = state; _state = state;
_presentation = new LandblockPresentationPipeline( _presentation = presentationPipeline
applyTerrain, ?? new LandblockPresentationPipeline(
state, applyTerrain,
onLandblockLoaded, state,
ensureEnvCellMeshes, onLandblockLoaded,
retirementCoordinator, ensureEnvCellMeshes,
removeTerrain, retirementCoordinator,
demoteNearLayer); removeTerrain,
demoteNearLayer);
NearRadius = nearRadius; NearRadius = nearRadius;
FarRadius = farRadius; FarRadius = farRadius;
} }

View file

@ -64,6 +64,42 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
Assert.Equal(2, posePublishes); 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] [Fact]
public void ShortStaticAnimFrame_RetainsCompleteTrailingPartState() public void ShortStaticAnimFrame_RetainsCompleteTrailingPartState()
{ {

View file

@ -282,6 +282,7 @@ public sealed class EntityScriptActivatorTests
registerCount++; registerCount++;
registeredEntity = entity; registeredEntity = entity;
Assert.Same(info, actual); Assert.Same(info, actual);
return true;
}, },
unregisterDatStaticAnimationOwner: ownerId => unregisterDatStaticAnimationOwner: ownerId =>
{ {
@ -313,7 +314,11 @@ public sealed class EntityScriptActivatorTests
ScriptId: 0, ScriptId: 0,
PartTransforms: Array.Empty<Matrix4x4>(), PartTransforms: Array.Empty<Matrix4x4>(),
DefaultAnimationId: 0x03000001u), DefaultAnimationId: 0x03000001u),
registerDatStaticAnimationOwner: (_, _) => registerCount++); registerDatStaticAnimationOwner: (_, _) =>
{
registerCount++;
return true;
});
activator.OnCreate(MakeEntity(0x70000420u, Vector3.Zero)); activator.OnCreate(MakeEntity(0x70000420u, Vector3.Zero));
@ -335,7 +340,11 @@ public sealed class EntityScriptActivatorTests
PartTransforms: Array.Empty<Matrix4x4>(), PartTransforms: Array.Empty<Matrix4x4>(),
DefaultAnimationId: 0x03000001u, DefaultAnimationId: 0x03000001u,
UsesStaticAnimationWorkset: true), UsesStaticAnimationWorkset: true),
registerDatStaticAnimationOwner: (_, _) => registerCount++, registerDatStaticAnimationOwner: (_, _) =>
{
registerCount++;
return true;
},
unregisterDatStaticAnimationOwner: _ => unregisterCount++); unregisterDatStaticAnimationOwner: _ => unregisterCount++);
WorldEntity entity = MakeEntity(0x70000421u, Vector3.Zero); WorldEntity entity = MakeEntity(0x70000421u, Vector3.Zero);
@ -378,6 +387,7 @@ public sealed class EntityScriptActivatorTests
animationAttempts++; animationAttempts++;
if (animationAttempts == 1) if (animationAttempts == 1)
throw new InvalidOperationException("animation acquire fault"); throw new InvalidOperationException("animation acquire fault");
return true;
}); });
Assert.Throws<InvalidOperationException>(() => activator.OnCreate(entity)); Assert.Throws<InvalidOperationException>(() => activator.OnCreate(entity));
@ -424,7 +434,7 @@ public sealed class EntityScriptActivatorTests
if (effectReleaseAttempts == 1) if (effectReleaseAttempts == 1)
throw new InvalidOperationException("effect release fault"); throw new InvalidOperationException("effect release fault");
}, },
registerDatStaticAnimationOwner: (_, _) => { }, registerDatStaticAnimationOwner: (_, _) => true,
unregisterDatStaticAnimationOwner: _ => unregisterDatStaticAnimationOwner: _ =>
{ {
animationReleaseAttempts++; animationReleaseAttempts++;
@ -465,6 +475,97 @@ public sealed class EntityScriptActivatorTests
Assert.Equal(first.Id, GameWindow.ParticleEntityKey(first)); 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<Matrix4x4>(),
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<InvalidOperationException>(() =>
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<Matrix4x4>(),
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] [Fact]
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid() public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
{ {

View file

@ -35,6 +35,7 @@ public sealed class GpuWorldStateActivatorTests
GpuWorldState State, GpuWorldState State,
PhysicsScriptRunner Runner, PhysicsScriptRunner Runner,
ParticleHookSink Sink, ParticleHookSink Sink,
EntityEffectPoseRegistry Poses,
RecordingSink Recording); RecordingSink Recording);
private static Pipeline BuildPipeline(uint scriptId) private static Pipeline BuildPipeline(uint scriptId)
@ -57,7 +58,7 @@ public sealed class GpuWorldStateActivatorTests
_ => new ScriptActivationInfo(scriptId, Array.Empty<Matrix4x4>())); _ => new ScriptActivationInfo(scriptId, Array.Empty<Matrix4x4>()));
var state = new GpuWorldState(entityScriptActivator: activator); 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) private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities)
@ -135,6 +136,45 @@ public sealed class GpuWorldStateActivatorTests
Assert.Equal(0, p.Runner.ActiveScriptCount); 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] [Fact]
public void AddEntitiesToExistingLandblock_FiresActivatorForEachPromoted() public void AddEntitiesToExistingLandblock_FiresActivatorForEachPromoted()
{ {

View file

@ -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<string>();
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<string>();
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<InvalidOperationException>(() => 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<string>();
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<InvalidOperationException>(() => 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<string>();
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<InvalidOperationException>(() =>
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<string>());
Assert.Throws<ArgumentNullException>(() =>
new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
retirementCoordinator: null));
}
[Fact]
public void SameIdReapply_RealActivatorRebindsWithoutDefaultReplay()
{
ConcreteFixture fixture = Fixture(new List<string>());
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<string>());
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<string> calls,
Action<EnvCellLandblockBuild>? 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<Matrix4x4>()));
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<ushort>.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<LoadedCell>(),
[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<MeshRef>(),
};
private static LandblockMeshData EmptyMesh() => new(
Array.Empty<TerrainVertex>(),
Array.Empty<uint>());
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<string> 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;
}
}
}

View file

@ -486,7 +486,8 @@ public sealed class LandblockPhysicsPublisherTests
MethodInfo begin = Assert.Single( MethodInfo begin = Assert.Single(
type.GetMethods(), type.GetMethods(),
method => method.Name == nameof(LandblockPhysicsPublisher.BeginPublication)); method => method.Name == nameof(LandblockPhysicsPublisher.BeginPublication)
&& method.ReturnType == typeof(LandblockPhysicsPublication));
ParameterInfo parameter = Assert.Single(begin.GetParameters()); ParameterInfo parameter = Assert.Single(begin.GetParameters());
Assert.Equal(typeof(LandblockRenderPublication), parameter.ParameterType); Assert.Equal(typeof(LandblockRenderPublication), parameter.ParameterType);
Assert.DoesNotContain(type.GetConstructors().SelectMany(constructor => Assert.DoesNotContain(type.GetConstructors().SelectMany(constructor =>

View file

@ -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<WorldEntity>(), 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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<ArgumentException>(() =>
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<uint, EnvCell>(),
new Dictionary<uint, DatReaderWriter.DBObjs.Environment>(),
new Dictionary<uint, Setup> { [SetupId] = setup },
new Dictionary<uint, GfxObj>());
}
private static LandblockBuild Build(
uint landblockId,
IReadOnlyList<WorldEntity> 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<MeshRef>(),
};
private static LandblockMeshData EmptyMesh() => new(
Array.Empty<TerrainVertex>(),
Array.Empty<uint>());
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;
}
}
}