fix(portal): commit destination projection before reveal

Retail installs the destination CObjCell before hidden world simulation resumes. Rebucket the retained local-player projection inside the Place transaction so Hidden/UnHide scripts and particles settle behind the portal viewport instead of firing after reveal. Preserve the existing unloaded-cell scheduler gate and pin the ordering with a cross-landblock placement test.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-25 10:32:21 +02:00
parent 1a14812c44
commit 7aa9618136
4 changed files with 173 additions and 1 deletions

View file

@ -464,6 +464,94 @@ public sealed class LocalPlayerTeleportControllerTests
spatial.Snapshot.ControllerRotation);
}
[Fact]
public void ConcretePlacement_CommitsDestinationSpatialBucketBeforeReconcile()
{
const uint guid = 0x50000001u;
const uint sourceCell = 0x20210001u;
const uint destinationCell = 0x30310001u;
var world = new GpuWorldState();
world.AddLandblock(new LoadedLandblock(
sourceCell & 0xFFFF0000u | 0xFFFFu,
new DatReaderWriter.DBObjs.LandBlock(),
Array.Empty<WorldEntity>()));
world.AddLandblock(new LoadedLandblock(
destinationCell & 0xFFFF0000u | 0xFFFFu,
new DatReaderWriter.DBObjs.LandBlock(),
Array.Empty<WorldEntity>()));
var runtime = new LiveEntityRuntime(world, new NullResources());
runtime.RegisterLiveEntity(Spawn(guid, sourceCell));
WorldEntity entity = runtime.MaterializeLiveEntity(
guid,
sourceCell,
id => new WorldEntity
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
})!;
var identity = new LocalPlayerIdentityState { ServerGuid = guid };
var controllerSlot = new LocalPlayerControllerSlot
{
Controller = new PlayerMovementController(new PhysicsEngine()),
};
controllerSlot.Controller.SetPosition(
Vector3.Zero,
sourceCell,
Vector3.Zero);
var cameras = new ChaseCameraInputState
{
Legacy = new ChaseCamera(),
Retail = new RetailChaseCamera(),
};
var origin = new LiveWorldOriginState();
origin.SetPlaceholder(0x30, 0x31);
LiveEntityRecord? recordAtReconcile = null;
var spatial = new FakeSpatialReconcile(() =>
{
Assert.True(runtime.TryGetRecord(guid, out recordAtReconcile));
return new PlacementSnapshot(
entity.Position,
entity.ParentCellId ?? 0u,
entity.Rotation,
controllerSlot.Controller.Position,
controllerSlot.Controller.CellId,
controllerSlot.Controller.BodyOrientation);
});
var placement = new LocalPlayerTeleportPlacement(
new PhysicsEngine(),
runtime,
identity,
controllerSlot,
new LocalPlayerPhysicsHostSlot(),
cameras,
origin,
spatial);
placement.Place(
new Vector3(12f, 24f, 6f),
destinationCell,
Quaternion.Identity);
Assert.NotNull(recordAtReconcile);
uint resolvedDestinationCell = controllerSlot.Controller.CellId;
Assert.Equal(
destinationCell & 0xFFFF0000u,
resolvedDestinationCell & 0xFFFF0000u);
Assert.Equal(resolvedDestinationCell, recordAtReconcile.FullCellId);
Assert.Equal(
destinationCell & 0xFFFF0000u | 0xFFFFu,
recordAtReconcile.CanonicalLandblockId);
Assert.True(recordAtReconcile.IsSpatiallyProjected);
Assert.True(recordAtReconcile.IsSpatiallyVisible);
Assert.Contains(entity, world.Entities);
Assert.Equal(resolvedDestinationCell, entity.ParentCellId);
Assert.Equal(1, spatial.Count);
}
private static int Index(IReadOnlyList<string> events, string prefix)
{
for (int i = 0; i < events.Count; i++)