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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue