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.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)
{

View file

@ -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;
}
/// <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>

View file

@ -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<WorldEntity, ScriptActivationInfo?> _resolver;
private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner;
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<WorldEntity, ScriptActivationInfo>? _rebindDatStaticAnimationOwner;
private readonly Dictionary<uint, StaticOwnerState> _staticOwners = new();
public EntityScriptActivator(
@ -59,8 +66,9 @@ public sealed class EntityScriptActivator
Func<WorldEntity, ScriptActivationInfo?> resolver,
Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null,
Action<uint>? unregisterDatStaticEffectOwner = null,
Action<WorldEntity, ScriptActivationInfo>? registerDatStaticAnimationOwner = null,
Action<uint>? unregisterDatStaticAnimationOwner = null)
Func<WorldEntity, ScriptActivationInfo, bool>? registerDatStaticAnimationOwner = null,
Action<uint>? unregisterDatStaticAnimationOwner = null,
Action<WorldEntity, ScriptActivationInfo>? 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;
}
/// <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)
@ -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)

View file

@ -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<ulong>? mergedRenderIds = additionalRenderIds is null
? null
: new HashSet<ulong>(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<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(
GpuLandblockSpatialPublication publication)
{

View file

@ -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
/// </summary>
public LandblockPhysicsPublication BeginPublication(
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);
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);
}
/// <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();
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);
}
/// <summary>
@ -304,11 +334,10 @@ public sealed class LandblockPhysicsPublisher
LandblockPhysicsPublication publication,
Action<WorldEntity>? 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));
}
}
}

View file

@ -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<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 Action<uint>? _onLandblockLoaded;
private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
@ -79,6 +86,37 @@ public sealed class LandblockPresentationPipeline
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 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;
}

View file

@ -20,17 +20,32 @@ public sealed class LandblockRenderPublication
internal LandblockRenderPublication(
object owner,
LandblockBuild build,
LandblockMeshData meshData,
Vector3 origin,
Dictionary<uint, LoadedCell> visibilityCells)
Dictionary<uint, LoadedCell> visibilityCells,
Vector3 aabbMin,
Vector3 aabbMax)
{
Owner = owner;
Build = build;
MeshData = meshData;
Origin = origin;
_visibilityCells = new ReadOnlyDictionary<uint, LoadedCell>(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;
}
/// <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(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<uint, LoadedCell>();
var visibilityCells = new Dictionary<uint, LoadedCell>();
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);
}
/// <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>
@ -188,28 +254,35 @@ public sealed class LandblockRenderPublisher
/// </summary>
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));
}
}
}

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<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? 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;
}