From 7aa96181368b2ce0901e6bfddc690649f7b824a2 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 10:32:21 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 15 +++- ...2026-07-16-portal-completion-pseudocode.md | 56 ++++++++++++ .../LocalPlayerTeleportController.cs | 15 ++++ .../LocalPlayerTeleportControllerTests.cs | 88 +++++++++++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 737e4e84..5048514c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1211,7 +1211,7 @@ Passed 2026-07-17: “Yes works.” ## #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 **Filed:** 2026-07-16 **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 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`; `src/AcDream.App/Rendering/GameWindow.cs`; `src/AcDream.App/Streaming/StreamingController.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/ObjectMeshManager.cs`; `src/AcDream.App/Rendering/Sky/SkyProjection.cs`; diff --git a/docs/research/2026-07-16-portal-completion-pseudocode.md b/docs/research/2026-07-16-portal-completion-pseudocode.md index 978ef002..77465aa5 100644 --- a/docs/research/2026-07-16-portal-completion-pseudocode.md +++ b/docs/research/2026-07-16-portal-completion-pseudocode.md @@ -204,6 +204,62 @@ WorldBuilder cross-check: its upload queue similarly distinguishes generated CPU scenery from render-thread GPU publication (`GameScene.ProcessUploads`), 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 Named retail references: diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index ca94817a..d78483bb 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -236,6 +236,21 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme entity.SetPosition(controller.Position); entity.ParentCellId = controller.CellId; 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 diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index e502d0ff..ea6f5f67 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -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())); + world.AddLandblock(new LoadedLandblock( + destinationCell & 0xFFFF0000u | 0xFFFFu, + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty())); + 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(), + })!; + 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 events, string prefix) { for (int i = 0; i < events.Count; i++)