refactor(streaming): compose landblock presentation transaction

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

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

View file

@ -64,6 +64,42 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests
Assert.Equal(2, posePublishes);
}
[Fact]
public void Rebind_RetainsSequencerAndPublishesIntoReplacementEntity()
{
var loader = new Loader();
loader.Add(AnimationId, TwoFrameAnimation());
var scheduler = new RetailStaticAnimatingObjectScheduler(
loader,
(_, _) => { },
(_, _, _) => { });
Setup setup = MakeSetup();
WorldEntity first = MakeEntity();
ScriptActivationInfo info = new(
ScriptId: 0,
PartTransforms: first.IndexedPartTransforms,
PartAvailability: first.IndexedPartAvailable,
Setup: setup,
DefaultAnimationId: AnimationId,
UsesStaticAnimationWorkset: true);
scheduler.Register(first, info);
scheduler.Tick(1f / 60f);
float phaseBeforeRebind = first.MeshRefs[0].PartTransform.Translation.X;
WorldEntity replacement = MakeEntity();
replacement.Position = new Vector3(4f, 5f, 6f);
scheduler.Rebind(replacement, info with
{
PartTransforms = replacement.IndexedPartTransforms,
PartAvailability = replacement.IndexedPartAvailable,
});
scheduler.Tick(1f / 60f);
Assert.Equal(1, scheduler.Count);
Assert.True(
replacement.MeshRefs[0].PartTransform.Translation.X > phaseBeforeRebind);
}
[Fact]
public void ShortStaticAnimFrame_RetainsCompleteTrailingPartState()
{

View file

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

View file

@ -35,6 +35,7 @@ public sealed class GpuWorldStateActivatorTests
GpuWorldState State,
PhysicsScriptRunner Runner,
ParticleHookSink Sink,
EntityEffectPoseRegistry Poses,
RecordingSink Recording);
private static Pipeline BuildPipeline(uint scriptId)
@ -57,7 +58,7 @@ public sealed class GpuWorldStateActivatorTests
_ => new ScriptActivationInfo(scriptId, Array.Empty<Matrix4x4>()));
var state = new GpuWorldState(entityScriptActivator: activator);
return new Pipeline(state, runner, sink, recording);
return new Pipeline(state, runner, sink, poses, recording);
}
private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities)
@ -135,6 +136,45 @@ public sealed class GpuWorldStateActivatorTests
Assert.Equal(0, p.Runner.ActiveScriptCount);
}
[Fact]
public void SameIdLandblockRehydrate_RebindsOwnerWithoutReplayingDefaultScript()
{
var p = BuildPipeline(scriptId: 0xAAu);
WorldEntity first = DatHydrated(
id: 0x40A9B401u,
pos: new Vector3(1f, 2f, 3f));
WorldEntity replacement = DatHydrated(
id: first.Id,
pos: new Vector3(8f, 9f, 10f));
p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, first));
Assert.Equal(1, p.Runner.ActiveScriptCount);
p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, replacement));
Assert.Equal(1, p.Runner.ActiveScriptCount);
Assert.True(p.Poses.TryGetRootPose(first.Id, out Matrix4x4 root));
Assert.Equal(replacement.Position, root.Translation);
p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls);
Assert.Equal(replacement.Position, p.Recording.Calls[0].Pos);
}
[Fact]
public void SameLandblockRehydrate_OmittedStaticRetiresScriptAndPose()
{
var p = BuildPipeline(scriptId: 0xAAu);
WorldEntity entity = DatHydrated(0x40A9B401u, Vector3.Zero);
p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, entity));
Assert.Equal(1, p.Runner.ActiveScriptCount);
Assert.True(p.Poses.TryGetRootPose(entity.Id, out _));
p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
Assert.Equal(0, p.Runner.ActiveScriptCount);
Assert.False(p.Poses.TryGetRootPose(entity.Id, out _));
}
[Fact]
public void AddEntitiesToExistingLandblock_FiresActivatorForEachPromoted()
{

View file

@ -0,0 +1,380 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Core.Plugins;
using AcDream.Core.Rendering;
using AcDream.Core.Terrain;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.App.Tests.Streaming;
public sealed class LandblockConcretePresentationPipelineTests
{
private const uint LandblockId = 0xA9B4FFFFu;
[Fact]
public void Loaded_ConcreteOwnersPreserveRetailPrefixAndSpatialOrder()
{
var calls = new List<string>();
ConcreteFixture fixture = Fixture(
calls,
commitEnvCells: _ => calls.Add("envcell"));
fixture.Events.EntitySpawned += _ =>
{
Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount);
Assert.False(fixture.State.IsLoaded(LandblockId));
calls.Add("plugin");
};
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
onLandblockLoaded: _ => calls.Add("live-recovery"),
retirementCoordinator: fixture.Retirements);
LandblockBuild build = Build(Entity(0x80A9B401u));
pipeline.PublishLoaded(Result(build));
Assert.Equal(
["terrain", "envcell", "plugin", "pin", "live-recovery"],
calls);
Assert.True(fixture.State.IsNearTier(LandblockId));
Assert.Equal(1, fixture.Render.Diagnostics.BeginCount);
Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount);
Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount);
}
[Fact]
public void RenderSuffixFailure_ResumesConcreteReceiptsWithoutReplayingPrefixes()
{
var calls = new List<string>();
bool failEnvOnce = true;
ConcreteFixture fixture = Fixture(
calls,
commitEnvCells: _ =>
{
calls.Add("envcell");
if (failEnvOnce)
{
failEnvOnce = false;
throw new InvalidOperationException("injected envcell failure");
}
});
fixture.Events.EntitySpawned += _ => calls.Add("plugin");
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
retirementCoordinator: fixture.Retirements);
LandblockBuild build = Build(Entity(0x80A9B401u));
LandblockStreamResult.Loaded result = Result(build);
Assert.Throws<InvalidOperationException>(() => pipeline.PublishLoaded(result));
Assert.False(fixture.State.IsLoaded(LandblockId));
Assert.True(pipeline.HasPendingPublication(result));
Assert.Equal(1, fixture.Render.Diagnostics.BeginCount);
Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount);
Assert.Equal(0, fixture.Physics.Diagnostics.CompleteCount);
Assert.Equal(0, fixture.Static.Diagnostics.BeginCount);
pipeline.ResumePublication(result);
Assert.Equal(1, calls.Count(call => call == "terrain"));
Assert.Equal(2, calls.Count(call => call == "envcell"));
Assert.Equal(1, calls.Count(call => call == "plugin"));
Assert.Equal(1, calls.Count(call => call == "pin"));
Assert.Equal(1, fixture.Render.Diagnostics.BeginCount);
Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount);
Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount);
Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount);
Assert.False(pipeline.HasPendingPublication(result));
Assert.True(fixture.State.IsLoaded(LandblockId));
}
[Fact]
public void CrossOwnerValidationFailure_BlocksBeforeAnyPresentationMutation()
{
var calls = new List<string>();
ConcreteFixture fixture = Fixture(calls);
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
retirementCoordinator: fixture.Retirements);
WorldEntity first = Entity(0x80A9B401u);
LandblockBuild build = Build(first, Entity(first.Id));
LandblockStreamResult.Loaded result = Result(build);
Assert.Throws<InvalidOperationException>(() => pipeline.PublishLoaded(result));
Assert.Empty(calls);
Assert.False(fixture.State.IsLoaded(LandblockId));
Assert.Equal(0, fixture.Engine.LandblockCount);
Assert.Equal(0, fixture.Render.Diagnostics.BeginCount);
Assert.Equal(0, fixture.Physics.Diagnostics.BeginCount);
Assert.Equal(0, fixture.Static.Diagnostics.BeginCount);
Assert.True(pipeline.HasPendingPublication(result));
Assert.Equal(1, pipeline.PendingPublicationCount);
}
[Fact]
public void RetainedIdSourceChange_BlocksBeforeReplacementMutation()
{
var calls = new List<string>();
ConcreteFixture fixture = Fixture(calls);
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
retirementCoordinator: fixture.Retirements);
WorldEntity first = Entity(0x80A9B401u);
pipeline.PublishLoaded(Result(Build(first)));
long renderBegins = fixture.Render.Diagnostics.BeginCount;
long physicsBegins = fixture.Physics.Diagnostics.BeginCount;
long staticBegins = fixture.Static.Diagnostics.BeginCount;
calls.Clear();
LandblockStreamResult.Loaded changed = Result(Build(
Entity(first.Id, sourceId: 0x01000002u)));
Assert.Throws<InvalidOperationException>(() =>
pipeline.PublishLoaded(changed));
Assert.Empty(calls);
Assert.Equal(renderBegins, fixture.Render.Diagnostics.BeginCount);
Assert.Equal(physicsBegins, fixture.Physics.Diagnostics.BeginCount);
Assert.Equal(staticBegins, fixture.Static.Diagnostics.BeginCount);
Assert.True(pipeline.HasPendingPublication(changed));
}
[Fact]
public void ConcreteConstructor_RequiresSharedRetirementCoordinator()
{
ConcreteFixture fixture = Fixture(new List<string>());
Assert.Throws<ArgumentNullException>(() =>
new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
retirementCoordinator: null));
}
[Fact]
public void SameIdReapply_RealActivatorRebindsWithoutDefaultReplay()
{
ConcreteFixture fixture = Fixture(new List<string>());
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
retirementCoordinator: fixture.Retirements);
WorldEntity first = Entity(0x80A9B401u);
WorldEntity replacement = Entity(first.Id);
pipeline.PublishLoaded(Result(Build(first)));
Assert.Equal(1, fixture.Runner.ActiveOwnerCount);
pipeline.PublishLoaded(Result(Build(replacement)));
Assert.Equal(1, fixture.Runner.ActiveOwnerCount);
Assert.True(fixture.Poses.TryGetRootPose(first.Id, out _));
}
[Fact]
public void OmittedStaticReapply_RealActivatorBalancesScriptAndPoseOwners()
{
ConcreteFixture fixture = Fixture(new List<string>());
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
retirementCoordinator: fixture.Retirements);
WorldEntity entity = Entity(0x80A9B401u);
pipeline.PublishLoaded(Result(Build(entity)));
Assert.Equal(1, fixture.Runner.ActiveOwnerCount);
Assert.True(fixture.Poses.TryGetRootPose(entity.Id, out _));
pipeline.PublishLoaded(Result(Build()));
Assert.Equal(0, fixture.Runner.ActiveOwnerCount);
Assert.False(fixture.Poses.TryGetRootPose(entity.Id, out _));
}
private static ConcreteFixture Fixture(
List<string> calls,
Action<EnvCellLandblockBuild>? commitEnvCells = null)
{
var poses = new EntityEffectPoseRegistry();
var particleSink = new ParticleHookSink(
new ParticleSystem(new EmitterDescRegistry()),
poses);
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = 10.0,
Hook = new CreateParticleHook { EmitterInfoId = 100u },
});
var runner = new PhysicsScriptRunner(
id => id == 0xAAu ? script : null,
new NullHookSink());
var activator = new EntityScriptActivator(
runner,
particleSink,
poses,
_ => new ScriptActivationInfo(0xAAu, Array.Empty<Matrix4x4>()));
var state = new GpuWorldState(
new LandblockSpawnAdapter(new RecordingMeshAdapter(calls)),
entityScriptActivator: activator);
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
var render = new LandblockRenderPublisher(
(_, _, _) => calls.Add("terrain"),
static _ => { },
new CellVisibility(),
state,
commitEnvCells: commitEnvCells);
var physics = new LandblockPhysicsPublisher(engine, new float[256]);
var lighting = new LightingHookSink(
new LightManager(),
new NullPoseSource());
var events = new WorldEvents();
var retirements = new LandblockRetirementCoordinator(
state,
static _ => { },
static _ => LandblockRetirementStage.None);
return new ConcreteFixture(
state,
engine,
render,
physics,
new LandblockStaticPresentationPublisher(
lighting,
new TranslucencyFadeManager(),
new WorldGameState(),
events),
events,
retirements,
runner,
poses);
}
private static LandblockStreamResult.Loaded Result(LandblockBuild build) =>
new(
LandblockId,
LandblockStreamTier.Near,
build,
EmptyMesh());
private static LandblockBuild Build(params WorldEntity[] entities)
{
uint cellId = (LandblockId & 0xFFFF0000u) | 0x0100u;
var shell = new EnvCellShellPlacement(
cellId,
0x2_0000_0001UL,
0x0D000001u,
1,
ImmutableArray<ushort>.Empty,
Vector3.Zero,
Quaternion.Identity,
Matrix4x4.Identity,
new WbBoundingBox(Vector3.Zero, Vector3.One),
new WbBoundingBox(Vector3.Zero, Vector3.One));
var envCells = new EnvCellLandblockBuild(
LandblockId,
Array.Empty<LoadedCell>(),
[shell]);
return new LandblockBuild(
new LoadedLandblock(
LandblockId,
new LandBlock
{
Terrain = new TerrainInfo[81],
Height = new byte[81],
},
entities,
PhysicsDatBundle.Empty),
envCells,
new LandblockBuildOrigin(0xA9, 0xB4));
}
private static WorldEntity Entity(
uint id,
uint sourceId = 0x01000001u) => new()
{
Id = id,
SourceGfxObjOrSetupId = sourceId,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
private static LandblockMeshData EmptyMesh() => new(
Array.Empty<TerrainVertex>(),
Array.Empty<uint>());
private sealed record ConcreteFixture(
GpuWorldState State,
PhysicsEngine Engine,
LandblockRenderPublisher Render,
LandblockPhysicsPublisher Physics,
LandblockStaticPresentationPublisher Static,
WorldEvents Events,
LandblockRetirementCoordinator Retirements,
PhysicsScriptRunner Runner,
EntityEffectPoseRegistry Poses);
private sealed class NullHookSink : IAnimationHookSink
{
public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook)
{
}
}
private sealed class RecordingMeshAdapter(List<string> calls) : IWbMeshAdapter
{
public void IncrementRefCount(ulong id)
{
}
public void PinPreparedRenderData(ulong id) => calls.Add("pin");
public void DecrementRefCount(ulong id)
{
}
}
private sealed class NullPoseSource : IEntityEffectPoseSource
{
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
{
rootWorld = default;
return false;
}
public bool TryGetPartPose(
uint localEntityId,
int partIndex,
out Matrix4x4 partLocal)
{
partLocal = default;
return false;
}
}
}

