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:
parent
1a14812c44
commit
7aa9618136
4 changed files with 173 additions and 1 deletions
|
|
@ -1211,7 +1211,7 @@ Passed 2026-07-17: “Yes works.”
|
||||||
|
|
||||||
## #218 — Portal silhouette pose, destination reveal, and indoor observer snap
|
## #218 — Portal silhouette pose, destination reveal, and indoor observer snap
|
||||||
|
|
||||||
**Status:** DONE — 2026-07-21, final two-client observer gate user-confirmed
|
**Status:** REGRESSION FIX IMPLEMENTED 2026-07-25 — visual re-gate pending
|
||||||
**Severity:** HIGH
|
**Severity:** HIGH
|
||||||
**Filed:** 2026-07-16
|
**Filed:** 2026-07-16
|
||||||
**Component:** portal VFX / streaming / physics / outbound movement
|
**Component:** portal VFX / streaming / physics / outbound movement
|
||||||
|
|
@ -1263,10 +1263,23 @@ SmartBox projection, matching `SmartBox::RenderNormalMode`/`GameSky::Draw`;
|
||||||
the old fixed 60-degree sky camera exposed the clear/fog background during the
|
the old fixed 60-degree sky camera exposed the clear/fog background during the
|
||||||
nearly 180-degree exit warp.
|
nearly 180-degree exit warp.
|
||||||
|
|
||||||
|
A later retained-runtime regression left one half of destination placement
|
||||||
|
uncommitted: the controller, mesh transform, and authoritative `FullCellId`
|
||||||
|
advanced, but the local player's live projection remained in its old/pending
|
||||||
|
GPU bucket. Since retail `CPhysicsObj::update_object @ 0x00515D10` advances
|
||||||
|
scripts only with a non-null cell, acdream correctly paused the queued Hidden
|
||||||
|
PES—but for the wrong reason and until after viewport reveal. Connected traces
|
||||||
|
showed `0x33000331` firing only after the normal world became visible, directly
|
||||||
|
causing the opaque pop and late purple/action tail. `LocalPlayerTeleportPlacement`
|
||||||
|
now rebuckets the same retained entity to the resolved destination cell before
|
||||||
|
spatial reconciliation and before destination simulation resumes. Legitimate
|
||||||
|
unloaded-cell script pausing remains unchanged.
|
||||||
|
|
||||||
**Files:** `src/AcDream.Core/Physics/AnimationSequencer.cs`;
|
**Files:** `src/AcDream.Core/Physics/AnimationSequencer.cs`;
|
||||||
`src/AcDream.App/Rendering/GameWindow.cs`;
|
`src/AcDream.App/Rendering/GameWindow.cs`;
|
||||||
`src/AcDream.App/Streaming/StreamingController.cs`;
|
`src/AcDream.App/Streaming/StreamingController.cs`;
|
||||||
`src/AcDream.App/Streaming/GpuWorldState.cs`;
|
`src/AcDream.App/Streaming/GpuWorldState.cs`;
|
||||||
|
`src/AcDream.App/Streaming/LocalPlayerTeleportController.cs`;
|
||||||
`src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`;
|
`src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`;
|
||||||
`src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs`;
|
`src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs`;
|
||||||
`src/AcDream.App/Rendering/Sky/SkyProjection.cs`;
|
`src/AcDream.App/Rendering/Sky/SkyProjection.cs`;
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,62 @@ WorldBuilder cross-check: its upload queue similarly distinguishes generated
|
||||||
CPU scenery from render-thread GPU publication (`GameScene.ProcessUploads`),
|
CPU scenery from render-thread GPU publication (`GameScene.ProcessUploads`),
|
||||||
which confirms that worker completion alone is not draw readiness.
|
which confirms that worker completion alone is not draw readiness.
|
||||||
|
|
||||||
|
### 2.1. Destination placement enters the spatial cell before simulation resumes
|
||||||
|
|
||||||
|
Named retail references:
|
||||||
|
|
||||||
|
- `CPhysicsObj::change_cell` at `0x00513390`
|
||||||
|
- `CPhysicsObj::update_object` at `0x00515D10`
|
||||||
|
- `CPhysicsObj::enter_world` at `0x00516170`
|
||||||
|
- `CPhysicsObj::prepare_to_enter_world` at `0x00511FA0`
|
||||||
|
- `CPhysicsObj::set_hidden` at `0x00514C60`
|
||||||
|
|
||||||
|
Retail does not separate an accepted destination Position from the object's
|
||||||
|
live cell pointer. `enter_world` runs `SetPosition`, which installs the object
|
||||||
|
in its destination `CObjCell`, before the PartArray and MovementManager
|
||||||
|
enter-world boundaries complete. `update_object` then rejects only a parented
|
||||||
|
object, a null `cell`, or a Frozen object; Hidden is not a reason to skip the
|
||||||
|
ScriptManager/ParticleManager tail.
|
||||||
|
|
||||||
|
```text
|
||||||
|
accepted portal destination becomes ready:
|
||||||
|
player.enter_world(destination)
|
||||||
|
SetPosition(destination)
|
||||||
|
change_cell(destination CObjCell)
|
||||||
|
PartArray.HandleEnterWorld()
|
||||||
|
MovementManager.HandleEnterWorld()
|
||||||
|
|
||||||
|
destination simulation resumes behind the portal viewport
|
||||||
|
update_object()
|
||||||
|
require cell != null and not Frozen
|
||||||
|
UpdateObjectInternal()
|
||||||
|
advance ScriptManager and ParticleManager
|
||||||
|
```
|
||||||
|
|
||||||
|
acdream keeps authoritative full-cell identity and render-bucket residency as
|
||||||
|
separate facts because its world streams asynchronously. The portal arrival
|
||||||
|
transaction must therefore commit both facts before releasing destination
|
||||||
|
simulation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
LocalPlayerTeleportPlacement.Place:
|
||||||
|
resolve and set controller/body position
|
||||||
|
update retained WorldEntity root transform
|
||||||
|
RebucketLiveEntity(destination full cell)
|
||||||
|
reconcile child/effect/light poses
|
||||||
|
signal materialized and release world simulation
|
||||||
|
```
|
||||||
|
|
||||||
|
A 2026-07-25 connected trace proved the missing rebucket was the cause of the
|
||||||
|
spell-recall exit regression. The local record had destination `FullCellId`,
|
||||||
|
resources, and a logical projection, but remained non-resident in its old or
|
||||||
|
pending GPU bucket. The queued Hidden PES `0x33000331` consequently stayed
|
||||||
|
paused until the normal world was already visible, then fired immediately
|
||||||
|
before UnHide stopped it. That produced both the sudden opaque character and
|
||||||
|
the late purple/recall tail. The correct fix is the missing destination spatial
|
||||||
|
commit, not a recall classifier, timer, or relaxation of the legitimate
|
||||||
|
unloaded-cell script gate.
|
||||||
|
|
||||||
## 3. Successful indoor transitions commit the canonical outbound Position
|
## 3. Successful indoor transitions commit the canonical outbound Position
|
||||||
|
|
||||||
Named retail references:
|
Named retail references:
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,21 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme
|
||||||
entity.SetPosition(controller.Position);
|
entity.SetPosition(controller.Position);
|
||||||
entity.ParentCellId = controller.CellId;
|
entity.ParentCellId = controller.CellId;
|
||||||
entity.Rotation = rotation;
|
entity.Rotation = rotation;
|
||||||
|
|
||||||
|
// Retail CPhysicsObj::enter_world installs the object in its
|
||||||
|
// destination CObjCell before hidden scripts/particles resume.
|
||||||
|
// The accepted Position packet has already advanced FullCellId,
|
||||||
|
// but that wire fact alone does not move acdream's retained
|
||||||
|
// projection out of its source/pending GPU bucket. Commit both
|
||||||
|
// halves of the spatial move here, while portal space still owns
|
||||||
|
// the viewport, so CPhysicsObj::update_object's cell-gated tail
|
||||||
|
// can advance the Hidden/UnHide PES chain at retail's boundary.
|
||||||
|
if (!_liveEntities.RebucketLiveEntity(playerGuid, controller.CellId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Teleport Place could not commit local player 0x{playerGuid:X8} "
|
||||||
|
+ $"to destination cell 0x{controller.CellId:X8}.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retail teleport_hook tail @ 0x00514ED0 clears the local target and
|
// Retail teleport_hook tail @ 0x00514ED0 clears the local target and
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,94 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
spatial.Snapshot.ControllerRotation);
|
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)
|
private static int Index(IReadOnlyList<string> events, string prefix)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < events.Count; i++)
|
for (int i = 0; i < events.Count; i++)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue