acdream/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs
Erik 529e0e9d88 feat(runtime): C3c - production placement cutover: both hosts on the residence conductors (routes 1+8)
Campaign P remaining-physics-divergence, placement cutover slice C3c
(docs/plans/2026-08-02-placement-cutover.md). Both production hosts now
register every initial Create through the residence + continuation-
executor + first-entry-conductor machinery (C0-C3b):

- Graphical (route 1): RegisterEntityWithInitialResidence at Create; the
  shared RuntimeFirstEntryDriveController pumps both conductors from the
  placement-receipt flow; MaterializeProjection and RebucketLiveEntity
  are presentation-only while a residence is ACTIVE (ExecutorCompleted is
  the presentation-binding receipt); post-residence entities take the
  full legacy path including the prepare_to_enter_world clock edges.
  PlayerModeController attaches presentation to the Runtime-published
  controller; its legacy resolve/step-heights/host-construction path is
  deleted; presentation-only rollback (retail has no entry-flow rollback).
- Headless (route 8): OnSpawned registers with residence when a drive
  exists; content-less sessions keep the pre-flip direct registration;
  SynchronizeLocalPlayer/CreateController/ApplySetupStepHeights deleted;
  prepared-collision read failure is a typed AwaitingCollisionSource
  retry; far remotes outside the service window complete celless.
- RuntimeLocalPlayerMovementState.Controller setter sealed internal; all
  controller mutation flows through the publication lifecycle.

Fix slices landed within this cutover, each dual-gated:
- F1: live movement-stat/server-physics application routed through the
  Runtime ownership seam (post-logout ingest crash on the retired
  controller eliminated; RuntimeMovementSkillProjection deleted).
- F2: login activation wedge - collision-admission prefix gate factored
  out of the seal (reentrant-commit RejectedAuthority), rearm generation
  identity corrected, PlayerModeAutoEntry requires the Runtime-published
  controller (world reveal can no longer seal unmaterialized).
- F3: landblock-prefix 0-sentinel replaced by explicit absent-id guards;
  map-corner landblocks (grid row/col 0) fully legal through admission,
  park/rearm/retire, quiescence, and outdoor shadow seeds.
- F5: local-player first-entry ground contact seeded by the shared
  SpawnPlacementSettler (moved App->Core) at FinalizeActivation - the
  retail first-gravity-frame touch (enter_world 0x00516170 carries no
  seed); the legacy unconditional force-seed is overwritten by a real
  floor-found contact; airborne spawns stay airborne; outbound contact
  bit verified end-to-end. Fixes the standing-cast 'You can't do that
  while in the air!' rejections.
- R1 (dual-review round): login constraint leash armed at the committed
  placement (HandleReceivedPosition 0x00453FD0 analog); register rows
  AD-61 (settle-timing compression now covering the local player) and
  AD-42 (repointed off the deleted resolve split) in this commit;
  residence-conversion owner API; wire-landblock guards; drive-pending
  ledger in IsConverged; route attach/detach latch; executor-drain drift
  model documented + source-pinned.

Gates: Runtime 1,003, App 4,039/3 skips, Headless 79, complete solution
10,816/0 failed/4 skips (Release, -m:1); connected lifecycle/reconnect
gate PASS (logs/connected-world-gate-20260802-175401; graceful exits,
world-visible, zero airborne rejections). The nine-stop soak remains red
for the pre-existing 6b28ff99 whole-world collision-clone throughput
regression (attributed with evidence; scheduled as its own slice before
C5). Dual Opus reviews (retail-conformance + adversarial): delta PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 18:10:33 +02:00

1152 lines
46 KiB
C#

using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Lighting;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
namespace AcDream.App.Tests.World;
/// <summary>
/// Step 9 deterministic whole-lifecycle gate for the canonical live-object,
/// projectile, and DAT-effect ownership graph. It deliberately exercises
/// enough simultaneous owners to expose collection mutation and incomplete
/// teardown that single-object conformance tests cannot reveal.
/// </summary>
public sealed class LiveEntityLifecycleStressTests
{
private const int OwnerCount = 96;
private const int ReusedOwnerCount = 32;
private const uint GuidBase = 0x72000000u;
private const uint CellA = 0x01010001u;
private const uint CellB = 0x01020001u;
private const uint ScriptDid = 0x3300AA01u;
private const uint EmitterDid = 0x3200AA01u;
private const uint SetupDid = 0x02000124u;
private const uint RecallMotion = 0x10000153u;
private static readonly PhysicsStateFlags MissileState =
PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Lighting
| PhysicsStateFlags.Missile
| PhysicsStateFlags.AlignPath
| PhysicsStateFlags.PathClipped;
[Fact]
public void NinetySixOwners_ChurnDeleteResetAndGuidReuse_LeaveNoRuntimeResources()
{
using var fixture = new Fixture();
var oldLocalIds = new List<uint>(OwnerCount + ReusedOwnerCount);
var lifetimeRecords = new List<LiveEntityRecord>(OwnerCount + ReusedOwnerCount);
for (int i = 0; i < OwnerCount; i++)
{
uint guid = GuidBase + (uint)i;
fixture.Effects.HandleDirect(new PlayPhysicsScript(guid, ScriptDid));
LiveEntityRecord record = fixture.Spawn(guid, generation: 1, i);
lifetimeRecords.Add(record);
oldLocalIds.Add(record.LocalEntityId!.Value);
// Repeated direct network PES plays append to the same owner's
// retail FIFO. They must neither deduplicate nor create another
// logical owner. The separate recall test below drives the exact
// recall motion and CallPES chain.
if (i % 12 == 0)
{
fixture.Effects.HandleDirect(new PlayPhysicsScript(guid, ScriptDid));
fixture.Effects.HandleDirect(new PlayPhysicsScript(guid, ScriptDid));
}
}
fixture.Runner.Tick(0.0);
fixture.Particles.Tick(0.01f);
fixture.Projectiles.Tick(
currentTime: 0.05,
liveCenterX: 1,
liveCenterY: 1,
playerWorldPosition: null);
Assert.Equal(OwnerCount, fixture.Runtime.Count);
Assert.Equal(OwnerCount, fixture.Runtime.MaterializedCount);
Assert.Equal(OwnerCount, fixture.RenderOwners.Count);
Assert.Equal(OwnerCount, fixture.Projectiles.Count);
Assert.Equal(OwnerCount, fixture.Effects.ReadyOwnerCount);
Assert.Equal(0, fixture.Effects.PendingPacketCount);
Assert.Equal(OwnerCount, fixture.Runner.ActiveOwnerCount);
Assert.True(fixture.Runner.ActiveScriptCount > OwnerCount);
Assert.Equal(OwnerCount, fixture.Particles.ActiveEmitterCount);
Assert.Equal(OwnerCount, fixture.ParticleSink.ActiveBindingCount);
Assert.Equal(OwnerCount, fixture.ParticleSink.LogicalEmitterCount);
Assert.Equal(OwnerCount, fixture.ParticleSink.TrackedOwnerCount);
Assert.Equal(OwnerCount, fixture.ParticleSink.RenderPassOwnerCount);
Assert.Equal(OwnerCount, fixture.Poses.Count);
Assert.Equal(OwnerCount, fixture.Lights.RegisteredCount);
Assert.Equal(OwnerCount, fixture.LiveLights.TrackedOwnerCount);
Assert.Equal(OwnerCount, fixture.LiveLights.PresentedOwnerCount);
Assert.Equal(OwnerCount, fixture.Lighting.OwnedLightOwnerCount);
Assert.Equal(OwnerCount, fixture.Lighting.PoseTrackedOwnerCount);
Assert.Equal(OwnerCount, fixture.Lighting.RetainedOwnerStateCount);
Assert.Equal(OwnerCount, fixture.Engine.ShadowObjects.TotalRegistered);
Assert.Equal(OwnerCount, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
// Loaded -> pending -> loaded -> pending -> loaded churn keeps the
// same projections and all logical owners; only spatial presentation
// changes. This is the streaming race that previously replayed create.
for (int i = 0; i < OwnerCount; i += 2)
Assert.True(fixture.Runtime.RebucketLiveEntity(GuidBase + (uint)i, CellB));
Assert.Equal(OwnerCount / 2, fixture.Spatial.PendingLiveEntityCount);
Assert.Equal(OwnerCount, fixture.RenderOwners.Count);
Assert.Equal(OwnerCount / 2, fixture.ParticleSink.HiddenPresentationOwnerCount);
Assert.Equal(OwnerCount / 2, fixture.Lights.RegisteredCount);
Assert.Equal(OwnerCount / 2, fixture.LiveLights.PresentedOwnerCount);
fixture.Spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount);
Assert.Equal(0, fixture.ParticleSink.HiddenPresentationOwnerCount);
Assert.Equal(OwnerCount, fixture.Lights.RegisteredCount);
fixture.Spatial.RemoveLandblock(0x0102FFFFu);
Assert.Equal(OwnerCount / 2, fixture.Spatial.PendingLiveEntityCount);
fixture.Spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount);
Assert.Equal(OwnerCount, fixture.RenderOwners.Count);
Assert.Equal(OwnerCount, fixture.Projectiles.Count);
Assert.Equal(OwnerCount, fixture.Lights.RegisteredCount);
Assert.Equal(OwnerCount, fixture.LiveLights.PresentedOwnerCount);
// End one third rapidly, then reuse those exact server GUIDs with a
// newer instance timestamp. No queue, body, emitter, light, or shadow
// from generation 1 may attach to generation 2.
for (int i = 0; i < ReusedOwnerCount; i++)
{
uint guid = GuidBase + (uint)i;
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false));
}
Assert.Equal(OwnerCount - ReusedOwnerCount, fixture.Runtime.Count);
Assert.Equal(OwnerCount - ReusedOwnerCount, fixture.RenderOwners.Count);
Assert.Equal(OwnerCount - ReusedOwnerCount, fixture.Particles.ActiveEmitterCount);
Assert.Equal(OwnerCount - ReusedOwnerCount, fixture.Lights.RegisteredCount);
Assert.Equal(
OwnerCount - ReusedOwnerCount,
fixture.Engine.ShadowObjects.RetainedRegistrationCount);
for (int i = 0; i < ReusedOwnerCount; i++)
{
uint guid = GuidBase + (uint)i;
fixture.Effects.HandleDirect(new PlayPhysicsScript(guid, ScriptDid));
LiveEntityRecord replacement = fixture.Spawn(guid, generation: 2, i);
lifetimeRecords.Add(replacement);
oldLocalIds.Add(replacement.LocalEntityId!.Value);
}
fixture.Runner.Tick(1.0);
fixture.Particles.Tick(0.01f);
Assert.Equal(OwnerCount, fixture.Runtime.Count);
Assert.Equal(OwnerCount, fixture.RenderOwners.Count);
Assert.Equal(OwnerCount, fixture.Projectiles.Count);
Assert.Equal(OwnerCount, fixture.Particles.ActiveEmitterCount);
Assert.Equal(OwnerCount, fixture.Lights.RegisteredCount);
Assert.Equal(OwnerCount, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
// Mix accepted deletes with a session reset, and leave packets for
// never-created owners pending so the network reset path is covered.
for (int i = 0; i < OwnerCount; i += 2)
{
uint guid = GuidBase + (uint)i;
ushort generation = i < ReusedOwnerCount ? (ushort)2 : (ushort)1;
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, generation),
isLocalPlayer: false));
}
for (int i = 0; i < 8; i++)
{
fixture.Effects.HandleDirect(new PlayPhysicsScript(
0x73000000u + (uint)i,
ScriptDid));
}
Assert.Equal(8, fixture.Effects.PendingPacketCount);
fixture.Runtime.Clear();
fixture.Effects.ClearNetworkState();
fixture.Particles.Tick(0.01f);
Assert.Equal(0, fixture.Runtime.Count);
Assert.Equal(0, fixture.Runtime.MaterializedCount);
Assert.Empty(fixture.RenderOwners);
Assert.Equal(fixture.RenderRegisterCount, fixture.RenderUnregisterCount);
Assert.Equal(0, fixture.Projectiles.Count);
Assert.Equal(0, fixture.Effects.ReadyOwnerCount);
Assert.Equal(0, fixture.Effects.PendingPacketCount);
Assert.Equal(0, fixture.Runner.ActiveOwnerCount);
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
Assert.Equal(0, fixture.Runner.ScheduledCallPesCount);
Assert.Equal(0, fixture.Runner.OwnerAnchorCount);
Assert.Equal(0, fixture.Particles.ActiveEmitterCount);
Assert.Equal(0, fixture.Particles.ActiveParticleCount);
Assert.Equal(0, fixture.ParticleSink.ActiveBindingCount);
Assert.Equal(0, fixture.ParticleSink.LogicalEmitterCount);
Assert.Equal(0, fixture.ParticleSink.TrackedOwnerCount);
Assert.Equal(0, fixture.ParticleSink.RenderPassOwnerCount);
Assert.Equal(0, fixture.ParticleSink.HiddenPresentationOwnerCount);
Assert.Equal(0, fixture.Poses.Count);
Assert.Equal(0, fixture.Lights.RegisteredCount);
Assert.Equal(0, fixture.Lighting.OwnedLightOwnerCount);
Assert.Equal(0, fixture.Lighting.PoseTrackedOwnerCount);
Assert.Equal(0, fixture.Lighting.RetainedOwnerStateCount);
Assert.Equal(0, fixture.LiveLights.TrackedOwnerCount);
Assert.Equal(0, fixture.LiveLights.PresentedOwnerCount);
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
Assert.Equal(0, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount);
Assert.Equal(0, fixture.Spatial.PendingBucketCount);
Assert.Equal(0, fixture.Spatial.PendingRescueCount);
Assert.Equal(0, fixture.Spatial.PersistentGuidCount);
Assert.Equal(0, fixture.Spatial.PendingVisibilityTransitionCount);
Assert.DoesNotContain(fixture.Spatial.Entities, entity => entity.ServerGuid != 0);
Assert.All(lifetimeRecords, record =>
{
Assert.Null(record.PhysicsBody);
Assert.Null(record.ProjectileRuntime);
Assert.Null(record.EffectProfile);
Assert.Null(record.WorldEntity);
});
// A stale retained shadow payload would resurrect here despite the
// visible-cell count already reading zero.
foreach (uint localId in oldLocalIds)
{
fixture.Engine.ShadowObjects.UpdatePosition(
localId,
new Vector3(20f, 20f, 10f),
Quaternion.Identity,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0x01010000u,
seedCellId: CellA);
}
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
// Session teardown is idempotent and cannot recreate bookkeeping.
fixture.Runtime.Clear();
fixture.Effects.ClearNetworkState();
Assert.Equal(0, fixture.Runtime.Count);
Assert.Equal(0, fixture.Effects.PendingPacketCount);
AssertOwnershipLedgerConverged(fixture);
}
[Fact]
public void RepeatedRetailRecallMotion_HiddenTeleportUnhide_DrainsEveryPresentationOwner()
{
using var fixture = new RecallPortalFixture();
for (int cycle = 0; cycle < 12; cycle++)
{
fixture.PlayRecallMotion(cycle + 1.0);
Assert.Equal(1, fixture.Particles.ActiveEmitterCount);
Assert.Equal(1, fixture.ParticleSink.ActiveBindingCount);
fixture.ApplyHidden();
Assert.Equal(1, fixture.Presentation.DeferredShadowRestoreCount);
fixture.BeginDeferredTeleport();
Assert.Equal(1, fixture.Teleport.PendingPlacementCount);
Assert.Equal(1, fixture.Presentation.ActivePlacementCount);
fixture.LoadDestination();
Assert.Equal(0, fixture.Teleport.PendingPlacementCount);
Assert.Equal(0, fixture.Presentation.ActivePlacementCount);
Assert.Equal(1, fixture.Presentation.DeferredShadowRestoreCount);
fixture.ApplyVisible();
Assert.Equal(0, fixture.Presentation.DeferredShadowRestoreCount);
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
fixture.UnloadPriorLandblock();
}
Assert.Equal(12, fixture.TypedScripts.Count(type =>
type == LiveEntityPresentationController.HiddenScriptType));
Assert.Equal(12, fixture.TypedScripts.Count(type =>
type == LiveEntityPresentationController.UnHideScriptType));
Assert.Equal(12, fixture.RecallHookCount);
fixture.Clear();
Assert.Equal(0, fixture.Runtime.Count);
Assert.Equal(0, fixture.Effects.ReadyOwnerCount);
Assert.Equal(0, fixture.Runner.ActiveOwnerCount);
Assert.Equal(0, fixture.Runner.OwnerAnchorCount);
Assert.Equal(0, fixture.Particles.ActiveEmitterCount);
Assert.Equal(0, fixture.ParticleSink.ActiveBindingCount);
Assert.Equal(0, fixture.Poses.Count);
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
Assert.Equal(0, fixture.Teleport.PendingPlacementCount);
Assert.Equal(0, fixture.Presentation.ReadyOwnerCount);
Assert.Equal(0, fixture.Presentation.DeferredShadowRestoreCount);
Assert.Equal(0, fixture.Presentation.ActivePlacementCount);
Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount);
Assert.Equal(0, fixture.Spatial.PendingBucketCount);
}
private static void AssertOwnershipLedgerConverged(Fixture fixture)
{
RuntimeEntityObjectOwnershipSnapshot canonical =
fixture.EntityObjects.CaptureOwnership();
var ledger = new J3OwnershipLedger(
canonical,
fixture.Runtime.MaterializedCount,
fixture.Runtime.VisibleRecords.Count,
fixture.Runtime.AnimationRuntimeCount,
fixture.Runtime.SpatialAnimationRuntimeCount,
fixture.Runtime.SpatialRemoteMotionRuntimeCount,
fixture.Runtime.SpatialProjectileRuntimeCount,
fixture.Runtime.SpatialRootObjectCount,
fixture.RenderOwners.Count,
fixture.RenderRegisterCount - fixture.RenderUnregisterCount,
fixture.Projectiles.Count,
fixture.Effects.ReadyOwnerCount,
fixture.Effects.PendingPacketCount,
fixture.Runner.ActiveOwnerCount,
fixture.Runner.ActiveScriptCount,
fixture.Runner.ScheduledCallPesCount,
fixture.Runner.OwnerAnchorCount,
fixture.Particles.ActiveEmitterCount,
fixture.Particles.ActiveParticleCount,
fixture.ParticleSink.ActiveBindingCount,
fixture.ParticleSink.LogicalEmitterCount,
fixture.ParticleSink.TrackedOwnerCount,
fixture.ParticleSink.RenderPassOwnerCount,
fixture.ParticleSink.HiddenPresentationOwnerCount,
fixture.Poses.Count,
fixture.Lights.RegisteredCount,
fixture.LiveLights.TrackedOwnerCount,
fixture.LiveLights.PresentedOwnerCount,
fixture.Lighting.OwnedLightOwnerCount,
fixture.Lighting.PoseTrackedOwnerCount,
fixture.Lighting.RetainedOwnerStateCount,
fixture.Engine.ShadowObjects.TotalRegistered,
fixture.Engine.ShadowObjects.RetainedRegistrationCount,
fixture.Engine.ShadowObjects.SuspendedRegistrationCount,
fixture.Spatial.PendingLiveEntityCount,
fixture.Spatial.PendingBucketCount,
fixture.Spatial.PendingRescueCount,
fixture.Spatial.PersistentGuidCount,
fixture.Spatial.PendingVisibilityTransitionCount,
fixture.Spatial.Entities.Count(entity =>
entity.ServerGuid != 0u));
Assert.Equal(default(J3OwnershipLedger), ledger);
}
private readonly record struct J3OwnershipLedger(
RuntimeEntityObjectOwnershipSnapshot Canonical,
int AppMaterializedCount,
int AppVisibleCount,
int AnimationRuntimeCount,
int SpatialAnimationRuntimeCount,
int SpatialRemoteMotionRuntimeCount,
int SpatialProjectileRuntimeCount,
int SpatialRootObjectCount,
int RenderOwnerCount,
int RenderRegistrationDelta,
int ProjectileCount,
int EffectReadyOwnerCount,
int EffectPendingPacketCount,
int ScriptOwnerCount,
int ActiveScriptCount,
int ScheduledCallPesCount,
int ScriptAnchorCount,
int EmitterCount,
int ParticleCount,
int ParticleBindingCount,
int LogicalEmitterCount,
int ParticleTrackedOwnerCount,
int ParticleRenderPassOwnerCount,
int HiddenParticlePresentationCount,
int PoseCount,
int RegisteredLightCount,
int TrackedLiveLightCount,
int PresentedLiveLightCount,
int OwnedLightCount,
int LightPoseCount,
int RetainedLightStateCount,
int ShadowCount,
int RetainedShadowCount,
int SuspendedShadowCount,
int PendingSpatialEntityCount,
int PendingBucketCount,
int PendingRescueCount,
int PersistentGuidCount,
int PendingVisibilityCount,
int MaterializedWorldEntityCount);
private sealed class Fixture : IDisposable
{
private readonly Dictionary<uint, DatPhysicsScript> _scripts = new();
private EntityEffectController? _effects;
private LiveEntityLightController? _liveLights;
internal Fixture()
{
Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
AddEmptyPhysicsLandblock(Engine, 0x0101FFFFu, 0f, 0f);
AddEmptyPhysicsLandblock(Engine, 0x0102FFFFu, 0f, 192f);
var emitterRegistry = new EmitterDescRegistry();
emitterRegistry.Register(PersistentEmitter());
Particles = new ParticleSystem(emitterRegistry, new Random(42));
ParticleSink = new ParticleHookSink(Particles, Poses);
Lighting = new LightingHookSink(Lights, Poses);
Router.Register(ParticleSink);
Router.Register(Lighting);
_scripts.Add(ScriptDid, PersistentScript());
Runner = new PhysicsScriptRunner(
did => _scripts.TryGetValue(did, out DatPhysicsScript? script)
? script
: null,
Router,
randomUnit: () => 0.5,
canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true);
EntityObjects = new RuntimeEntityObjectLifetime(Engine);
// C3c: initial-Create registration begins the canonical residence,
// whose admission requires a live session generation.
EntityObjects.BindEventContext(
static () => new AcDream.Runtime.RuntimeGenerationToken(1UL),
static () => 1UL);
Runtime = new LiveEntityRuntime(
Spatial,
new DelegateLiveEntityResourceLifecycle(
entity =>
{
Assert.True(RenderOwners.Add(entity.Id));
RenderRegisterCount++;
Poses.PublishMeshRefs(entity);
ParticleSink.SetEntityRenderPass(entity.Id, ParticleRenderPass.Scene);
},
entity =>
{
Runner.StopAllForEntity(entity.Id);
ParticleSink.StopAllForEntity(entity.Id, fadeOut: false);
Poses.Remove(entity.Id);
Assert.True(RenderOwners.Remove(entity.Id));
RenderUnregisterCount++;
}),
record =>
{
var cleanups = new List<Action>
{
() => _effects?.OnLiveEntityUnregistered(record),
};
if (record.WorldEntity is { } entity)
{
cleanups.Add(() => Engine.ShadowObjects.Deregister(entity.Id));
cleanups.Add(() => _liveLights?.Forget(record));
}
LiveEntityTeardown.Run(cleanups);
},
EntityObjects);
Effects = new EntityEffectController(
Runtime,
Runner,
new PhysicsScriptTableResolver(_ => null),
Poses);
_effects = Effects;
Router.Register(Effects);
Projectiles = new ProjectileController(Runtime);
LiveLights = new LiveEntityLightController(
Runtime,
Poses,
Lighting,
setupId => setupId == SetupDid ? Setup : null);
_liveLights = LiveLights;
Runtime.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
internal GpuWorldState Spatial { get; } = new();
internal PhysicsEngine Engine { get; } = new();
internal EntityEffectPoseRegistry Poses { get; } = new();
internal AnimationHookRouter Router { get; } = new();
internal ParticleSystem Particles { get; }
internal ParticleHookSink ParticleSink { get; }
internal LightManager Lights { get; } = new();
internal LightingHookSink Lighting { get; }
internal PhysicsScriptRunner Runner { get; }
internal RuntimeEntityObjectLifetime EntityObjects { get; }
internal LiveEntityRuntime Runtime { get; }
internal EntityEffectController Effects { get; }
internal ProjectileController Projectiles { get; }
internal LiveEntityLightController LiveLights { get; }
internal Setup Setup { get; } = ProjectileSetup();
internal HashSet<uint> RenderOwners { get; } = new();
internal int RenderRegisterCount { get; private set; }
internal int RenderUnregisterCount { get; private set; }
internal LiveEntityRecord Spawn(uint guid, ushort generation, int ordinal)
{
Vector3 position = new(
8f + (ordinal % 12) * 6f,
8f + (ordinal / 12) * 6f,
10f);
WorldSession.EntitySpawn spawn = MissileSpawn(guid, generation, position);
LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection(
spawn,
localId => new WorldEntity
{
Id = localId,
ServerGuid = guid,
SourceGfxObjOrSetupId = SetupDid,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = CellA,
},
initializeProjection: exact =>
exact.EffectProfile = EntityEffectProfile.CreateLive(
new Setup(),
spawn.Physics!.Value));
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
Assert.True(Effects.OnLiveEntityReady(guid));
Assert.True(Projectiles.TryBind(record, Setup, 0.0, 1, 1));
Engine.ShadowObjects.Register(
entity.Id,
SetupDid,
entity.Position,
entity.Rotation,
radius: 0.1f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0x01010000u,
state: (uint)MissileState,
seedCellId: CellA,
isStatic: false);
Assert.True(LiveLights.Register(guid));
return record;
}
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (record.WorldEntity is { } entity)
ParticleSink.SetEntityPresentationVisible(entity.Id, visible);
}
public void Dispose()
{
Runtime.Clear();
Effects.ClearNetworkState();
Runtime.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
LiveLights.Dispose();
}
}
private sealed class RecallPortalFixture : IDisposable
{
private const uint Guid = 0x75000001u;
private const uint CellOne = 0x01010001u;
private const uint CellTwo = 0x01020001u;
private const uint RecallScriptDid = 0x3300BB01u;
private const uint IdleAnimationDid = 0x0300BB01u;
private const uint RecallAnimationDid = 0x0300BB02u;
private const uint Style = 0x8000003Du;
private const uint ReadyMotion = 0x41000003u;
private readonly AnimationHookFrameQueue _hookQueue;
private readonly TestRemoteMotion _remote = new();
private uint _currentCell = CellOne;
private uint _destinationCell;
private ushort _stateSequence = 1;
private bool _cleared;
internal RecallPortalFixture()
{
Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var emitterRegistry = new EmitterDescRegistry();
emitterRegistry.Register(PersistentEmitter());
Particles = new ParticleSystem(emitterRegistry, new Random(73));
ParticleSink = new ParticleHookSink(Particles, Poses);
Router.Register(ParticleSink);
DatPhysicsScript recallScript = RecallScript();
Runner = new PhysicsScriptRunner(
did => did == RecallScriptDid ? recallScript : null,
Router,
randomUnit: () => 0.5);
EntityEffectController? effects = null;
LiveEntityPresentationController? presentation = null;
RemoteTeleportController? teleport = null;
Runtime = LiveEntityRuntimeFixture.Create(
Spatial,
new DelegateLiveEntityResourceLifecycle(
entity =>
{
Poses.PublishMeshRefs(entity);
ParticleSink.SetEntityRenderPass(entity.Id, ParticleRenderPass.Scene);
},
entity =>
{
Runner.StopAllForEntity(entity.Id);
ParticleSink.StopAllForEntity(entity.Id, fadeOut: false);
Poses.Remove(entity.Id);
}),
record =>
{
effects?.OnLiveEntityUnregistered(record);
presentation?.Forget(record);
teleport?.Forget(record);
if (record.WorldEntity is { } entity)
Engine.ShadowObjects.Deregister(entity.Id);
});
Effects = effects = new EntityEffectController(
Runtime,
Runner,
new PhysicsScriptTableResolver(_ => null),
Poses);
Router.Register(Effects);
WorldSession.EntitySpawn spawn = RecallSpawn(Guid, CellOne);
LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection(
spawn,
localId => new WorldEntity
{
Id = localId,
ServerGuid = Guid,
SourceGfxObjOrSetupId = SetupDid,
Position = new Vector3(10f, 10f, 10f),
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = CellOne,
},
initializeProjection: exact =>
exact.EffectProfile = EntityEffectProfile.CreateLive(
new Setup(),
spawn.Physics!.Value));
Entity = Assert.IsType<WorldEntity>(record.WorldEntity);
Assert.True(Effects.OnLiveEntityReady(Guid));
_remote.Body.SnapToCell(CellOne, Entity.Position, Entity.Position);
_remote.CellId = CellOne;
Runtime.SetRemoteMotionRuntime(Guid, _remote);
Engine.ShadowObjects.Register(
Entity.Id,
SetupDid,
Entity.Position,
Entity.Rotation,
radius: 0.48f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0x0101FFFFu,
collisionType: ShadowCollisionType.Cylinder,
cylHeight: 1.835f,
seedCellId: CellOne,
isStatic: false);
Presentation = presentation = new LiveEntityPresentationController(
Runtime,
Engine.ShadowObjects,
(_, type, _) =>
{
TypedScripts.Add(type);
return true;
},
new LiveEntityPartArrayEnterWorldPort(_ => { }),
liveCenter: () => (1, 1));
Assert.True(Presentation.OnLiveEntityReady(Guid));
Teleport = teleport = new RemoteTeleportController(
Engine,
Runtime,
(_, _) => (0.48f, 1.835f),
(position, _) => position,
(_, _, _) => { },
(guid, generation, defer) =>
Presentation.CompleteAuthoritativePlacement(guid, generation, defer),
(guid, generation) =>
Presentation.BeginAuthoritativePlacement(guid, generation),
resolvePlacement: (position, cell, _, _, _, _) => new ResolveResult(
position,
cell,
IsOnGround: true,
InContact: true,
OnWalkable: true,
ContactPlane: new Plane(Vector3.UnitZ, 0f),
ContactPlaneCellId: cell));
Runtime.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
_hookQueue = new AnimationHookFrameQueue(Router, Poses);
}
internal GpuWorldState Spatial { get; } = new();
internal PhysicsEngine Engine { get; } = new();
internal EntityEffectPoseRegistry Poses { get; } = new();
internal AnimationHookRouter Router { get; } = new();
internal ParticleSystem Particles { get; }
internal ParticleHookSink ParticleSink { get; }
internal PhysicsScriptRunner Runner { get; }
internal LiveEntityRuntime Runtime { get; }
internal EntityEffectController Effects { get; }
internal WorldEntity Entity { get; }
internal LiveEntityPresentationController Presentation { get; }
internal RemoteTeleportController Teleport { get; }
internal List<uint> TypedScripts { get; } = [];
internal int RecallHookCount { get; private set; }
internal void PlayRecallMotion(double gameTime)
{
AnimationSequencer recallSequencer = BuildRecallSequencer();
recallSequencer.SetCycle(Style, ReadyMotion);
recallSequencer.PlayAction(RecallMotion);
recallSequencer.Advance(0.15f);
IReadOnlyList<AnimationHook> hooks = recallSequencer.ConsumePendingHooks();
RecallHookCount += hooks.Count(hook => hook is CallPESHook call
&& (uint)call.PES == RecallScriptDid);
_hookQueue.Capture(Entity.Id, recallSequencer, hooks);
_hookQueue.Drain();
Runner.Tick(gameTime);
Particles.Tick(0.01f);
}
internal void ApplyHidden()
{
Assert.True(Runtime.TryApplyState(
new SetState.Parsed(
Guid,
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
InstanceSequence: 1,
StateSequence: ++_stateSequence),
out _,
out _));
Assert.True(Presentation.OnStateAccepted(Guid));
}
internal void BeginDeferredTeleport()
{
_destinationCell = _currentCell == CellOne ? CellTwo : CellOne;
Assert.False(Spatial.IsLoaded((_destinationCell & 0xFFFF0000u) | 0xFFFFu));
Teleport.BeginPlacement(Guid, generation: 1);
Assert.True(Runtime.RebucketLiveEntity(Guid, _destinationCell));
RemoteTeleportController.Result result = Teleport.TryApply(
_remote,
Entity,
new Vector3(Entity.Position.X + 3f, Entity.Position.Y, Entity.Position.Z),
_destinationCell,
Entity.Position,
Quaternion.Identity,
gameTime: _stateSequence,
destinationProjectionVisible: false,
generation: 1,
positionSequence: 1);
Assert.True(result.Applied);
Assert.False(result.ContactResolved);
}
internal void LoadDestination()
{
Spatial.AddLandblock(EmptyLandblock(
(_destinationCell & 0xFFFF0000u) | 0xFFFFu));
}
internal void ApplyVisible()
{
Assert.True(Runtime.TryApplyState(
new SetState.Parsed(
Guid,
(uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: 1,
StateSequence: ++_stateSequence),
out _,
out _));
Assert.True(Presentation.OnStateAccepted(Guid));
}
internal void UnloadPriorLandblock()
{
uint priorLandblock = (_currentCell & 0xFFFF0000u) | 0xFFFFu;
Spatial.RemoveLandblock(priorLandblock);
_currentCell = _destinationCell;
}
internal void Clear()
{
if (_cleared)
return;
_cleared = true;
Runtime.Clear();
Effects.ClearNetworkState();
_hookQueue.Clear();
Particles.Tick(0.01f);
}
public void Dispose()
{
Clear();
Runtime.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
Teleport.Dispose();
Presentation.Dispose();
}
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (record.WorldEntity is { } entity)
ParticleSink.SetEntityPresentationVisible(entity.Id, visible);
}
private static AnimationSequencer BuildRecallSequencer()
{
var setup = new Setup();
setup.Parts.Add(0x01000001u);
setup.DefaultScale.Add(Vector3.One);
var table = new MotionTable
{
DefaultStyle = (DRWMotionCommand)Style,
};
table.StyleDefaults[(DRWMotionCommand)Style] =
(DRWMotionCommand)ReadyMotion;
int readyKey = unchecked((int)((Style << 16) | (ReadyMotion & 0xFFFFFFu)));
table.Cycles[readyKey] = MotionData(IdleAnimationDid);
var links = new MotionCommandData();
links.MotionData[unchecked((int)RecallMotion)] = MotionData(RecallAnimationDid);
table.Links[readyKey] = links;
Animation idle = Animation(frames: 2);
Animation recall = Animation(frames: 2);
recall.PartFrames[0].Hooks.Add(new CallPESHook
{
Direction = AnimationHookDir.Forward,
PES = RecallScriptDid,
Pause = 0f,
});
var loader = new RecallAnimationLoader();
loader.Add(IdleAnimationDid, idle);
loader.Add(RecallAnimationDid, recall);
return new AnimationSequencer(setup, table, loader);
}
private static MotionData MotionData(uint animationDid)
{
var data = new MotionData();
QualifiedDataId<Animation> id = animationDid;
data.Anims.Add(new AnimData
{
AnimId = id,
LowFrame = 0,
HighFrame = -1,
Framerate = 10f,
});
return data;
}
private static Animation Animation(int frames)
{
var animation = new Animation();
for (int i = 0; i < frames; i++)
{
var frame = new AnimationFrame(1u);
frame.Frames.Add(new Frame
{
Origin = Vector3.Zero,
Orientation = Quaternion.Identity,
});
animation.PartFrames.Add(frame);
}
return animation;
}
private sealed class RecallAnimationLoader : IAnimationLoader
{
private readonly Dictionary<uint, Animation> _animations = new();
internal void Add(uint id, Animation animation) => _animations[id] = animation;
public Animation? LoadAnimation(uint id) =>
_animations.TryGetValue(id, out Animation? animation) ? animation : null;
}
private sealed class TestRemoteMotion : IRuntimeRemotePlacement
{
private uint _cellId;
private Func<uint>? _readCell;
private Action<uint>? _writeCell;
public PhysicsBody Body { get; } = new();
public uint CellId
{
get => _readCell?.Invoke() ?? _cellId;
set
{
_cellId = value;
_writeCell?.Invoke(value);
}
}
public bool Airborne { get; set; }
public Vector3 LastServerPosition { get; set; }
public double LastServerPositionTime { get; set; }
public Vector3 LastShadowSyncPosition { get; set; }
public Quaternion LastShadowSyncOrientation { get; set; }
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
{
_readCell = read;
_writeCell = write;
}
public void HitGround() { }
public void LeaveGround() { }
}
}
private static DatPhysicsScript PersistentScript()
{
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = 0.0,
Hook = new CreateParticleHook
{
EmitterInfoId = EmitterDid,
PartIndex = uint.MaxValue,
EmitterId = 1u,
Offset = new Frame
{
Origin = Vector3.Zero,
Orientation = Quaternion.Identity,
},
},
});
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = 60.0,
Hook = new DestroyParticleHook { EmitterId = 1u },
});
return script;
}
private static DatPhysicsScript RecallScript()
{
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = 0.0,
Hook = new CreateParticleHook
{
EmitterInfoId = EmitterDid,
PartIndex = uint.MaxValue,
EmitterId = 7u,
Offset = new Frame
{
Origin = Vector3.Zero,
Orientation = Quaternion.Identity,
},
},
});
return script;
}
private static EmitterDesc PersistentEmitter() => new()
{
DatId = EmitterDid,
Type = AcDream.Core.Vfx.ParticleType.Still,
EmitterKind = ParticleEmitterKind.BirthratePerSec,
MaxParticles = 2,
InitialParticles = 1,
TotalParticles = 0,
TotalDuration = 0f,
Lifespan = 120f,
LifetimeMin = 120f,
LifetimeMax = 120f,
Birthrate = 0f,
StartSize = 0.25f,
EndSize = 0.25f,
StartAlpha = 1f,
EndAlpha = 1f,
};
private static Setup ProjectileSetup() => new()
{
Spheres =
{
new Sphere
{
Origin = new Vector3(0f, -0.165f, 0.1045f),
Radius = 0.102f,
},
},
Lights =
{
[0] = new LightInfo
{
ViewSpaceLocation = new Frame
{
Origin = Vector3.Zero,
Orientation = Quaternion.Identity,
},
Color = new ColorARGB
{
Red = 255,
Green = 255,
Blue = 255,
Alpha = 255,
},
Intensity = 1f,
Falloff = 8f,
},
},
};
private static WorldSession.EntitySpawn MissileSpawn(
uint guid,
ushort generation,
Vector3 position)
{
var wirePosition = new CreateObject.ServerPosition(
CellA,
position.X,
position.Y,
position.Z,
1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(
Position: 1,
Movement: 1,
State: 1,
Vector: 1,
Teleport: 0,
ServerControlledMove: 1,
ForcePosition: 0,
ObjDesc: 1,
Instance: generation);
var physics = new PhysicsSpawnData(
RawState: (uint)MissileState,
Position: wirePosition,
Movement: null,
AnimationFrame: null,
SetupTableId: SetupDid,
MotionTableId: null,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: 1f,
Friction: 0f,
Elasticity: 0f,
Translucency: null,
Velocity: new Vector3(2f, 0f, 0f),
Acceleration: null,
AngularVelocity: Vector3.Zero,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
wirePosition,
SetupDid,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: 1f,
Name: "Stress missile",
ItemType: null,
MotionState: null,
MotionTableId: null,
PhysicsState: (uint)MissileState,
Friction: 0f,
Elasticity: 0f,
InstanceSequence: generation,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private static WorldSession.EntitySpawn RecallSpawn(uint guid, uint cell)
{
var wirePosition = new CreateObject.ServerPosition(
cell, 10f, 10f, 10f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(
Position: 1,
Movement: 1,
State: 1,
Vector: 1,
Teleport: 1,
ServerControlledMove: 1,
ForcePosition: 1,
ObjDesc: 1,
Instance: 1);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: wirePosition,
Movement: null,
AnimationFrame: null,
SetupTableId: SetupDid,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: 1f,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
wirePosition,
SetupDid,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: 1f,
Name: "Recall stress owner",
ItemType: null,
MotionState: null,
MotionTableId: 0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private static void AddEmptyPhysicsLandblock(
PhysicsEngine engine,
uint landblockId,
float offsetX,
float offsetY)
{
var heights = new byte[81];
var table = new float[256];
Array.Fill(table, -1000f);
engine.AddLandblock(
landblockId,
new TerrainSurface(heights, table),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
offsetX,
offsetY);
}
private static LoadedLandblock EmptyLandblock(uint canonicalId) =>
new(canonicalId, new LandBlock(), Array.Empty<WorldEntity>());
}