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:
parent
e1110f7e54
commit
ea0da8c8ae
15 changed files with 1744 additions and 89 deletions
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 =>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue