fix(portal): synchronize destination presentation state

This commit is contained in:
Erik 2026-07-16 21:17:13 +02:00
parent 4b1bceefbb
commit e95f55f25b
42 changed files with 2815 additions and 288 deletions

View file

@ -0,0 +1,104 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class LandblockSpawnAdapterReadinessTests
{
[Fact]
public void RenderReady_WaitsForStaticAndEnvCellMeshes()
{
var meshes = new ReadinessMeshAdapter();
var adapter = new LandblockSpawnAdapter(meshes);
var entity = new WorldEntity
{
Id = 1,
ServerGuid = 0,
SourceGfxObjOrSetupId = 0x01000010u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = [new MeshRef(0x01000010u, Matrix4x4.Identity)],
};
var landblock = new LoadedLandblock(
0x1234FFFFu,
new LandBlock(),
new[] { entity });
const ulong envCellGeometryId = 0x2_0000_1234ul;
adapter.OnLandblockLoaded(landblock, new[] { envCellGeometryId });
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
meshes.ReadyIds.Add(0x01000010ul);
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
meshes.ReadyIds.Add(envCellGeometryId);
Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId));
}
[Fact]
public void RenderReady_EmptyPublishedLandblockIsReadyUntilUnload()
{
var meshes = new ReadinessMeshAdapter();
var adapter = new LandblockSpawnAdapter(meshes);
var landblock = new LoadedLandblock(
0x1234FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>());
adapter.OnLandblockLoaded(landblock);
Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId));
adapter.OnLandblockUnloaded(landblock.LandblockId);
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
}
[Fact]
public void SharedEnvCellGeometry_IsPinnedPerLandblockAndReleasedSymmetrically()
{
const ulong sharedGeometryId = 0x2_0000_1234ul;
var meshes = new ReadinessMeshAdapter();
var adapter = new LandblockSpawnAdapter(meshes);
var first = new LoadedLandblock(
0x1234FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>());
var second = new LoadedLandblock(
0x1235FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>());
adapter.OnLandblockLoaded(first, new[] { sharedGeometryId, sharedGeometryId });
adapter.OnLandblockLoaded(second, new[] { sharedGeometryId });
Assert.Equal(2, meshes.ReferenceCounts[sharedGeometryId]);
Assert.Equal(2, meshes.PreparedPinCalls.Count);
adapter.OnLandblockUnloaded(first.LandblockId);
Assert.Equal(1, meshes.ReferenceCounts[sharedGeometryId]);
adapter.OnLandblockUnloaded(second.LandblockId);
Assert.DoesNotContain(sharedGeometryId, meshes.ReferenceCounts);
}
private sealed class ReadinessMeshAdapter : IWbMeshAdapter
{
public HashSet<ulong> ReadyIds { get; } = new();
public Dictionary<ulong, int> ReferenceCounts { get; } = new();
public List<ulong> PreparedPinCalls { get; } = new();
public void IncrementRefCount(ulong id) =>
ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1;
public void PinPreparedRenderData(ulong id)
{
PreparedPinCalls.Add(id);
IncrementRefCount(id);
}
public void DecrementRefCount(ulong id)
{
int count = ReferenceCounts[id] - 1;
if (count == 0) ReferenceCounts.Remove(id);
else ReferenceCounts[id] = count;
}
public bool IsRenderDataReady(ulong id) => ReadyIds.Contains(id);
}
}

View file

@ -0,0 +1,69 @@
using AcDream.App.Rendering.Wb;
using AcDream.Content;
using Chorizite.Core.Render.Enums;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class MeshUploadCachesTests
{
[Fact]
public void CpuCacheHit_AfterCompletedUpload_ReStagesExactlyOnceWithTexturePayload()
{
var staging = new MeshUploadStagingQueue();
var cache = new CpuMeshUploadCache(capacity: 2);
byte[] textureBytes = [1, 2, 3, 4];
var data = new ObjectMeshData { ObjectId = 0x01000010ul, UploadAttempts = 2 };
data.TextureBatches[(1, 1, TextureFormat.RGBA8)] =
[
new TextureBatchData { TextureData = textureBytes },
];
cache.Store(data);
Assert.True(staging.Stage(data));
Assert.True(staging.TryDequeue(out var initial));
Assert.Same(data, initial);
staging.Complete(data.ObjectId); // GPU upload completed, then was later evicted.
data.UploadAttempts = 3;
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out var cached));
Assert.Same(data, cached);
Assert.Equal(0, data.UploadAttempts);
Assert.Equal(textureBytes, cached!.TextureBatches.Values.Single().Single().TextureData);
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out _));
Assert.True(staging.TryDequeue(out var restaged));
Assert.Same(data, restaged);
Assert.False(staging.TryDequeue(out _));
}
[Fact]
public void Retry_RetainsQueueOwnershipUntilCompletion()
{
var staging = new MeshUploadStagingQueue();
var data = new ObjectMeshData { ObjectId = 0x01000010ul };
Assert.True(staging.Stage(data));
Assert.True(staging.TryDequeue(out var attempt));
staging.Requeue(attempt!);
Assert.False(staging.Stage(data));
Assert.True(staging.TryDequeue(out var retry));
Assert.Same(data, retry);
staging.Complete(data.ObjectId);
Assert.True(staging.Stage(data));
}
[Fact]
public void UploadCompletion_NeverManufacturesLogicalOwnership()
{
const ulong id = 0x01000010ul;
var ownership = new MeshOwnershipCounter();
Assert.Equal(1, ownership.Acquire(id));
Assert.True(ownership.MarkUploadComplete(id));
Assert.Equal(1, ownership.Count(id));
Assert.Equal(0, ownership.Release(id));
Assert.False(ownership.MarkUploadComplete(id)); // late upload after unload
Assert.Equal(0, ownership.Count(id));
}
}

View file