View file

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

View file

@ -0,0 +1,309 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Streaming;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Core.Plugins;
using AcDream.Core.Rendering;
using AcDream.Core.Terrain;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.App.Tests.Streaming;
public sealed class LandblockStaticPresentationPublisherTests
{
private const uint LandblockId = 0xA9B4FFFFu;
private const uint AdjacentLandblockId = 0xAAB4FFFFu;
private const uint SetupId = 0x02000042u;
private static readonly float[] HeightTable = new float[256];
[Fact]
public void Reapply_ReplacesLightsAndRefreshesPluginWithoutSpawnReplay()
{
var fixture = Fixture();
int spawnCount = 0;
fixture.Events.EntitySpawned += _ => spawnCount++;
WorldEntity first = Entity(0x80A9B401u, new Vector3(12f, 12f, 0f));
WorldEntity moved = Entity(first.Id, new Vector3(36f, 12f, 0f));
Publish(fixture, Build(LandblockId, [first], BundleWithLight()));
Assert.Equal(1, fixture.Lights.RegisteredCount);
Assert.Equal(1, spawnCount);
Assert.Equal(first.Position, Assert.Single(fixture.World.Entities).Position);
fixture.Lighting.SetOwnerLighting(first.Id, enabled: false);
fixture.Translucency.StartPartFade(first.Id, 0u, 0f, 0.6f, 0f);
fixture.Translucency.StartPartFade(first.Id, 1u, 0f, 1f, 2f);
fixture.Translucency.AdvanceAll(0.5f);
Assert.True(fixture.Translucency.TryGetCurrentValue(
first.Id,
1u,
out float activeFadeBefore));
Publish(fixture, Build(LandblockId, [moved], BundleWithLight()));
Assert.Equal(1, fixture.Lights.RegisteredCount);
Assert.Equal(1, spawnCount);
Assert.Equal(moved.Position, Assert.Single(fixture.World.Entities).Position);
Assert.False(Assert.Single(fixture.Lighting.GetOwnedLights(first.Id)!).IsLit);
Assert.Equal(1, fixture.Lighting.RetainedOwnerStateCount);
Assert.True(fixture.Translucency.TryGetCurrentValue(
first.Id,
0u,
out float settled));
Assert.Equal(0.6f, settled);
Assert.True(fixture.Translucency.TryGetCurrentValue(
first.Id,
1u,
out float activeFadeAfter));
Assert.Equal(activeFadeBefore, activeFadeAfter);
Assert.Equal(1, fixture.Publisher.Diagnostics.PluginSpawnCount);
Assert.Equal(1, fixture.Publisher.Diagnostics.PluginRefreshCount);
Assert.Equal(2, fixture.Publisher.Diagnostics.LightReplacementCount);
}
[Fact]
public void Reapply_OmittedStaticRemovesLightTranslucencyAndPluginProjection()
{
var fixture = Fixture();
WorldEntity entity = Entity(0x80A9B401u, new Vector3(12f, 12f, 0f));
Publish(fixture, Build(LandblockId, [entity], BundleWithLight()));
fixture.Lighting.SetOwnerLighting(entity.Id, enabled: false);
fixture.Translucency.StartPartFade(entity.Id, 0u, 0f, 1f, 2f);
Publish(
fixture,
Build(LandblockId, Array.Empty<WorldEntity>(), PhysicsDatBundle.Empty));
Assert.Equal(0, fixture.Lights.RegisteredCount);
Assert.Equal(0, fixture.Lighting.RetainedOwnerStateCount);
Assert.False(fixture.Translucency.TryGetCurrentValue(entity.Id, 0u, out _));
Assert.Empty(fixture.World.Entities);
Assert.Equal(1, fixture.Publisher.Diagnostics.PluginRemovalCount);
Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveLandblockCount);
Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveEntityCount);
}
[Fact]
public void RetirementThenReload_EmitsNewLogicalPluginSpawn()
{
var fixture = Fixture();
int spawnCount = 0;
fixture.Events.EntitySpawned += _ => spawnCount++;
WorldEntity entity = Entity(0x80A9B401u, new Vector3(12f, 12f, 0f));
Publish(fixture, Build(LandblockId, [entity], BundleWithLight()));
fixture.Publisher.RemoveLighting(entity);
fixture.Publisher.RemoveTranslucency(entity);
fixture.Publisher.RemovePluginProjection(entity);
Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveEntityCount);
Publish(fixture, Build(LandblockId, [entity], BundleWithLight()));
Assert.Equal(2, spawnCount);
Assert.Equal(1, fixture.Lights.RegisteredCount);
Assert.Equal(1, fixture.Publisher.Diagnostics.ActiveEntityCount);
}
[Fact]
public void Prepare_RejectsDuplicateAndCrossLandblockStaticIdsBeforeMutation()
{
var fixture = Fixture();
WorldEntity first = Entity(0x80A9B401u, Vector3.Zero);
LandblockPhysicsPublication duplicatePhysics = PhysicsPrefix(
fixture,
Build(LandblockId, [first, Entity(first.Id, Vector3.One)]));
Assert.Throws<InvalidOperationException>(() =>
fixture.Publisher.PreparePublication(duplicatePhysics));
Assert.Empty(fixture.World.Entities);
Assert.Equal(0, fixture.Lights.RegisteredCount);
Publish(fixture, Build(LandblockId, [first]));
LandblockPhysicsPublication adjacentPhysics = PhysicsPrefix(
fixture,
Build(AdjacentLandblockId, [Entity(first.Id, new Vector3(192f, 0f, 0f))]));
Assert.Throws<InvalidOperationException>(() =>
fixture.Publisher.PreparePublication(adjacentPhysics));
Assert.Single(fixture.World.Entities);
}
[Fact]
public void LiveProjectionInBuild_IsIgnoredByDatStaticPresentation()
{
var fixture = Fixture();
WorldEntity live = Entity(7u, Vector3.Zero, serverGuid: 0x80000007u);
Publish(fixture, Build(LandblockId, [live], BundleWithLight()));
Assert.Empty(fixture.World.Entities);
Assert.Equal(0, fixture.Lights.RegisteredCount);
Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveEntityCount);
}
[Fact]
public void ForeignReceipt_IsRejectedWithoutChangingOwnerState()
{
var first = Fixture();
var second = Fixture();
LandblockPhysicsPublication physics = PhysicsPrefix(
first,
Build(LandblockId, [Entity(1u, Vector3.Zero)]));
LandblockStaticPresentationPublication receipt =
first.Publisher.PreparePublication(physics);
Assert.Throws<ArgumentException>(() =>
second.Publisher.BeginPublication(receipt));
Assert.Equal(0, second.Publisher.Diagnostics.BeginCount);
}
private static void Publish(FixtureState fixture, LandblockBuild build)
{
LandblockPhysicsPublication physics = PhysicsPrefix(fixture, build);
fixture.Render.CompletePublication(fixture.LastRender!);
LandblockStaticPresentationPublication presentation =
fixture.Publisher.PreparePublication(physics);
fixture.Publisher.BeginPublication(presentation);
fixture.Physics.CompletePublication(
physics,
entity => fixture.Publisher.PrepareEntityBeforeCollision(
presentation,
entity));
fixture.Publisher.CompletePublication(presentation);
}
private static LandblockPhysicsPublication PhysicsPrefix(
FixtureState fixture,
LandblockBuild build)
{
LandblockRenderPublication render = fixture.Render.BeginPublication(
build,
EmptyMesh());
fixture.LastRender = render;
LandblockPhysicsPublication physics =
fixture.Physics.PreparePublication(render);
fixture.Physics.BeginPublication(physics);
return physics;
}
private static FixtureState Fixture()
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
var gpuState = new GpuWorldState();
var render = new LandblockRenderPublisher(
static (_, _, _) => { },
static _ => { },
new CellVisibility(),
gpuState);
var physics = new LandblockPhysicsPublisher(engine, HeightTable);
var lights = new LightManager();
var lighting = new LightingHookSink(lights, new NullPoseSource());
var translucency = new TranslucencyFadeManager();
var world = new WorldGameState();
var events = new WorldEvents();
return new FixtureState(
render,
physics,
new LandblockStaticPresentationPublisher(
lighting,
translucency,
world,
events),
lights,
lighting,
translucency,
world,
events);
}
private static PhysicsDatBundle BundleWithLight()
{
var setup = new Setup();
setup.Lights[0] = new LightInfo
{
ViewSpaceLocation = new Frame { Orientation = Quaternion.Identity },
Color = new ColorARGB
{
Red = 255,
Green = 255,
Blue = 255,
Alpha = 255,
},
Intensity = 1f,
Falloff = 8f,
};
return new PhysicsDatBundle(
new LandBlockInfo(),
new Dictionary<uint, EnvCell>(),
new Dictionary<uint, DatReaderWriter.DBObjs.Environment>(),
new Dictionary<uint, Setup> { [SetupId] = setup },
new Dictionary<uint, GfxObj>());
}
private static LandblockBuild Build(
uint landblockId,
IReadOnlyList<WorldEntity> entities,
PhysicsDatBundle? bundle = null) => new(
new LoadedLandblock(
landblockId,
new LandBlock
{
Terrain = new TerrainInfo[81],
Height = new byte[81],
},
entities,
bundle ?? PhysicsDatBundle.Empty),
Origin: new LandblockBuildOrigin(0xA9, 0xB4));
private static WorldEntity Entity(
uint id,
Vector3 position,
uint serverGuid = 0u) => new()
{
Id = id,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = SetupId,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
private static LandblockMeshData EmptyMesh() => new(
Array.Empty<TerrainVertex>(),
Array.Empty<uint>());
private sealed record FixtureState(
LandblockRenderPublisher Render,
LandblockPhysicsPublisher Physics,
LandblockStaticPresentationPublisher Publisher,
LightManager Lights,
LightingHookSink Lighting,
TranslucencyFadeManager Translucency,
WorldGameState World,
WorldEvents Events)
{
public LandblockRenderPublication? LastRender { get; set; }
}
private sealed class NullPoseSource : IEntityEffectPoseSource
{
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
{
rootWorld = default;
return false;
}
public bool TryGetPartPose(
uint localEntityId,
int partIndex,
out Matrix4x4 partLocal)
{
partLocal = default;
return false;
}
}
}