@ -0,0 +1,437 @@
using AcDream.App.Streaming;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using System.Collections.Immutable;
namespace AcDream.App.Tests.Streaming;
public sealed class StreamingControllerReadinessTests
{
[Fact]
public void RenderNeighborhoodResident_RequiresEveryPublishedLandblockInRing()
{
var state = new GpuWorldState();
StreamingController controller = CreateController(state);
for (int dx = -1; dx <= 1; dx++)
for (int dy = -1; dy <= 1; dy++)
{
if (dx == 1 && dy == 1)
continue;
AddPublished(state, 0x12 + dx, 0x36 + dy);
}
Assert.False(controller.IsRenderNeighborhoodResident(0x12360022u, 1));
AddPublished(state, 0x13, 0x37);
Assert.True(controller.IsRenderNeighborhoodResident(0x12360022u, 1));
}
[Fact]
public void RenderNeighborhoodResident_RadiusZeroAcceptsEnvCellId()
{
var state = new GpuWorldState();
StreamingController controller = CreateController(state);
AddPublished(state, 0x8C, 0x04);
Assert.True(controller.IsRenderNeighborhoodResident(0x8C0401ADu, 0));
Assert.False(controller.IsRenderNeighborhoodResident(0x8D0401ADu, 0));
}
[Fact]
public void RenderNeighborhoodResident_SkipsOffMapNeighborsLikePhysicsGate()
{
var state = new GpuWorldState();
StreamingController controller = CreateController(state);
AddPublished(state, 0, 0);
AddPublished(state, 0, 1);
AddPublished(state, 1, 0);
AddPublished(state, 1, 1);
Assert.True(controller.IsRenderNeighborhoodResident(0x00000001u, 1));
}
[Fact]
public void RenderNeighborhoodResident_RejectsNegativeRadius()
{
StreamingController controller = CreateController(new GpuWorldState());
Assert.Throws<ArgumentOutOfRangeException>(
() => controller.IsRenderNeighborhoodResident(0x1236FFFFu, -1));
}
[Theory]
[InlineData(0xFF0401ADu)]
[InlineData(0x04FF01ADu)]
[InlineData(0x12360000u)]
[InlineData(0x12360041u)]
[InlineData(0x1236FFFEu)]
public void RenderNeighborhoodResident_RejectsInvalidMapEdgeDestination(uint cellId)
{
StreamingController controller = CreateController(new GpuWorldState());
Assert.False(controller.IsRenderNeighborhoodResident(cellId, 0));
}
[Fact]
public void RenderNeighborhoodResident_WaitsForActualMeshUpload()
{
var meshes = new ReadinessMeshAdapter();
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
StreamingController controller = CreateController(state);
uint id = 0x1236FFFFu;
const ulong envCellGeometryId = 0x2_0000_1234ul;
var entity = new WorldEntity
{
Id = 1,
ServerGuid = 0,
SourceGfxObjOrSetupId = 0x01000010u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = new[] { new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity) },
};
state.AddLandblock(
new LoadedLandblock(id, new LandBlock(), new[] { entity }),
new[] { envCellGeometryId });
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
meshes.ReadyIds.Add(0x01000010ul);
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
meshes.ReadyIds.Add(envCellGeometryId);
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
}
[Fact]
public void RenderNeighborhoodResident_PreservesShellGateWhenPromotionPrecedesBaseLoad()
{
var meshes = new ReadinessMeshAdapter();
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
StreamingController controller = CreateController(state);
uint id = 0x1236FFFFu;
const ulong envCellGeometryId = 0x2_0000_1234ul;
state.AddEntitiesToExistingLandblock(
id,
Array.Empty<WorldEntity>(),
new[] { envCellGeometryId });
state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
Assert.True(state.IsNearTier(id));
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
meshes.ReadyIds.Add(envCellGeometryId);
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
}
[Fact]
public void RenderNeighborhoodResident_FarTerrainDoesNotSatisfyPortalGate()
{
var meshes = new ReadinessMeshAdapter();
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
StreamingController controller = CreateController(state);
uint id = 0x1236FFFFu;
const ulong envCellGeometryId = 0x2_0000_1234ul;
state.AddLandblock(
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
tier: LandblockStreamTier.Far);
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
state.AddEntitiesToExistingLandblock(
id,
Array.Empty<WorldEntity>(),
new[] { envCellGeometryId });
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
meshes.ReadyIds.Add(envCellGeometryId);
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
}
[Fact]
public void StaleFarPublication_DoesNotReplaceNearEntitiesOrReadiness()
{
var meshes = new ReadinessMeshAdapter();
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
StreamingController controller = CreateController(state);
uint id = 0x1236FFFFu;
const ulong envCellGeometryId = 0x2_0000_1234ul;
var entity = new WorldEntity
{
Id = 1,
ServerGuid = 0,
SourceGfxObjOrSetupId = 0x01000010u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = [new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity)],
};
meshes.ReadyIds.UnionWith([0x01000010ul, envCellGeometryId]);
state.AddLandblock(
new LoadedLandblock(id, new LandBlock(), new[] { entity }),
new[] { envCellGeometryId },
LandblockStreamTier.Near);
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
state.AddLandblock(
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
tier: LandblockStreamTier.Far);
Assert.True(state.IsNearTier(id));
Assert.Contains(entity, state.Entities);
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
}
[Fact]
public void EnvCellPublication_RearmsSpecializedPreparationAfterPin()
{
uint id = 0x1236FFFFu;
const ulong geometryId = 0x2_0000_1234ul;
var meshes = new ReadinessMeshAdapter();
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
var outbox = new Queue<LandblockStreamResult>();
int ensureCalls = 0;
var shell = new EnvCellShellPlacement(
0x12360001u,
geometryId,
0x0D000001u,
2,
ImmutableArray.Create<ushort>(3, 4),
System.Numerics.Vector3.Zero,
System.Numerics.Quaternion.Identity,
System.Numerics.Matrix4x4.Identity,
new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One),
new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One));
var envCells = new EnvCellLandblockBuild(
id,
Array.Empty<LoadedCell>(),
new[] { shell });
var build = new LandblockBuild(
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
envCells);
outbox.Enqueue(new LandblockStreamResult.Loaded(
id,
LandblockStreamTier.Near,
build,
new AcDream.Core.Terrain.LandblockMeshData(
Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
Array.Empty<uint>())));
var controller = new StreamingController(
enqueueLoad: (_, _) => { },
enqueueUnload: _ => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (_, _) => { },
state: state,
nearRadius: 0,
farRadius: 0,
ensureEnvCellMeshes: completed =>
{
// The specialized request is replayed only after the synthetic
// id is pinned, closing existing-at-schedule -> evicted-before-
// publication without falling through generic GfxObj decode.
Assert.Equal(1, meshes.ReferenceCounts[geometryId]);
Assert.Equal(shell, Assert.Single(completed.Shells));
ensureCalls++;
});
controller.Tick(0x12, 0x36);
Assert.Equal(1, ensureCalls);
}
[Fact]
public void PromotionWithoutBase_PublishesSelfContainedNearAndPinsBeforeReplay()
{
uint id = 0x1236FFFFu;
const ulong geometryId = 0x2_0000_5678ul;
var meshes = new ReadinessMeshAdapter();
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
var outbox = new Queue<LandblockStreamResult>();
int ensureCalls = 0;
var shell = new EnvCellShellPlacement(
0x12360001u,
geometryId,
0x0D000001u,
2,
ImmutableArray.Create<ushort>(3, 4),
System.Numerics.Vector3.Zero,
System.Numerics.Quaternion.Identity,
System.Numerics.Matrix4x4.Identity,
new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One),
new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One));
var envCells = new EnvCellLandblockBuild(
id,
Array.Empty<LoadedCell>(),
new[] { shell });
var nearBuild = new LandblockBuild(
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
envCells);
var mesh = new AcDream.Core.Terrain.LandblockMeshData(
Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
Array.Empty<uint>());
outbox.Enqueue(new LandblockStreamResult.Promoted(id, nearBuild, mesh));
var controller = new StreamingController(
enqueueLoad: (_, _) => { },
enqueueUnload: _ => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (_, _) => { },
state: state,
nearRadius: 0,
farRadius: 0,
ensureEnvCellMeshes: completed =>
{
Assert.Equal(1, meshes.ReferenceCounts[geometryId]);
Assert.Equal(shell, Assert.Single(completed.Shells));
ensureCalls++;
});
controller.Tick(0x12, 0x36);
Assert.Equal(1, ensureCalls);
Assert.Equal(1, meshes.ReferenceCounts[geometryId]);
Assert.True(state.IsNearTier(id));
// A Far job that had already started may still finish after the
// self-contained promotion. It must not replace or replay the Near tier.
var farBase = new LandblockBuild(
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
outbox.Enqueue(new LandblockStreamResult.Loaded(
id,
LandblockStreamTier.Far,
farBase,
mesh));
controller.Tick(0x12, 0x36);
Assert.Equal(1, ensureCalls);
Assert.Equal(1, meshes.ReferenceCounts[geometryId]);
Assert.True(state.IsNearTier(id));
}
[Fact]
public void PromotionBeforeBase_DemotedBeforeBase_DropsPendingNearPresentation()
{
uint id = 0x1236FFFFu;
const ulong geometryId = 0x2_0000_9ABCul;
var meshes = new ReadinessMeshAdapter();
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
var outbox = new Queue<LandblockStreamResult>();
int ensureCalls = 0;
var appliedBuilds = new List<LandblockBuild>();
var demotedWhileStaticPresent = new List<uint>();
var shell = new EnvCellShellPlacement(
0x12360001u,
geometryId,
0x0D000001u,
2,
ImmutableArray.Create<ushort>(3, 4),
System.Numerics.Vector3.Zero,
System.Numerics.Quaternion.Identity,
System.Numerics.Matrix4x4.Identity,
new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One),
new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One));
var envCells = new EnvCellLandblockBuild(
id,
Array.Empty<LoadedCell>(),
new[] { shell });
var staticEntity = new WorldEntity
{
Id = 1,
ServerGuid = 0,
SourceGfxObjOrSetupId = 0x01000010u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = [new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity)],
};
var nearBuild = new LandblockBuild(
new LoadedLandblock(id, new LandBlock(), new[] { staticEntity }),
envCells);
var mesh = new AcDream.Core.Terrain.LandblockMeshData(
Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
Array.Empty<uint>());
outbox.Enqueue(new LandblockStreamResult.Promoted(id, nearBuild, mesh));
var controller = new StreamingController(
enqueueLoad: (_, _) => { },
enqueueUnload: _ => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (build, _) => appliedBuilds.Add(build),
state: state,
nearRadius: 0,
farRadius: 3,
demoteNearLayer: demotedId =>
{
Assert.Contains(staticEntity, state.Entities);
demotedWhileStaticPresent.Add(demotedId);
},
ensureEnvCellMeshes: _ => ensureCalls++);
controller.Tick(0x12, 0x36); // self-contained promotion publishes Near.
Assert.Single(appliedBuilds);
Assert.NotNull(appliedBuilds[0].EnvCells);
controller.Tick(0x12, 0x39); // normal Near->Far demotion retires presentation.
var farBase = new LandblockBuild(
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
outbox.Enqueue(new LandblockStreamResult.Loaded(
id,
LandblockStreamTier.Far,
farBase,
mesh));
controller.Tick(0x12, 0x39);
Assert.Equal(1, ensureCalls);
Assert.DoesNotContain(geometryId, meshes.ReferenceCounts);
Assert.DoesNotContain(staticEntity, state.Entities);
Assert.Equal(new[] { id }, demotedWhileStaticPresent);
Assert.True(state.IsLoaded(id));
Assert.False(state.IsNearTier(id));
Assert.Equal(2, appliedBuilds.Count);
Assert.Null(appliedBuilds[1].EnvCells);
Assert.Empty(appliedBuilds[1].Landblock.Entities);
}
private static StreamingController CreateController(GpuWorldState state)
=> new(
(_, _) => { },
_ => { },
_ => Array.Empty<LandblockStreamResult>(),
(_, _) => { },
state,
nearRadius: 1,
farRadius: 2);
private static void AddPublished(GpuWorldState state, int x, int y)
{
uint id = ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu;
state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
}
private sealed class ReadinessMeshAdapter : IWbMeshAdapter
{
public HashSet<ulong> ReadyIds { get; } = new();
public Dictionary<ulong, int> ReferenceCounts { get; } = new();
public void IncrementRefCount(ulong id) =>
ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1;
public void DecrementRefCount(ulong id)
{
int next = ReferenceCounts.GetValueOrDefault(id) - 1;
if (next <= 0) ReferenceCounts.Remove(id);
else ReferenceCounts[id] = next;
}
public bool IsRenderDataReady(ulong id) => ReadyIds.Contains(id);
}
}

View file

@ -1,3 +1,4 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.World;
using AcDream.Content.Vfx;
@ -57,7 +58,7 @@ public sealed class RecallTeleportAnimationTests
}
[Fact]
public void InstalledRecall_RetiresBeforeTeleportHiddenStateIsDispatched()
public void InstalledRecall_HiddenEnterWorldPublishesFinishedCyclicPoseWithoutAdvancingTime()
{
string datDir = @"C:\Turbine\Asheron's Call";
if (!File.Exists(Path.Combine(datDir, "client_portal.dat")))
@ -81,38 +82,29 @@ public sealed class RecallTeleportAnimationTests
aceDuration += (high - anim.LowFrame) / Math.Abs(anim.Framerate);
}
const float updateStep = 1f / 60f;
double elapsed = 0.0;
bool hidden = false;
bool hiddenPacketReady = false;
var frame = new RetailLiveFrameCoordinator(
dt =>
{
if (!hidden)
sequencer.Advance(dt);
},
() =>
{
if (hiddenPacketReady)
hidden = true;
},
() => { },
() => { });
// The recall motion is dispatched after an object's UseTime phase.
// On the first later frame whose timestamp reaches ACE's scheduled
// teleport boundary, retail advances the PartArray and only then
// consumes the Hidden SetState from the inbound queue.
while (elapsed < aceDuration)
{
hiddenPacketReady = elapsed + updateStep >= aceDuration;
frame.Tick(updateStep);
elapsed += updateStep;
}
Assert.True(hidden);
Assert.True(
// Advance returns a reusable scratch buffer; snapshot the authored tail
// before HandleEnterWorld/SampleCurrentPose overwrites that buffer.
PartTransform[] recallTail = sequencer.Advance((float)aceDuration).ToArray();
Assert.False(
sequencer.CurrentNodeDiag.IsLooping,
"Recall must reach the Ready cyclic tail before Hidden freezes PartArray playback.");
"At ACE's exact teleport boundary the authored recall link is still the current node.");
// set_hidden -> CPartArray::HandleEnterWorld ->
// MotionTableManager::HandleEnterWorld removes link animations and
// selects the first cyclic (Ready) node. Hidden then suppresses time
// advance, but set_frame/UpdateParts still samples this new pose.
sequencer.Manager.HandleEnterWorld();
IReadOnlyList<PartTransform> finishedPose = sequencer.SampleCurrentPose();
Assert.True(sequencer.CurrentNodeDiag.IsLooping);
Assert.Equal(recallTail.Length, finishedPose.Count);
Assert.Contains(
Enumerable.Range(0, finishedPose.Count),
index => Vector3.DistanceSquared(
recallTail[index].Origin,
finishedPose[index].Origin) > 1e-6f
|| MathF.Abs(Quaternion.Dot(
Quaternion.Normalize(recallTail[index].Orientation),
Quaternion.Normalize(finishedPose[index].Orientation))) < 0.999999f);
}
}