diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b8e5e7b8..8f76bc1c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -228,6 +228,68 @@ task scope held `RuntimeSetPositionState.cs` off-limits for that session **Acceptance:** A headless tick with N>0 outstanding placement receipts does not allocate a new array per tick. +## #312 — Cancelled park restored Runtime state but never the presentation half + +**Status:** DONE (fixed 2026-08-04; awaiting the two-client visual gate below) +**Severity:** HIGH (a remote player permanently invisible in world and radar) +**Filed:** 2026-08-04 +**Component:** physics / placement / presentation + +**Description:** A remote player that recalled into the observer's location was +absent from both the 3-D world and the radar while remaining fully simulated — +physics ticking, equipment attached, chat and spellcasting visible. It never +recovered: not on remote movement, not on the observer walking away and back. +Intermittent (it did not reproduce on the next recall). + +**Root cause:** `ParkDeferred` publishes a `Withdraw` receipt with two halves. +Runtime owns the canonical half (`InWorld`, transient bits, object clock, +residency, spatial root) and `RestoreParkWithdrawal` rolls it back on cancel. +The PRESENTATION half — the graphical bucket, `IsSpatiallyProjected` / +`IsSpatiallyVisible`, the projection-visibility sinks (which drive +`EntitySpawnAdapter.SetPresentationResident`, i.e. the WB draw registry), plugin +world state and events, the effect-pose registry, the local-player shadow — is +performed by the host sink, and its only mirror image was a later `Place` +receipt. The per-packet prologue `RebucketLiveEntity` masked the hole for a +MOVING remote; a remote that parks on its FINAL accepted Position and then goes +idle never gets another packet, because ACE stops broadcasting for a stationary +entity. That is the intermittency and the "never recovers". + +Introduced by `7f1c1f5a` (C4 route 4b-2), the first commit that lets an ordinary +remote `UpdatePosition` open a canonical `SetPosition` and therefore reach a +restorable park. H1 (a latched `PhysicsStateFlags.Hidden`) was refuted: the +failing entity's 71 `[remote-slide-tick]` lines come from the ordinary remote +`Tick`, not the hidden-only loop. + +**Fix:** a new `RuntimePlacementProjectionKind.WithdrawalRestored` receipt, +published by `RestoreParkWithdrawal` on the one ordered placement stream exactly +when the entity ends the rollback canonically whole. It is acknowledge-only in +Runtime (the parked operation is already retired), and the host sink maps it to +the exact inverse of its own withdrawal. Routing the restore's `SetFullCell` +through `CommitCanonicalCell` was considered and rejected on measurement: the +`CellCommitted` -> `RebucketLiveEntity` recovery it would fire never touches +plugin world state, the world-event stream, or the effect-pose registry, and it +cannot fire at all on the shipped remote path, where the prologue rebucket has +already recommitted a non-zero `FullCellId` before the merge cancels the park. + +**Files:** `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` +(`RuntimePlacementProjectionKind`, `PublishWithdrawalRestoration`, +`RestoreParkWithdrawal`, `AcknowledgeProjection`); +`src/AcDream.App/World/RuntimePlacementPresentationSink.cs` +(`TryApplyWithdrawalRestoration`); `src/AcDream.App/World/LiveEntityRuntime.cs` +(`TryApplyRuntimePlacementProjection`, `TryApplyRuntimePlacementPlace`'s +`commitPose`); `src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs`. + +**Research:** +[`2026-08-04-invisible-recalled-remote-diagnosis.md`](research/2026-08-04-invisible-recalled-remote-diagnosis.md). +Register: AP-136 amended, AD-63 filed (selection is not re-established). + +**Acceptance:** `WithdrawalRestored_ReinstatesEveryPresentationRegistrationTheWithdrawalRemoved` +(App.Tests) and `CancellingWakeableParkPublishesTheWithdrawalRestorationReceipt` +(Runtime.Tests) both fail with the fix reverted. Live gate, folded into #309's +`ACDREAM_PROBE_PARK=1` two-client run: recall a remote into the observer, let it +STAND STILL, and confirm it renders and blips; `[park-restore]` must report +`presentation=True` for that guid. + ## Recent-regression cleanup — 2026-08-03 Plan: [`2026-08-03-recent-regression-cleanup.md`](plans/2026-08-03-recent-regression-cleanup.md). diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 67a73084..c0f081f8 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 48 active rows (AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-42 refreshed same round — its cited App-side login resolve split was deleted by the C3c flip, the split survives only on the unflipped remote-teleport/headless portal-resync paths; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 49 active rows (AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-42 refreshed same round — its cited App-side login resolve split was deleted by the C3c flip, the split survives only on the unflipped remote-teleport/headless portal-resync paths; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -156,10 +156,11 @@ readiness/requeue adaptation. See | AD-60 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** Executor Position-continuation merges never directly commit residency: `ApplyPositionAction` refreshes `canonical.Snapshot.Position` with the retained wire pose but withholds the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`); only a Runtime `SetPosition` commit (the continuation's own classified placement) or a later simulation full-cell commit may change residency. The LEGACY immediate-apply path's `RefreshSnapshot(canonical, snapshot, refreshPosition: acceptedPosition)` (`RuntimeEntityObjectLifetime.cs:1338`) still derives `FullCellId` from bare wire acceptance - that coarser rule is part of the AP-1 divergence this campaign is removing, not something this row blesses. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment) | Matches retail exactly: `HandleReceivedPosition` never writes a resident cell - `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. | If a future change passes `refreshPosition: true` here, a wire Position would make a cellless canonical body resident without any placement/collision commit - the classic AP-1-shaped bug this campaign exists to close. | `SmartBox::HandleReceivedPosition` 0x00453FD0; `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` comment | | AD-61 | **Filed 2026-08-02 (C3c review round 1).** The #270 settle-timing compression now covers the LOCAL player: `RuntimeLocalPlayerPhysicsPublicationState.SettleFirstEntryGroundContact` runs the shared `SpawnPlacementSettler` exactly once after the dormant activation's final commit (suffix-current authority only), compressing retail's first post-`enter_world` gravity frame — which grants CONTACT/ON_WALKABLE from a real touch — into the placement transaction. The legacy App-era force-seed (`Contact\|OnWalkable\|Active` in `PlayerMovementController.SetPositionCore`) still RUNS during publication-candidate preparation and is then OVERWRITTEN by the faithful activation commit + settle (it was never deleted). Caveat (review minor M2): the settler commits `settle.Position` but discards `settle.CellId` — a settle whose few-cm sweep crosses a cell boundary keeps the placement cell until the next resolve corrects it (inherited #270 semantics; ISSUES entry filed) | `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs` (`SettleFirstEntryGroundContact`); `src/AcDream.Core/Physics/SpawnPlacementSettler.cs` (`TrySettle`); overwritten seed `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`SetPositionCore`) | Timing compression only: contact comes exclusively from the sweep's real touch (no caller-bool seeding, no forced transients), an airborne spawn stays genuinely airborne, and the overwritten force-seed leaves no observable residue past the activation commit — the committed state is exactly what retail's first gravity frame produces | A settle crossing a cell boundary reports the stale placement cell for the frames before the next resolve; a future reader trusting `SetPositionCore`'s "treat as grounded" seed comment could reintroduce the Contact-without-plane state the landing family calls unrepresentable | `CPhysicsObj::enter_world` 0x00516170; `SmartBox::HandleCreateObject` 0x00454C80 | | AD-62 | **Filed 2026-08-03 (C4 route 2, round 2); rewritten round 3.** General rule: an accepted local-player ForcePosition that this route does not carry through to a committed canonical placement is never re-applied. That half matches retail — `SmartBox::BlipPlayer` attempts the placement exactly once and never retries. What diverges is that acdream has non-commit outcomes retail cannot reach at all, because retail's world is fully resident and its placement synchronous. Round 3 narrowed the loss to the re-apply alone wherever the packet's placement was actually BEGUN: the retail position event now fires at that packet's terminal outcome whether or not the placement committed (`SettlePending`'s `positionEventOwed` path), matching `BlipPlayer` discarding `SetPositionSimple`'s `enum SetPositionError` and `HandleReceivedPosition` acking unconditionally @0x00454091. Shapes losing ONLY the re-apply: (i) the destination landblock's collision generation is unpublished so the placement parks (`DeferredCell`) and is then retired by a non-position cause (collision-generation retirement, the lost-cell deadline, `ParkCollisionResidents`) with the accepted authority unmoved — the funnel's EQUAL branch; (ii) the same park superseded by a newer ordinary `Apply` Position which now owns the pose — the ADVANCED+ordinary branch; (iii) any OTHER `PositionAuthorityVersion` advance moving the record out from under the funnel's re-issue test — `TryApplyPickup` (`RuntimeEntityObjectLifetime.cs:1116`), `CommitPositionChannelUpdate` (`:2041`), `AdvanceCreateAuthority` (`:2466`) — effectively unreachable for a live local player, but they fail silently in the same direction and the funnel cannot tell them from (ii). Shapes still losing BOTH the re-apply and the ack because no placement was ever begun for that packet: (iv) a `Contention` whose blocking operation is EXTERNAL to this drive (a concurrent portal/teleport placement owns the entity) — nothing is recorded in `_pending`, so nothing pumps it and the packet is dropped outright; (vi) a re-issue retry marker whose re-issue never manages to begin before the funnel clears it. Losing BOTH for a DIFFERENT reason — the placement WAS begun, but the descriptor was displaced before reaching its own terminal settle: (v) a packet superseded by a newer force whose own placement begins cleanly — `SettlePending` opens by nulling `_pending` without reading it, so the older descriptor's owed ack is discarded. Replaying it would be worse than losing it (a stale-sequence report carrying the newer packet's committed pose), and the displacing packet always acks, so ACE always receives a report for the newest force. The `DeferredCell` park is NOT a precondition of this row: shapes (iv)-(vi) never park. In every shape the body stays where the last successful placement left it and the next accepted Position (ACE broadcasts at 5-10 Hz) carries the corrected pose forward. | `src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs` (`SettlePending` — the single terminal-outcome funnel: its `positionEventOwed` ack and its two non-reissuing branches; and `TryExecuteAcceptedLocalPosition`'s `Contention` return) | Retail has no park and no external placement authority: `SmartBox::BlipPlayer` runs synchronously against a fully resident world, so "arrived but not yet placeable" and "another placement owns this entity" are both unrepresentable there. Those are our async collision-publication and single-placement-authority adaptations. Re-issuing a retired force instead would be worse than not: shape (ii) would stamp the force route's `Teleport\|Slide` flags and an unconditional ack onto an ordinary echo's pose while skipping the `ConstrainTo` the ordinary branch runs (`RuntimeAuthoritativePositionRouteClassifier.cs:368-388`), and shape (i) can re-issue into the same persistent cancellation cause indefinitely. The drive still owns at most one in-flight placement and still re-issues whenever the newest accepted event IS a still-unserved ForcePosition. | A server correction whose destination collision is slow to publish, or which lands while another placement authority owns the entity, can be silently skipped: the player stays at the pre-correction pose for one broadcast interval (~100-200 ms). Sustained (a slow-publishing destination correcting repeatedly) this reads as rubber-banding that does not take. In shapes (iv)-(vi) ACE additionally receives one fewer `AutonomousPosition` than retail would have sent, so the server cannot tell its force was not applied. | `SmartBox::HandleReceivedPosition` @0x00453FD0 FORCE_POSITION branch (`SendPositionEvent` @0x00454091, early return @0x0045409D); `SmartBox::BlipPlayer` @0x00453940 (discards the error, returns void); `CPhysicsObj::SetPositionSimple` @0x005162B0 (returns `enum SetPositionError`; other callers test `== OK_SPE` @0x0055605D/@0x00556021); `CommandInterpreter::SendPositionEvent` @0x006B4770 | +| AD-63 | **Filed 2026-08-04 (cancelled-park presentation rollback).** When a cancelled restorable park is rolled back, the entity's presentation is restored EXCEPT the player's selection. `ParkDeferred`'s Withdraw receipt makes the host sink clear the selection if the parked entity was the selected object (`_clearSelectionForUnavailableEntity`), and the `WithdrawalRestored` receipt that rolls that withdrawal back deliberately does not re-select it. Every other registration the withdrawal removed — the graphical bucket, projection visibility, plugin world state, the world-event replay set, the effect-pose registry, the local-player shadow, the presentation visibility sinks — IS restored exactly. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryApplyWithdrawalRestoration` vs `TryPublishWithdrawal`'s `_clearSelectionForUnavailableEntity` call) | Selection is user intent, not a projection registration. Retail clears the selection when its target becomes unavailable (`SelectionChangeReason.SelectedObjectRemoved` is acdream's name for the same edge) and never re-selects on the object's behalf; re-selecting here would invent input the player did not give. Retail also cannot reach this state at all — it has no cancel for a lost-cell park (AP-136) — so there is no retail behaviour to match, only two acdream choices, and "do not act for the player" is the conservative one. | The player loses their target for the ~150 ms park window if the selected object happened to park, and must re-click it. No other state is affected: the object is visible, on the radar, collidable, and assessable again as soon as the restoration receipt drains. Retire together with AP-136 by making the park survive cancellation (issue #309), which removes the withdrawal — and therefore the selection clear — entirely. | AP-136 (the park rollback this rides on); no retail anchor — retail has no cancellable lost-cell park | --- -## 3. Documented approximation (AP) — 98 active rows (AP-140 filed 2026-08-04 at the Bug B Opus review — the accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT; the fix made that gate live and behaviour-visible, so it is now a filed divergence rather than an unremarked one; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review and NARROWED 2026-08-04 at the C4 route 4b-2 delta review — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 98 active rows (AP-140 filed 2026-08-04 at the Bug B Opus review — the accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT; the fix made that gate live and behaviour-visible, so it is now a filed divergence rather than an unremarked one; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -282,7 +283,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-133 | **Filed 2026-08-03 (#282).** A retail `CPhysicsObj` has exactly ONE `cell`; `ShouldDrawParticles` @0x0050fe60 reads that same field and calls `IsInView` on it, and `set_cell_id` @0x0050f4f0 / `change_cell` @0x00513390 are the only things that move it. acdream splits the concept into `WorldEntity.ParentCellId` (render parent, null for outdoor dat stabs and building shells) and `WorldEntity.EffectCellId` (authored landcell for those parentless stabs). Every consumer now resolves through the single `WorldEntity.VisibilityCellId` accessor (`ParentCellId ?? EffectCellId`); live entities carry `ParentCellId` only. | `src/AcDream.Core/World/WorldEntity.cs` (`VisibilityCellId`); writers `LandblockLoader.cs:80,97`, `LandblockBuildFactory.cs:408` | Outdoor dat stabs deliberately keep a null render parent so portal visibility does not filter them as interior geometry, yet retail still gives their physics object a landcell for particle gating. One accessor keeps the two fields from being read in conflicting orders, which is exactly how #282 arose - `EntityEffectPoseRegistry` preferred `EffectCellId` while `WbDrawDispatcher` and the remote spawn seed preferred `ParentCellId`. | A future writer that sets `EffectCellId` on a live entity re-creates #282: it wins `VisibilityCellId` while the 11 per-tick `ParentCellId` writers leave it frozen, stranding that entity's particles and lights on a stale cell so they fail `IsInView` after it crosses a boundary. | `CPhysicsObj::ShouldDrawParticles` 0x0050fe60; `CPhysicsObj::set_cell_id` 0x0050f4f0; `CPhysicsObj::change_cell` 0x00513390 | | AP-134 | **Filed 2026-08-03 (#297).** Retail keeps ONE `PublicWeenieDesc::_bitfield` per object and mutates it in place — `SetPlayerKillerStatus` @0x005AC7C0 rewrites bits 5/21/25 (PK `0x20` / Free `0x200000` / PKLite `0x2000000`, mutually exclusive), driven from `ACCWeenieObject::OnStatUpdated` @0x0058DF20 `case 0x86`, and `IsPK`/`IsImpenetrable`/`IsPKLite` @0x0058C8xx read that same field. acdream replicates the value into FIVE stores: `ClientObject.PublicWeenieBitfield` (the source, written only by `ClientObjectTable.UpdateIntProperty` on PropertyInt 134), `InboundPhysicsStateController._snapshots[guid].ObjectDescriptionFlags`, `RuntimeEntityRecord.Snapshot.ObjectDescriptionFlags`, the decoded `ShadowObjectRegistry` registration + per-cell `ShadowEntry.Flags`, and the local player's `RuntimeMovementSkillState` own-PWD bitfield. Coherence is maintained by two `ObjectUpdated` subscribers (`RuntimeEntityPvpBitfieldSnapshotSync` for the two snapshot stores, `LiveEntityPvpBitfieldSync` for the decoded shadow flags) plus the appearance-rebuild path re-deriving from the snapshot. The two shadow-flag writers are the SAME invalidation applied at the two edges that can invalidate it, not competing authorities. | `src/AcDream.Runtime/Entities/RuntimeEntityPvpBitfieldSnapshotSync.cs`; `src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs`; source writer `src/AcDream.Core/Items/ClientObjectTable.cs` (`UpdateIntProperty`, PropertyInt 134); decode `EntityCollisionFlagsExt.FromPwdBitfield` | ACE never re-sends a `PublicWeenieDesc` after login (`EnqueueBroadcastUpdateObject` has zero live callers), so PropertyInt 134 over 0x02CE/0x02CD is the ONLY signal a PK status changed — a client cannot learn it from the bitfield itself. The replication exists because acdream separates wire snapshots, canonical records, and the collision shadow registry, which retail does not; each layer needs the decoded value at a different lifetime. Before #297 the snapshot stores were immutable wire captures; this commit is what converts them into write-through caches, and therefore what creates the invariant. | Any future write path that sets `ClientObject.PublicWeenieBitfield` outside `UpdateIntProperty`, or any NEW decoded cache of the PK bits, silently re-creates #297: the player walks through PKLite opponents and melee/missile admission refuses them, with no test failing. Note the same class already exists one field over — `Properties.Ints[134]` is written by `UpsertProperties` (PlayerDescription 0x0013) and `UpdateProperties` (IdentifyObjectResponse) WITHOUT mirroring into the bitfield (#300), and retail's `OnStatUpdated` also rewrites `_blipColor` (`case 0x5f`) and `_radar_enum` (`case 0x85`) which acdream ignores entirely (#301). | `PublicWeenieDesc::SetPlayerKillerStatus` 0x005AC7C0; `ACCWeenieObject::OnStatUpdated` 0x0058DF20 (`case 0x86`); `ACCWeenieObject::IsPK`/`IsImpenetrable`/`IsPKLite` 0x0058C8xx; retail `PKStatusEnum` `acclient.h:6412-6427` | | AP-135 | **Filed 2026-08-03 (C4 route 4a).** Retail `CPhysicsObj::MoveOrTeleport` 0x00516330 writes NOTHING on the airborne no-op (`arg4 == 0` -> `return 0` @0x0051636D), and `SmartBox::HandleReceivedPosition` 0x00453FD0 skips `ConstrainTo` with it (@0x00454272 sits inside `if (MoveOrTeleport(...) != 0)` @0x00454254). acdream honours that for every retail-modeled write — body pose, interpolation queue, leash, render entity, collision shadow, and the AP-80 velocity-derived animation cycle — but deliberately KEEPS two acdream-only per-packet bookkeeping writes on that branch: `RemoteMotion.CellId = wire landblock` and the `LastServerPos`/`LastServerPosTime` sample. This was pre-existing player-remote behaviour; route 4a extends it to NPC remotes so both arms are identical | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, both remote airborne-no-op returns) | The cell id is what acdream's OWN per-tick free-fall `ResolveWithTransition` sweep gates on (`rm.CellId != 0`); without it an airborne remote's sphere sweep is skipped and it falls through the floor (#42's neighbourhood). The server sample is what the first grounded packet after the arc synthesizes its velocity from; dropping it would make that velocity span the whole jump. Neither is a retail `CPhysicsObj` field being written | A remote's cell membership tracks the server's landblock during an arc where retail would keep the cell its own physics last resolved. Visible only if the server's mid-arc landblock disagrees with the client's swept cell — the wire cell is authoritative in every case acdream has observed. Retire together with the free-fall sweep gate, when the remote arc is resolved by the same transition machinery the local player uses | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x0051636D `return 0`); `SmartBox::HandleReceivedPosition` 0x00453FD0 (@0x00454254/@0x00454272) | -| AP-136 | **Filed 2026-08-04 (C4 route 4b-1 review).** Retail has NO cancel for a lost-cell park. `CPhysicsObj::SetPositionInternal` @0x00515BD0 commits the destination pose with `store_position` @0x00515CE2 and registers the object via `CObjectMaint::GotoLostCell` @0x00515CF2 (@0x00508210); the registration is removed by exactly one thing, `CObjectMaint::InitObjCell` @0x00508260, which drains the lost list on cell load and calls `CPhysicsObj::reenter_visibility` @0x00508296 (@0x00516250) to re-place at the committed pose. An update that performs no SetPosition leaves the registration untouched, so retail keeps the object HIDDEN until its cell loads. acdream's accepted-Position merge cancels the park instead (a shipped, tested invariant), so on cancel we roll the withdrawal back — `InWorld`, object clock, canonical residency — and the entity becomes VISIBLE IMMEDIATELY at the committed destination pose, uncollidable until its landblock publishes. The pose itself is retail-exact and is deliberately not rolled back. The `ShadowObjectRegistry.Suspend` applied by `WithdrawCanonical` is also not lifted, because un-suspending needs a real placement dispatch (`ReplacePositionRows`); the entity rejoins the broadphase on its next placement | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s `restorableOnCancel`, `Forget`, `RestoreParkWithdrawal`) | The alternative — leaving the cancelled park's withdrawal in place — strands the entity invisible AND intangible for the rest of the session, because `CancelCoreDeferred` restores none of it and the operation that was the only thing able to wake it is gone. Restoring at the pre-park pose was tried and is wrong: retail commits the destination pose, and route 2's tests pin that the pose survives the cancel. Restore covers the plain unplaceable-destination park and — **narrowed 2026-08-04 at the C4 route 4b-2 delta review, then relocated at that slice's round 3** — `SubmitPreparedPlacementCore`'s two collision-prefix-QUIESCENCE parks. **Corrected round 4 (D1): NOT "unconditionally" for any of the three.** Since the relocation, the same post-snap quiescence test gates EVERY park including the plain one, which the row's own next sentences already described; the word contradicted them. The original blanket "no quiescence park is restorable" was over-broad: its stated reason — re-admitting a spatial root into a retiring prefix blocks the retirement — is exact for `ParkCollisionResidents`, where the entity's OWN cell is retiring, but `TryGetBlockingQuiescence` also fires on prefixes the placement merely TOUCHES (any `QueriedCellIds` entry, i.e. a NEIGHBOUR landblock the sweep crossed a seam into; and the request's `CurrentCellId`, which on a FIRST submit names the destination rather than the departed source because both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting — scoped at round 4 (D5): a retained retry re-submits with no fresh merge, and `RemoteTeleportController`'s rollback can rebucket that field to the pre-teleport landblock, so the arm is live). The decision is taken inside `ParkDeferred`, AFTER `SnapToCell`, as `!IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId)` — the cell `RestoreParkWithdrawal` will actually restore residency into, tested against EVERY live quiescence rather than against the single minimum-`OperationId` token `TryGetBlockingQuiescence` happened to return, and read after `LandDefs.AdjustToOutside` may have moved it (the reachable half of that, and the one a test now pins, is the re-derived cell: a wire (cell, position) pair whose position lies past its own named block's seam is exactly the pair #107's re-derivation distrusts, and it lands residency in a NEIGHBOUR landblock — see `QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell`). A cell id of 0 is `AdjustToOutside`'s map-edge failure sentinel, never landblock (0,0), so the map is not consulted with it (C3c-F3). **Round 4 (D6) added the same test at RESTORE time**, in `RestoreParkWithdrawal`'s residency arm: the park-time answer is a snapshot, and route 2's park is RETAINED until the next packet's merge-time `Forget` ~150 ms later, so a prefix clean when the park was taken can be quiescing when the rollback runs. The `InWorld`/transient/clock half is still restored unconditionally — it is per-entity simulation state, not a claim on any landblock's collision generation. So the rollback re-admits nothing into ANY quiescing prefix, at the moment it actually writes residency rather than only as of when the park was taken, while leaving these parks non-restorable stranded the entity `InWorld = false` / clock suspended / not a spatial root with the only operation able to wake it destroyed by its own next accepted Position. A retirement park (`ParkCollisionResidents`) is still never restored, and now says so explicitly rather than relying on a parameter default | A remote — or the LOCAL PLAYER, which traverses the same shared core through route 2 — that teleports into a non-resident landblock and then STOPS MOVING stays visible at the destination without collision, where retail would hide it and re-show it on cell load — ACE stops broadcasting for a stationary entity, so no later packet corrects it. At 5-10 Hz the ordinary case is superseded within ~150 ms. Retire by making the park SURVIVE cancellation (issue #309), which is blocked on re-deciding the newer-Position-cancels-the-park invariant pinned by `NewerPositionPickupAndParentEachCancelExactLostOperation` and on teardown convergence. **This row carries a user-observable change to shipped paths** — `restorableOnCancel: true` sits in `SubmitPreparedPlacementCore`, the shared core behind every production placement — so it needs the two-client connected check written up in #309 — **rewritten by this slice, not merely extended (round-4 D2 correction: this summary used to describe only the original three remote steps plus "route 2's corrections are unchanged", which no longer matches the issue's own body)**. #309 is now six steps run with `ACDREAM_PROBE_PARK=1`: the three original remote-park steps, plus a quiescing swept-NEIGHBOUR step and a quiescing-DESTINATION step that both exercise the LOCAL PLAYER through route 2 and both carry a stated `[park]`/`[park-restore]` confirmation signal (a quiescence window cannot be synchronised by hand, so without one the step passes while broken), plus the unchanged-ordinary-correction step. Step 5 also asks the tester to confirm the destination landblock's retirement still COMPLETES, and states correctly that the deliberately non-restored park does NOT recover on the next ordinary Position | `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D/@0x00515CDA/@0x00515CE2/@0x00515CF2/@0x00515CF7/@0x00515D07); `CObjectMaint::GotoLostCell` @0x00508210; `CObjectMaint::InitObjCell` @0x00508260 (@0x00508296); `CPhysicsObj::reenter_visibility` @0x00516250 | +| AP-136 | **Filed 2026-08-04 (C4 route 4b-1 review). AMENDED 2026-08-04 (cancelled-park presentation rollback): this row's central claim — "the entity becomes VISIBLE IMMEDIATELY at the committed destination pose" — was true only of the CANONICAL half until that fix, and the gap was a defect, not a divergence.** `ParkDeferred` publishes a `Withdraw` receipt whose presentation half the host sink performs (graphical bucket, projection visibility, plugin world state/events, effect-pose registry, local-player shadow, selection), and `RestoreParkWithdrawal` cannot reach any of it. Its only mirror image was a LATER `Place`, which a remote that parks on its final Position and then stops moving never receives, because ACE stops broadcasting for a stationary entity — so the entity was left simulated, collidable, and audible while ABSENT from both the world render and the radar for the rest of the session. The rollback now publishes `RuntimePlacementProjectionKind.WithdrawalRestored` on the same ordered receipt stream, gated on the entity ending the rollback canonically whole (`FullCellId != 0` and `InWorld`) — deliberately NOT on this row's residency arm alone, which the shipped graphical remote path correctly skips because its per-packet prologue rebucket already recommitted a non-zero `FullCellId`. The AP-136 residual below is unchanged and is now actually observable. Selection alone is not re-established: see AD-63. Retail has NO cancel for a lost-cell park. `CPhysicsObj::SetPositionInternal` @0x00515BD0 commits the destination pose with `store_position` @0x00515CE2 and registers the object via `CObjectMaint::GotoLostCell` @0x00515CF2 (@0x00508210); the registration is removed by exactly one thing, `CObjectMaint::InitObjCell` @0x00508260, which drains the lost list on cell load and calls `CPhysicsObj::reenter_visibility` @0x00508296 (@0x00516250) to re-place at the committed pose. An update that performs no SetPosition leaves the registration untouched, so retail keeps the object HIDDEN until its cell loads. acdream's accepted-Position merge cancels the park instead (a shipped, tested invariant), so on cancel we roll the withdrawal back — `InWorld`, object clock, canonical residency — and the entity becomes VISIBLE IMMEDIATELY at the committed destination pose, uncollidable until its landblock publishes. The pose itself is retail-exact and is deliberately not rolled back. The `ShadowObjectRegistry.Suspend` applied by `WithdrawCanonical` is also not lifted, because un-suspending needs a real placement dispatch (`ReplacePositionRows`); the entity rejoins the broadphase on its next placement | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s `restorableOnCancel`, `Forget`, `RestoreParkWithdrawal`) | The alternative — leaving the cancelled park's withdrawal in place — strands the entity invisible AND intangible for the rest of the session, because `CancelCoreDeferred` restores none of it and the operation that was the only thing able to wake it is gone. Restoring at the pre-park pose was tried and is wrong: retail commits the destination pose, and route 2's tests pin that the pose survives the cancel. Restore covers the plain unplaceable-destination park and — **narrowed 2026-08-04 at the C4 route 4b-2 delta review, then relocated at that slice's round 3** — `SubmitPreparedPlacementCore`'s two collision-prefix-QUIESCENCE parks. **Corrected round 4 (D1): NOT "unconditionally" for any of the three.** Since the relocation, the same post-snap quiescence test gates EVERY park including the plain one, which the row's own next sentences already described; the word contradicted them. The original blanket "no quiescence park is restorable" was over-broad: its stated reason — re-admitting a spatial root into a retiring prefix blocks the retirement — is exact for `ParkCollisionResidents`, where the entity's OWN cell is retiring, but `TryGetBlockingQuiescence` also fires on prefixes the placement merely TOUCHES (any `QueriedCellIds` entry, i.e. a NEIGHBOUR landblock the sweep crossed a seam into; and the request's `CurrentCellId`, which on a FIRST submit names the destination rather than the departed source because both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting — scoped at round 4 (D5): a retained retry re-submits with no fresh merge, and `RemoteTeleportController`'s rollback can rebucket that field to the pre-teleport landblock, so the arm is live). The decision is taken inside `ParkDeferred`, AFTER `SnapToCell`, as `!IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId)` — the cell `RestoreParkWithdrawal` will actually restore residency into, tested against EVERY live quiescence rather than against the single minimum-`OperationId` token `TryGetBlockingQuiescence` happened to return, and read after `LandDefs.AdjustToOutside` may have moved it (the reachable half of that, and the one a test now pins, is the re-derived cell: a wire (cell, position) pair whose position lies past its own named block's seam is exactly the pair #107's re-derivation distrusts, and it lands residency in a NEIGHBOUR landblock — see `QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell`). A cell id of 0 is `AdjustToOutside`'s map-edge failure sentinel, never landblock (0,0), so the map is not consulted with it (C3c-F3). **Round 4 (D6) added the same test at RESTORE time**, in `RestoreParkWithdrawal`'s residency arm: the park-time answer is a snapshot, and route 2's park is RETAINED until the next packet's merge-time `Forget` ~150 ms later, so a prefix clean when the park was taken can be quiescing when the rollback runs. The `InWorld`/transient/clock half is still restored unconditionally — it is per-entity simulation state, not a claim on any landblock's collision generation. So the rollback re-admits nothing into ANY quiescing prefix, at the moment it actually writes residency rather than only as of when the park was taken, while leaving these parks non-restorable stranded the entity `InWorld = false` / clock suspended / not a spatial root with the only operation able to wake it destroyed by its own next accepted Position. A retirement park (`ParkCollisionResidents`) is still never restored, and now says so explicitly rather than relying on a parameter default | A remote — or the LOCAL PLAYER, which traverses the same shared core through route 2 — that teleports into a non-resident landblock and then STOPS MOVING stays visible at the destination without collision, where retail would hide it and re-show it on cell load — ACE stops broadcasting for a stationary entity, so no later packet corrects it. At 5-10 Hz the ordinary case is superseded within ~150 ms. Retire by making the park SURVIVE cancellation (issue #309), which is blocked on re-deciding the newer-Position-cancels-the-park invariant pinned by `NewerPositionPickupAndParentEachCancelExactLostOperation` and on teardown convergence. **This row carries a user-observable change to shipped paths** — `restorableOnCancel: true` sits in `SubmitPreparedPlacementCore`, the shared core behind every production placement — so it needs the two-client connected check written up in #309 — **rewritten by this slice, not merely extended (round-4 D2 correction: this summary used to describe only the original three remote steps plus "route 2's corrections are unchanged", which no longer matches the issue's own body)**. #309 is now six steps run with `ACDREAM_PROBE_PARK=1`: the three original remote-park steps, plus a quiescing swept-NEIGHBOUR step and a quiescing-DESTINATION step that both exercise the LOCAL PLAYER through route 2 and both carry a stated `[park]`/`[park-restore]` confirmation signal (a quiescence window cannot be synchronised by hand, so without one the step passes while broken), plus the unchanged-ordinary-correction step. Step 5 also asks the tester to confirm the destination landblock's retirement still COMPLETES, and states correctly that the deliberately non-restored park does NOT recover on the next ordinary Position | `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D/@0x00515CDA/@0x00515CE2/@0x00515CF2/@0x00515CF7/@0x00515D07); `CObjectMaint::GotoLostCell` @0x00508210; `CObjectMaint::InitObjCell` @0x00508260 (@0x00508296); `CPhysicsObj::reenter_visibility` @0x00516250 | | AP-137 | **Filed 2026-08-04 (C4 route 4b-2); rewritten same day at the dual Opus review.** acdream can classify a remote's accepted Position into three states retail cannot reach, and they now share ONE stated handler instead of a duplicated near/far block. The states: (a) **no classification at all** — `RuntimeAcceptedPositionRouteRequests.TryBuild` refuses to fabricate a local-player position, so `ClassifyRemoteAcceptedPosition` returns null for EVERY remote packet until the local movement controller exists (the login window) and whenever the canonical record has not claimed a local id; (b) **`RejectedAuthority`/`RejectedData`** — acdream validates wire authority and payload finiteness, retail validates neither; (c) the **cell-less `SetPosition`** half, which retail routes through `this_1->cell == 0` @0x00516386 and route 4b-3 will own. All three take AP-87's shared `ApplyInterpolate` catch-up (`RuntimeRemoteFarSnapPosition.ResolveArm`'s `UnroutedCatchUp`). **R1 — what the deleted far test actually computed (the first version of this row was wrong).** It claimed the deleted `_playerController?.Position ?? Vector3.Zero` distance had "no relationship to `player_distance`". Not true: `worldPos` is streaming-origin-relative (local position + `(landblock − _origin.Center) × 192 m`) and the streaming origin recentres on the local player's landblock, so the fabricated distance measured the remote's range from the ORIGIN LANDBLOCK'S CORNER — a biased but genuinely correlated proxy, error bounded by the player's own offset inside that landblock (0–192 m per axis). It is deleted anyway because a silently-biased proxy for an exact 96 m threshold is not a threshold: the bias reaches ~2.8× the threshold, so the arm it selects is frequently not the arm retail selects, and correcting it needs exactly the player position the classifier declined to fabricate. **R2 — the cell-less delta, stated.** Retail's cell-less arm is an UNCONDITIONAL placement sitting AHEAD of the contact test (`teleport_hook` @0x005163EF, `SetPosition` flags `0x1012` @0x00516420, `return 1` @0x00516438); acdream now ENQUEUES that classification whenever `!firstUp && willBeDrTicked && bodyToTarget <= 4 m`, at ANY distance, not only ≥96 m. **Deliberately not changed to place in 4b-2**: retail's arm is not a pose write, it is `teleport_hook` @0x00514ED0 — the COMPLETE call list, corrected at the 2026-08-04 delta review, which found the earlier enumeration had dropped the last entry: `MovementManager::CancelMoveTo` @0x00514EDF, `PositionManager::UnStick` @0x00514EEE, `PositionManager::StopInterpolating` @0x00514EFD, `PositionManager::UnConstrain` @0x00514F0C, `TargetManager::ClearTarget` @0x00514F1B + `TargetManager::NotifyVoyeurOfEvent(Teleported_TargetStatus)` @0x00514F28, and `CPhysicsObj::report_collision_end(this, 1)` @0x00514F31 — followed by the canonical flags-`0x1012` `SetPosition` — the classifier itself records this as `TeleportHookPhase.BeforePositionOperation`. Writing only the pose would leave a live moveto, a live stick, and a leash anchored to the old cell, strictly worse than the queue. Porting the whole arm is route 4b-3's entire scope. **R3 — `RejectedData` is APPLIED anyway.** It is the one classification meaning "this payload failed validation" (`ClassifyAcceptedPosition` emits it for a `ValidPosition` failure and for a non-finite/negative derived `player_distance`), and `UnroutedCatchUp` hands the same payload to `ApplyInterpolate`. Not a regression — the legacy block did the same — but the slice's stated purpose was an explicit handler, so it is named. **Headless (contract item 6) is satisfied vacuously and that is stated, not implied:** nothing in `AcDream.Headless` constructs `RuntimeRemotePlacementDriveController` (`SessionPlayerComposition` is the only construction site) and `RuntimeLiveEntitySessionController.OnPositionUpdated` returns early for every non-local GUID, so the far snap is a graphical-host-only path | `src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs` (`ResolveArm`, `RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp`); applied at `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ApplyRemoteContactRouting`'s default arm); headless statement on `IRuntimeRemotePlacementServiceWindow` | AP-87's own snap conditions (`firstUp \|\| !willBeDrTicked \|\| bodyToTarget > 4 m`) still PLACE an unplaced or badly-lagging body, so a remote keeps tracking the server through the login window and through a rejected packet — this arm is never frozen. The deleted far test could not have been preserved honestly: one of its three residual inputs is an uncorrectably biased fabrication, one is unreachable (the classifier returns before evaluating distance), and the third contradicted retail's own branch order | A leftover-classified remote beyond 96 m that is already tracking catches up over a packet interval instead of snapping — invisible in practice at that range, but a real change to the cell-less path that route 4b-3 must re-check when it takes ownership, together with the two rejections' own arm. If AP-87's 4 m backstop were ever weakened, this arm would become the silent-freeze path the route 4b scoping named as its trap | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x00516386 cell-0/teleport, @0x005163AF near, @0x005163C1-E8 far); `CPhysicsObj::teleport_hook` @0x00514ED0; `RuntimeAcceptedPositionRouteRequests.TryBuild`; `GameRuntime.cs:288-290` (the no-fabricated-Vector3.Zero rule) | | AP-138 | **Filed 2026-08-04 (C4 route 4b-2, dual Opus review).** Retail's remote far snap is unconditional and unrefusable: `CPhysicsObj::MoveOrTeleport` @0x005163D9 calls `SetPositionSimple`, discards its `SetPositionError`, and returns 1 @0x005163E8, so `SmartBox::HandleReceivedPosition` arms `ConstrainTo` @0x00454272 every time. acdream's far snap is a canonical Runtime placement that can decline for reasons retail has no analogue for, and this row records the complete residual. **(1) An outcome that never reached the engine is a `store_position`; one that did is not.** Retail's `SetPositionInternal` @0x00515BD0 has exactly two shapes and acdream now represents both (**corrected 2026-08-04 at the delta review, which found the first version of this row asserting — wrongly — that no acdream non-commit outcome could represent the second**). STORES, because the resolve never ran: `Refused` (the pre-flight declined the destination), `Contention` (another authority owns the operation, or the Setup/world-frame preparation is retryable), `RejectedPreparation` (`RejectedAuthority`/`InvalidData` — preparation refused before anything was submitted), and `NotApplicable`. For those `ApplyAcceptedRemoteFarSnap` writes the accepted destination pose to the canonical body, exactly as retail commits it on the no-transition branch — `prepare_to_leave_visibility` @0x00515CDA, `store_position` @0x00515CE2, `GotoLostCell` @0x00515CF2, `return 0` @0x00515D07 — so the remote keeps tracking the server at 5-10 Hz, at the destination, with no resolved cell; retail would additionally have hidden it until cell load, which is AP-136's scope, not this one. DOES NOT STORE, because the resolve DID run and refused: `RejectedByPlacement` (`PhysicsEngine.SetPosition` returned a non-Ok error, acdream's port of retail's `CheckPositionInternal == 0` @0x00515C85/@0x00515CD5 and `curr_cell == 0` @0x00515C8F/@0x00515CB2, neither of which stores; or authority displaced after the engine ran, which includes the `CommitCanonical`-already-settled shape) and `Deferred` (Core parked, and `ParkDeferred` has ALREADY snapped the body to the parked result — the accepted destination for the pre-sweep park, the collision-settled `spherePath.CurPos` for the post-sweep one — which `RestoreParkWithdrawal` deliberately leaves alone). **(2) A quiescence park a far snap can provoke is now restorable at the source, not refused by a pre-flight.** **Rewritten 2026-08-04 at the delta review.** `CanAttemptDestination` (service window + Core's own `IsCollisionPrefixQuiescing`) reads ONE prefix, the destination's, and stays as an optimisation. It cannot be the correctness mechanism: Core's `PlacementTouchesPrefix` also matches the request's `CurrentCellId` (see the round-3 measurement below for what that arm actually names), and `ResultTouchesPrefix` scans every `QueriedCellIds` entry, a sweep footprint that spans NEIGHBOUR landblocks (`CellTransit.AddOutsideCell` re-derives the block id from the global lcoord and has no same-block filter) and does not EXIST until the sweep has run. Worse, the post-sweep check is `result.IsSuccessful && TryGetBlockingQuiescence(result, …)` and sits ahead of the restorable `result.IsDeferred` park, so a healthy about-to-COMMIT far snap near a seam was rewritten to `DeferredCell` and parked non-restorably. The fix is in `SubmitPreparedPlacementCore`: both quiescence parks are restorable, and `ParkDeferred` decides safety on the cell it will actually restore into — see AP-136 for the exact predicate and for why it does not re-open the retirement stall AP-136's blanket scoping was protecting against. On a FIRST submit the `CurrentCellId` half of `PlacementTouchesPrefix` is NOT the "source landblock a far snap is leaving": both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting (the graphical remote path through `LiveEntityRuntime.RebucketLiveEntity` in its shared prologue, route 2 through the merge), so that arm names the destination — measured 2026-08-04 at round 3. **Scoped at round 4 (D5): that is a first-submit property only, and the arm is live rather than dead code.** A RETAINED operation re-submits from its own cadence pump with no fresh merge (both drives re-read `record.FullCellId` at submit), and `RemoteTeleportController`'s rollback is a shipped writer that rebuckets it back to the PRE-teleport landblock, so a retry can genuinely name a third landblock — which `CanAttemptDestination`'s own doc already said and the two summaries elsewhere contradicted. **(3) The leash is not armed through a superseded incarnation.** Retail arms unconditionally on the nonzero return; acdream re-validates position ownership after the placement (the receipt is published synchronously and the projection sink can replace or delete the incarnation from inside it) and returns without arming if the owner moved. Both remote arms now run that check BEFORE their arming call — the player arm used to arm first, the NPC arm second, and one of the two mirror images had to be wrong | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`RuntimeRemotePlacementExecutionStatus` + `StoresAcceptedDestination`, `ApplyAcceptedRemoteFarSnap`, `StoreAcceptedDestinationPose`, `Advance`'s window-drop path, `CanAttemptDestination`, `SubmitAndResolve`); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s post-snap restorable decision and the two `SubmitPreparedPlacementCore` quiescence parks); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (both arms' re-validate-then-arm order) | The alternative to (1) is the shipped pre-review state: an emptied interpolation queue plus a stale body pose, i.e. a frozen remote that the next packet reproduces identically, since nothing about a refusal reason changes at packet cadence. That is strictly further from retail than either the deleted legacy block (which always tracked) or retail itself. The alternative tried and rejected in between — storing on EVERY non-commit outcome — is worse still in the other direction: it teleports the canonical body into a destination the engine's own sweep just refused, and overwrites a freshly settled pose (contact plane, step-down) whenever `CommitCanonical` landed and only the projection ownership was displaced. The alternative to (2) — keeping the pre-flight as the correctness mechanism and widening it — is structurally impossible, because the swept footprint half of Core's predicate does not exist until the sweep has run; the alternative of leaving the parks non-restorable strands the remote outright. The alternative to (3) — arming a leash on a host that is no longer the entity's canonical position owner — is a write through superseded state, the exact class the re-validation exists to prevent, and retail has no superseded-incarnation state for its unconditional arm to arbitrate | A remote whose destination this host cannot place into keeps moving and rendering but does not become collidable or cell-resident until a later packet commits — it can be walked through at range. Bounded by the 5-10 Hz packet stream and by how long the destination stays unpublished/quiescing. A remote whose destination the ENGINE refuses, or whose commit was displaced, keeps its last resolved pose for that packet instead of tracking — retail-exact, but it means a remote can look one packet stale near geometry it cannot be placed into. A quiescence park whose blocking prefix is a swept neighbour re-shows the entity immediately at the destination rather than hiding it until cell load (AP-136's own residual, now reachable through this path and through route 2's local-player corrections). A superseded incarnation's leash is left unarmed for one packet; the replacement incarnation arms its own on its next accepted Position. Retire (1) by making the far arm's failure path open retail's lost-cell registration instead of a bare pose write, which is issue #309's territory (the park must survive cancellation first) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x005163D9, @0x005163E8); `CPhysicsObj::SetPositionSimple` @0x005162B0 (flags `0x1012` @0x005162C4); `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D, @0x00515CDA, @0x00515CE2, @0x00515CF2, @0x00515CB2, @0x00515CD5, @0x00515D07); `SmartBox::HandleReceivedPosition` @0x00453FD0 (@0x00454254, @0x00454272) | | AP-139 | **Filed 2026-08-04 (Bug B).** The remote tick clears its InterpolationManager queue on the LANDING edge — retail’s own `set_on_walkable(1)` transition, the same edge HitGround fires from. Retail has no such clear on a ground or contact edge: its only queue teardown outside a completed walk is `PositionManager::StopInterpolating` from `CPhysicsObj::teleport_hook` @0x00514EFD and the `InterpolationManager::UseTime` @0x00555f20 stall/autonomy blips. The clear is carried over unchanged in intent from the deleted hand-rolled landing block (#184, 2026-07-07), which hung it on a hand-rolled `Airborne && IsOnGround && Velocity.Z <= 0` test that also fired on a steep (non-walkable) contact; Bug B re-derived the edge without changing the behaviour it was written for | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs` (the SetPositionInternal commit block); the packet-side twin lives in `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, the player-remote landing snap) | A contact-free arc never enqueues — route 4a's airborne no-op writes nothing at all — so anything still queued when the body lands is a pre-arc waypoint, and the first catch-up after touchdown would otherwise walk the body backward toward it | A remote that regains contact while a legitimately fresh waypoint is queued loses one correction and re-acquires it on the next accepted Position (~5-10 Hz). A body that repeatedly loses and regains contact (a bounce chain down a rough face) clears the queue once per bounce. Retire when the arc itself feeds the queue, at which point the pre-arc waypoints are no longer stale | `CPhysicsObj::teleport_hook @ 0x00514ED0` (`StopInterpolating` @0x00514EFD); `InterpolationManager::UseTime @ 0x00555f20`; `CPhysicsObj::SetPositionInternal @ 0x00515330` | diff --git a/docs/research/2026-08-04-invisible-recalled-remote-diagnosis.md b/docs/research/2026-08-04-invisible-recalled-remote-diagnosis.md new file mode 100644 index 00000000..384ed9c9 --- /dev/null +++ b/docs/research/2026-08-04-invisible-recalled-remote-diagnosis.md @@ -0,0 +1,476 @@ +# Invisible recalled remote — diagnosis + +**Date:** 2026-08-04 +**HEAD:** `204d0ae0` +**Mode:** REPORT-ONLY. No source or test edits were made. +**Subject:** remote player `0x50000001` recalls into the observer's location and +is absent from both the 3-D world and the radar, while remaining logically alive +(chat and spellcasting visible, physics ticking, equipment attached). + +**Bottom line:** the failing stage is pinned to a narrow set — something that the +per-packet prologue rebucket does **not** restore. Two hypotheses survive every +piece of evidence. They are separated by two cheap, decisive tests named in §6. +`7f1c1f5a` is **not exonerated**: it introduced a concrete mechanism that +produces exactly this signature. Do not act before running §6. + +--- + +## 1. Evidence held + +For guid `0x50000001`: + +| Signal | Count | Meaning | +| --- | --- | --- | +| `[remote-slide-tick]` | 71 | body ticking in a remote physics workset | +| `[remote-slide-enq]` | 9 | accepted Positions reached interpolation enqueue | +| `[remote-slide-up]` | 9 | accepted Positions reached the App routing tail | +| `[remote-slide-vec]` | 1 | a 0xF74E VectorUpdate accepted | +| equipment attach | 1 | child `0x800046D9` → `RightHand`, **before** any tick or UP | + +User follow-ups: + +- **Never recovers** — not on remote movement, not on observer walk-away-and-back. +- **Did not reproduce on a second recall** — intermittent. +- **Ordering inversion vs. a working entity in the same session:** + +| entity | first `[remote-slide-tick]` | first `[remote-slide-up]` | order | +| --- | --- | --- | --- | +| `0x5000000F` (visible) | t=93313875 | t=93313843 | UP **32 ms before** first tick | +| `0x50000001` (invisible) | t=93507234 | t=93508546 | UP **1312 ms after** first tick | + +Both `firstUpAtEntry=True`; distances 46.4 m (failing) / 28.97 m (working) — both +inside the 96 m far threshold at observation time. + +--- + +## 2. The one thing the log proves outright + +`LiveEntityNetworkUpdateController.OnPosition` spans **lines 1459–2617** +(verified by brace-depth scan; single method). Within it: + +- `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:1768` — + `if (!_liveEntities!.RebucketLiveEntity(update.Guid, p.LandblockId) || …)` → + early `return` at `:1781`. +- `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2476` — + `ApplyRemoteContactRouting(…)`, sole caller of + `src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs:187` + (`LogRemoteSlideEnqueue` → `[remote-slide-enq]`). + +`:2476` is downstream of `:1768`. **Nine `[remote-slide-enq]` lines therefore +prove `RebucketLiveEntity` returned `true` nine times.** + +Each of those nine calls (`src/AcDream.App/World/LiveEntityRuntime.cs:801-977`): + +- did **not** take the C3c residence refusal at `:806-834`, +- found `record.WorldEntity` non-null (`:803-805`), +- set `record.IsSpatiallyProjected = true` (`:844`), +- re-placed the GPU bucket via `_spatial.RebucketLiveEntity` (`:868`) — + which is remove-then-place (`src/AcDream.App/Streaming/GpuWorldState.cs:1132-1133`) + and therefore **recovers a removed or pending projection**, +- recomputed `IsSpatiallyVisible` (`:894-895`), +- re-fired visibility observers (`:949-959`), +- succeeded at `CommitRebucket` (`:904-914`). + +### 2.1 What this eliminates + +- **Materialization never ran / null `WorldEntity`.** Eliminated (`:803-805`). +- **C3c initial-create residence gate stuck active.** Eliminated (`:806-834`). +- **Draw bucket simply never installed / permanently lost (#184 class).** + Eliminated — it is reinstalled on every packet. +- **`_pendingByLandblock` strand (#168 class).** Eliminated as a *permanent* + state: `GpuWorldState.RebucketLiveEntity`'s fast path only short-circuits when + `current.IsLoaded` is true (`GpuWorldState.cs:1106-1111`), so a pending entity + is re-placed every packet and promotes as soon as its landblock is loaded. +- **Unrestored park withdrawal of *canonical* residency.** `FullCellId` is + recommitted by `CommitRebucket` on every packet. + +### 2.2 Correction to an earlier draft of this document + +An earlier draft additionally claimed the 71 `[remote-slide-tick]` lines proved +`IsSpatiallyVisible == true` via `HasSpatialRuntimeProjection` +(`src/AcDream.App/World/LiveEntityRuntime.cs:3171-3176`). **That claim is +withdrawn.** `[remote-slide-tick]` is emitted from the *Runtime-side* +`src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:726`, whose workset +`RuntimePhysicsState.CopySpatialRemotesTo` +(`src/AcDream.Runtime/Physics/RuntimePhysicsState.cs:1314-1332`) filters on +`IsSpatialRoot(record)` — **not** on the App-side `IsSpatiallyProjected` / +`IsSpatiallyVisible`. The App-side filtered copy +(`LiveEntityRuntime.cs:2666-2685`) is used by +`src/AcDream.App/Physics/RemotePhysicsUpdater.cs:79`, which is +`TickHiddenEntities` (declared `:59`) — a **hidden-only** loop whose `:84` guard +`(record.FinalPhysicsState & PhysicsStateFlags.Hidden) == 0 → continue` skips +non-hidden entities. + +**Resolved on re-verification:** the emit site +`RuntimeRemotePhysicsUpdater.cs:726` falls inside `internal bool Tick` (declared +`:61`), **not** `internal bool TickHidden` (declared `:878`). App-side, +`TickHiddenEntities` reaches the hidden path via `TickHidden` (`:121` → `:251` → +`:279 _runtime.TickHidden`). **So the 71 tick lines came from the ORDINARY +remote path, not the hidden-only loop.** + +Consequence: H1 loses the "existing log is self-confirming" corroboration that an +earlier draft claimed. Whether it also *refutes* H1 depends on whether the +ordinary loop filters `Hidden` — **NOT ESTABLISHED**; the ordinary DR tick driver +was not located (it is not in `LiveEntityNetworkUpdateController`, +`LiveEntityMotionRuntimeController`, or `LiveEntityOrdinaryPhysicsUpdater`'s +searched surface). If the ordinary loop has no `Hidden` filter, H1 stands intact; +if it skips `Hidden` entities the way `TickHiddenEntities:84` skips non-hidden +ones, H1 is refuted outright and H2 becomes the sole surviving hypothesis. **This +is a five-minute source question and should be the first action of the next +session — it may remove a whole hypothesis before any client is launched.** + +--- + +## 3. Constraint on the answer + +The fault must be something that: + +1. survives a successful `RebucketLiveEntity` on every packet, +2. suppresses **both** world-render and radar, and +3. leaves physics/interpolation, chat, casting, and equipment working. + +Two candidates satisfy all three. + +--- + +## 4. H1 — `PhysicsStateFlags.Hidden` latched on + +`src/AcDream.App/World/LiveEntityRuntime.cs:3357-3376`: + +```csharp +PhysicsStateFlags state = record.FinalPhysicsState; +bool residenceVisible = …; +entity.IsDrawVisible = residenceVisible + && (state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0; + +bool interactionVisible = record.IsSpatiallyVisible + && record.ProjectionKind is LiveEntityProjectionKind.World + && (state & PhysicsStateFlags.Hidden) == 0; +``` + +`Hidden = 0x00004000` (`src/AcDream.Core/Physics/PhysicsBody.cs:52`). + +| Consumer | Gate | file:line | +| --- | --- | --- | +| World render | `entity.IsDrawVisible` | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1159-1164` | +| Radar blip | `_projections.SetVisible` → `_visible` | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs:116-120` → `LiveEntityRuntime.cs:1859` → `LiveEntityProjectionStore.cs:139-152` | + +Survives the rebucket: `RebucketLiveEntity` calls `RefreshPresentation` (`:896`), +which faithfully re-publishes a `FinalPhysicsState` that still carries `Hidden`. +**Presentation is not stale — it is correctly rendering a wrong flag.** Only a +fresh accepted SetState clears it, and ACE does not resend one. + +Recall is the one flow where a player is legitimately hidden then un-hidden, so a +lost un-hide is scenario-appropriate. Two candidate drop sites: + +1. **Queued behind the initial-create residence and lost.** + `LiveEntityNetworkUpdateController.cs:1429-1438` states the accepted SetState + is "queued behind the initial residence". The executor has six arms that + publish nothing (`src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs:1015`, + `:1022`, `:1028`, `:1045`, `:1048`, `:1108`) versus one `Released` arm reaching + `PublishExecutorCompletion` (`:1092`). Its `BecameHidden` handling is at + `:2315`, `:2347`. **The 1.3 s tick-before-first-UP inversion is direct evidence + this window was open ~40 physics quanta for the failing entity.** +2. **Edge-trigger against a wrong `previousState`.** + `src/AcDream.Core/Physics/RetailPhysicsStateTransition.cs:49-55` computes + `hiddenChanged` from `previousState ^ requestedState`. `ConstructorState` + (`:37-41`) deliberately excludes `Hidden`. A mismatched `previousState` makes + the clearing edge read as "no change" and skips it, latching `Hidden`. + +**Corroboration withdrawn.** An earlier draft argued the 71 tick lines came from +`TickHiddenEntities` and were therefore self-confirming for H1. §2.2 shows they +came from the ordinary `Tick` path instead. H1 now rests entirely on the +elimination argument in §3 plus the §6.1 walk-through test. + +--- + +## 5. H2 — park `Withdraw` tears down presentation state the rebucket never restores + +**This is a `7f1c1f5a` regression mechanism.** + +`7f1c1f5a` is the first commit that lets an *ordinary* remote `UpdatePosition` +open a canonical `SetPosition` operation +(`LiveEntityNetworkUpdateController.cs:1083-1088` → +`src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs:760-784`). +Before it, the only remote placement path was `RemoteTeleportController` behind +the `remotePlacementRequired` gate. + +When that placement parks (`src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs:2997`, +`:3053`, `:3086` → `ParkDeferred:4418`), it publishes a `Withdraw` projection +**synchronously** (`:4541-4545` → +`src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs:136`). + +The production sink's withdrawal — `src/AcDream.App/World/RuntimePlacementPresentationSink.cs:195-224` — does: + +```csharp +for (…) _visibilitySinks[i](record, false); // :202-207 +_worldState.RemoveById(entity.Id); // :209 +_worldEvents.ForgetEntity(entity.Id); // :212 +_effectPoses.Remove(entity.Id); // :215 +_clearSelectionForUnavailableEntity(…); // :222 +``` + +Its mirror image `TryPublishPlace` (`:162-193`) is the **only** thing that +re-adds `_worldState.Add` (`:168`), `_worldEvents.UpsertCurrent` (`:171`), and +`_effectPoses.PublishMeshRefs` (`:174`). + +`RestoreParkWithdrawal` (`RuntimeSetPositionState.cs:3503-3552`) restores +`InWorld`, the object clock, `FullCellId`, and the spatial root — and **by its +own documented design restores none of the render side** (`:3494-3501`). +Critically, its `SetFullCell` is a plain field write +(`src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs:340-347`) that +**bypasses `CommitCanonicalCell`**, so the `CellCommitted` → +`RebucketLiveEntity` recovery at `LiveEntityRuntime.cs:3316` never fires. +`TryAdoptWireCellAfterRouting` (`LiveEntityNetworkUpdateController.cs:1193-1194`) +suppresses the one remaining same-packet re-commit on the NPC arm. + +The per-packet prologue rebucket restores `IsSpatiallyProjected`, the GPU bucket, +`IsSpatiallyVisible`, and the visibility sinks — but **not** `_worldState`, +`_worldEvents`, or `_effectPoses.PublishMeshRefs`. If mesh refs are among the +casualties, `WbDrawDispatcher.cs:1163` (`if (entity.MeshRefs.Count == 0) continue;`) +skips the entity **permanently**, which is exactly "never recovers". + +**Fit:** intermittent (only when a placement parks), never recovers (only a +`Place` receipt or re-materialization restores it), physics healthy (Runtime +state fully restored), enq lines present (rebucket succeeds). + +**NOT ESTABLISHED:** whether `_effectPoses.Remove` actually clears +`entity.MeshRefs`, and whether the radar's candidate source +(`GpuWorldState._loadedLiveByLandblock`, restored by the rebucket) or its +`_visible` gate is affected. If neither kills the radar, H2 explains render-only +and H1 remains the better fit for the combined symptom. + +--- + +## 6. Discriminating tests — run these first, they are cheap and decisive + +### 6.1 Walk into the invisible player (free, no code) + +`src/AcDream.App/Physics/LiveEntityShadowPublisher.cs:46-55` gates collision-shadow +publication on `(record.FinalPhysicsState & PhysicsStateFlags.Hidden) == 0`. + +> **H1 predicts the observer walks THROUGH the invisible player.** +> H2 predicts it is **solid** (Runtime spatial root and shadow rows intact after +> the next placement). + +Also: `NoDraw` (`0x20`) suppresses render only — radar's `interactionVisible` +does not test it. Since radar is also dead, if H1 holds the flag is `Hidden`. + +### 6.2 `ACDREAM_PROBE_PARK=1` (already shipped by `7f1c1f5a`) + +Emits `[park] guid=… cause=… eligible=… captured=…` +(`RuntimeSetPositionState.cs:4466-4475`) and +`[park-restore] guid=… residency=…` (`:3545-3551`). + +> **A `[park]` line for the failing guid ⇒ H2 live and `7f1c1f5a` implicated.** +> **No `[park]` lines anywhere in the session ⇒ `7f1c1f5a` exonerated outright.** + +These two tests together resolve the regression question definitively. + +--- + +## 7. Regression verdict + +**Not settled by code reading alone — §6.2 settles it. Current position:** + +### 7.1 `204d0ae0` — no mechanism found + +Its `src/` files are `LiveEntityNetworkUpdateController.cs`, +`InterpolationManager.cs`, `MotionTableDispatchSink.cs`, `PhysicsDiagnostics.cs`, +`RuntimeRemotePhysicsUpdater.cs`, `RuntimeRemoteSteadyStatePosition.cs`. The +commit primitives it routes through are pure body-state functions +(`src/AcDream.Core/Physics/PhysicsObjUpdate.cs:153-179`, `:181-185`, `:209-248`) +— no cell, no shadow registry, no `WorldEntity`, no bucket. It neither added nor +removed a publication. + +**Residual, flagged not dismissed:** it deleted the per-tick forge of +`Contact|OnWalkable` and the per-tick velocity zero and made gravity persistent, +so a freshly created contact-free remote now **settles and moves during the +pre-Position window** where it was previously pinned motionless. It also moved +`Airborne` derivation inside `if (resolveResult.Ok && candidateMoved)` +(`RuntimeRemotePhysicsUpdater.cs:536-537`, `:683`) and added a new `LeaveGround` +re-entrancy early-exit (`:634-642`). These can plausibly **widen** a pre-existing +window without creating it. NOT ESTABLISHED. + +### 7.2 `7f1c1f5a` — NOT exonerated + +The prior review's specific warning ("losing the rebucket leaves a remote without +a draw bucket") is **refuted**: the prologue rebucket at `:1768` runs for every +arm, before routing, and its diff hunk in `RuntimeEntityObjectLifetime.cs` is +comment-only. `TryAdoptWireCellAfterRouting` suppresses only `remote.CellId` on +the `FarSnapPlacement` arm, which the 46.4 m failing entity did not take at +observation time. + +But §5 is a real, different mechanism that this commit introduced, and it +produces precisely the reported signature. Note the recall shape fits: the remote +was **far away before recalling**, so its pre-recall packets were ≥96 m and did +take the far arm, where a park is reachable. + +### 7.3 The honest caveat + +Intermittency makes "it worked before" weak evidence. A pre-existing race that +earlier gates never sampled remains fully consistent with everything observed — +that is exactly what H1 would be. **Do not conclude regression from the timeline +alone; conclude it from §6.2.** + +--- + +## 8. Capture plan for the next run + +### 8.1 Why new instrumentation is needed + +Verified by grep with zero hits in the owning files: + +| Capability | Existing probe? | +| --- | --- | +| Draw-bucket publication (`RebucketLiveEntity`/`CommitRebucket`) | **NONE** | +| Render residency (`IsLiveEntityProjectionResident`/`IsLiveEntityVisible`) | **NONE** | +| Radar registration | **NONE** | +| Wire create/despawn (0xF745/0xF747) | **NONE** — `ACDREAM_DUMP_OPCODES` fires only for *unhandled* opcodes (`WorldSession.cs:1988-1995`); both are handled at `:1687-1700` | +| Materialization | partial — `ACDREAM_DUMP_LIVE_SPAWNS` (`RuntimeOptions.cs:111`, `DatLiveEntityProjectionMaterializer.cs:149`) logs entry + two DROP paths, **no success line** | +| Physics state / Hidden | `[setstate]` exists (`LiveEntityNetworkUpdateController.cs:1454-1456`) but is gated on `ACDREAM_PROBE_BUILDING`, an unrelated heavy BSP flag | +| Parks | `ACDREAM_PROBE_PARK` — **exists and is exactly right for H2** | + +`ACDREAM_PROBE_ENT` is hard-wired to the local player's guid and cannot target a +remote. + +### 8.2 Required properties + +Always-on for the whole entity lifetime — the failure is rare and must not +require luck. Every line must carry the **server guid**: several render probes +key on `WorldEntity.Id`, a `LocalEntityId` from +`RuntimeEntityDirectory.FirstLocalEntityId = 1_000_000`, structurally unrelated +to `0x50000001` and not greppable by guid. + +### 8.3 Proposed probe `ACDREAM_PROBE_VISGATE=1` + +One new diagnostic owner (Code Structure Rule 5): + +1. **`[vis-state]`** — every `FinalPhysicsState` mutation: + `guid, prevState, requestedState, finalState, hiddenTransition, stateSeq, instSeq, accepted, droppedReason`. + Must fire on **dropped and queued** updates naming the reason. *Settles H1.* +2. **`[vis-present]`** — every `RefreshPresentation` (`LiveEntityRuntime.cs:3357`), + changed-values-only: + `guid, finalState, residenceVisible, isSpatiallyProjected, isSpatiallyVisible, projectionKind, fullCellId, meshRefCount, isDrawVisible, interactionVisible`. + `meshRefCount` is the H2 discriminator. +3. **`[vis-publish]`** — every `TryPublishPlace` / `TryPublishWithdrawal` + (`RuntimePlacementPresentationSink.cs:162`, `:195`): + `guid, kind(place|withdraw), worldState, worldEvents, effectPoses, sinkCount`. + *Settles H2 directly.* +4. **`[vis-residence]`** — initial-create residence lifecycle including every + non-publishing executor arm (`RuntimeInitialCreateContinuationExecutor.cs:1015`, + `:1022`, `:1028`, `:1045`, `:1048`, `:1108`). Measures the 1.3 s window. +5. **`[vis-create]`** — accepted `CreateObject`/`DeleteObject`: + `guid, opcode, instSeq, wireState, wireCell`. + +Also **re-gate the existing `[setstate]` line** off `ACDREAM_PROBE_BUILDING` onto +the new flag so it is usable without the BSP dump. + +### 8.4 Protocol + +1. **Release** build, `ACDREAM_PROBE_PARK=1 ACDREAM_PROBE_VISGATE=1` + `ACDREAM_PROBE_REMOTE_SLIDE=` (that family takes a per-guid allow-list, + `PhysicsDiagnostics.cs:397-404`). +2. Two clients; recall in/out until one fails. +3. **On failure, before anything else: walk into the invisible player** (§6.1). +4. Grep the failing guid. Verdict is mechanical: + - `[vis-state] finalState` retains `0x4000` → **H1**; `droppedReason` + + `[vis-residence]` name the drop site. + - `[park]` present and `[vis-publish] kind=withdraw` with no later `place`, + and `[vis-present] meshRefCount=0` → **H2**; `7f1c1f5a` implicated. + - Neither → both refuted; re-scope to the render candidate stream. + +--- + +## 9. Proposed fix + +**Needs §6 first.** The failing stage is pinned (§2, §3); which of the two +mechanisms fires is not, and they need different fixes. Guessing between them is +what the workflow forbids. + +Shape, per branch: + +- **If H1:** fix the lost state update at its drop site. If the residence queue + loses it, the non-`Released` executor arms must replay the queued state + continuation or refuse the residence — adjacent to open **#310**, which shares + the "executor arm that never completes" shape. If the edge-trigger is at fault, + fix the caller supplying `previousState`; **do not change + `RetailPhysicsStateTransitions.Apply`** — it is a faithful port of + `CPhysicsObj::set_state` @0x00514DD0 / `set_hidden` @0x00514C60. +- **If H2:** the asymmetry between `TryPublishWithdrawal` and `TryPublishPlace` + is the bug. Either the park's `Withdraw` must not tear down presentation state + the restore cannot rebuild, or `RestoreParkWithdrawal` must route its + `SetFullCell` through `CommitCanonicalCell` so the existing `CellCommitted` → + `RebucketLiveEntity` recovery (`LiveEntityRuntime.cs:3316`) fires. Blast radius: + Runtime placement + the App presentation sink. Rollback for the introducing + commit is `git revert 7f1c1f5aa6cd7842726d2edd909564d620eb587f`, but that also + reverts C4 route 4b-2 wholesale — prefer the targeted fix. + +**Forbidden either way** (CLAUDE.md, and the digests' DO-NOT-RETRY tables): no +"re-publish if invisible for N ms" guard, no periodic re-assert of +`FinalPhysicsState`, no retry loop, no timer. "Never recovers" is a *symptom* of +the lost update, not a defect to paper over. + +A **register row** (`docs/architecture/retail-divergence-register.md`) and a new +`docs/ISSUES.md` entry are required. This is **not** covered by #309 — §2.1 rules +out its canonical-residency mechanism. + +--- + +## 10. NOT ESTABLISHED + +1. **Whether the ordinary remote DR loop filters `Hidden`.** The tick lines are + confirmed to come from the ordinary `Tick` path (§2.2), but the loop that + drives it was not located. If it skips `Hidden` entities, **H1 is refuted + outright** and H2 is the sole hypothesis. **Highest-value check; five minutes + of source reading; do it before launching a client.** +2. **Whether `Hidden` is set at all.** H1 is an elimination argument, not an + observation. Settled by §6.1. +3. **Whether a park occurred for this guid.** Settled by §6.2. +4. **Whether `_effectPoses.Remove` clears `entity.MeshRefs`**, i.e. whether H2 + can kill render permanently. +5. **Whether ACE sends Hidden→un-Hidden across a recall for a remote observer.** + Not checked against `references/ACE/` (absent from this worktree; present in + the parent repo). One grep before the run. +6. **Whether `204d0ae0` widened the window** (§7.1). +7. **Whether `LiveRenderProjectionJournal` + (`src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs:141`) drives a + shipping draw call** or is a shadow scene. + +### Incidental findings (not the bug, worth filing) + +- **Stale comments:** `src/AcDream.App/World/LiveEntityRuntime.cs:1195` and + `src/AcDream.App/World/RuntimePlacementPresentationSink.cs:74` both assert + `PublishExecutorCompletion` has zero production callers. + `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs:1092` + **is** one. Pre-C3c-flip and actively misleading to this investigation. +- **`TryAdoptWireCellAfterRouting`'s justification is incomplete.** Its comment + (`LiveEntityNetworkUpdateController.cs:1160-1185`) argues the suppressed write + "would have written the value that is already there". That holds for + `Refused`/`Contention`/`RejectedPreparation`/`NotApplicable`, but **not** after + a park, because `WithdrawCanonical` zeroes `record.FullCellId` + (`RuntimeSetPositionState.cs:5082`) and `CommitCanonicalCell` early-returns only + on equality (`RuntimePhysicsState.cs:2145-2146`). +- **`RuntimeRemotePhysicsUpdater`'s `acknowledgeProjection` seam is inert** — + every production call site passes `null`. + +--- + +## 11. DO-NOT-RETRY compliance + +Checked against `claude-memory/project_render_pipeline_digest.md` and +`claude-memory/project_physics_collision_digest.md`: + +- No symptom-site guard, retry, timer, or settle period is proposed. +- The #168 `_pendingByLandblock` / `RelocateEntity` family + (`feedback_streaming_residence_race`) is **positively excluded** by §2.1 — the + recovery path exists and provably ran every packet. +- `feedback_no_placeholder_racing_real_data` (#192) honoured: the proposal fixes + the lost update / asymmetric teardown, not a correlated lifecycle proxy. +- No change proposed to AP-87's threshold, `InterpolationManager`'s + `node_fail_counter` snap-to-tail, or the `calc_friction`/jump chains. +- `feedback_probe_identity_attribution`: every proposed probe line carries the + server guid — which is why §8.2 rejects the `WorldEntity.Id`-keyed render probes. +- `feedback_verify_subagent_claims_against_source`: every load-bearing claim here + was read at the cited `file:line`; §2.2 records one claim withdrawn on + re-verification. diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 74030343..ae430796 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -1181,6 +1181,23 @@ internal sealed class LiveEntityNetworkUpdateController /// /// /// + /// Completion of the "already there" justification for the park case + /// (2026-08-04). The no-op argument above is a claim about the four + /// cell-resolving-nothing outcomes only, and it does NOT extend to + /// Deferred. A park runs WithdrawCanonical, which ZEROES + /// record.FullCellId; CommitCanonicalCell early-returns only + /// on equality, so nothing about "the value is already there" survives a + /// park. Deferred is nevertheless suppressed correctly, but for the + /// FIRST reason in this doc rather than the second: the park snapped the + /// body to a resolved cell that need not be the wire cell, and + /// RestoreParkWithdrawal re-commits residency from that body cell + /// (or, in this controller's shipped order, leaves in place the full cell + /// the per-UP RebucketLiveEntity above committed before routing). + /// Adopting the wire cell into RemoteMotion.CellId afterwards would + /// contradict whichever of those two the entity actually holds. + /// + /// + /// /// Returns true when the wire cell was adopted. /// /// diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 5a9752c7..40612ecb 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -1192,8 +1192,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource // sidecar yet, destination backend not loaded), and a false // return here wedges the whole ordered placement stream at the // FIFO head (RuntimePlacementProjectionSubscription's contract). - // Provably inert today: PublishExecutorCompletion has zero - // production callers. + // NOT inert: RuntimeInitialCreateContinuationExecutor's Released + // arm calls PublishExecutorCompletion in production, and + // RuntimePlacementPresentationSink binds presentation off that + // receipt (C3c) rather than ignoring it the way this method does. return true; } @@ -1205,6 +1207,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource out LiveEntityRecord? record) || record.WorldEntity is not { } entity) { + // A WithdrawalRestored receipt lands here for an entity that is + // gone, displaced by a newer incarnation, or not materialized: + // there is no prior projection left to restore, so this reports + // "nothing restored". Its ONLY caller acknowledges the receipt + // regardless rather than wedging the ordered stream - see + // RuntimePlacementPresentationSink.TryApplyWithdrawalRestoration. return false; } if (projection.Kind is RuntimePlacementProjectionKind.Place @@ -1215,6 +1223,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource // pending graphical bucket. Keep the FIFO head unacknowledged // until the destination backend exists, otherwise GpuWorldState's // later pending-drain edge escapes this exact receipt transaction. + // + // WithdrawalRestored deliberately does NOT take this gate: it + // re-installs a bucket the matching Withdraw removed rather than + // creating a new one, the legacy per-packet rebucket already + // permits a pending bucket for this same entity, and holding the + // FIFO head behind a streaming edge would wedge every later + // receipt for every entity. return false; } @@ -1224,21 +1239,40 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource TryApplyRuntimePlacementPlace(in projection, record, entity), RuntimePlacementProjectionKind.Withdraw => TryApplyRuntimePlacementWithdrawal(token, record, entity), + RuntimePlacementProjectionKind.WithdrawalRestored => + TryApplyRuntimePlacementPlace( + in projection, + record, + entity, + commitPose: false), _ => false, }; } + /// + /// False for a + /// receipt. That receipt is the EXACT inverse of + /// , which removed the + /// graphical projection without touching the sidecar's world pose - so the + /// rollback re-installs the projection without touching it either. The + /// entity's pose stays owned by the per-frame remote publication path that + /// kept running throughout the park. + /// private bool TryApplyRuntimePlacementPlace( in RuntimePlacementProjectionSnapshot projection, LiveEntityRecord record, - WorldEntity entity) + WorldEntity entity, + bool commitPose = true) { RuntimePlacementProjectionToken token = projection.Token; RuntimeEntityKey key = token.Entity; ulong projectionOperation = ++record.ProjectionMutationVersion; - entity.SetPosition(projection.WorldPosition); - entity.Rotation = projection.Orientation; + if (commitPose) + { + entity.SetPosition(projection.WorldPosition); + entity.Rotation = projection.Orientation; + } // #282: live entities carry ParentCellId only; EffectCellId is the // outdoor dat stab / building-shell field (LandblockLoader:80,97). entity.ParentCellId = token.ExactCellId; diff --git a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs index b26e43fd..7e7ebeaf 100644 --- a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs +++ b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs @@ -71,10 +71,18 @@ internal sealed class RuntimePlacementPresentationSink // C3c: the initial-Create completion receipt is the graphical // binding point for a residence-driven placement (the F1 // acknowledge-and-ignore behavior applied only while - // PublishExecutorCompletion had zero production callers). + // PublishExecutorCompletion had zero production callers; + // RuntimeInitialCreateContinuationExecutor's Released arm is one + // today). return TryApplyInitialCreateCompletion(in projection); } + if (projection.Kind + is RuntimePlacementProjectionKind.WithdrawalRestored) + { + return TryApplyWithdrawalRestoration(in projection); + } + if (projection.Kind is RuntimePlacementProjectionKind.Place or RuntimePlacementProjectionKind.Withdraw && _liveEntities.HasActiveInitialCreateResidence( @@ -159,6 +167,46 @@ internal sealed class RuntimePlacementPresentationSink return TryPublishPlace(record, entity); } + /// + /// Rolls back a this + /// sink already applied, after Runtime restored the canonical half of the + /// park it belonged to. Every registration + /// dropped is re-installed by its exact + /// mirror image - the graphical bucket and + /// projection visibility through + /// LiveEntityRuntime.TryApplyRuntimePlacementProjection, then plugin + /// world state, the world-event stream, the effect-pose registry, the + /// local-player shadow, and the presentation visibility sinks here. + /// + /// Always acknowledges. Like Discard and ExecutorCompleted + /// this receipt is not Operation-backed, and its caller + /// (RuntimePlacementProjectionSubscription) treats a false return as + /// "leave at the FIFO head" - which would wedge the entire ordered stream + /// for every entity. Every way the restore below can decline is an + /// entity that is gone, displaced by a newer incarnation, or not + /// materialized, i.e. one with no prior projection left to restore; the + /// replacement projects itself through its own receipts. + /// + /// Selection is deliberately not re-established. The + /// withdrawal's _clearSelectionForUnavailableEntity is a + /// user-intent mutation, not a projection registration; re-selecting an + /// object on the player's behalf would invent input. Recorded as AD-63 in + /// the divergence register. + /// + private bool TryApplyWithdrawalRestoration( + in RuntimePlacementProjectionSnapshot projection) + { + if (_liveEntities.TryApplyRuntimePlacementProjection(in projection) + && _liveEntities.TryGetRecord( + projection.Token.Entity, + out LiveEntityRecord record) + && record.WorldEntity is { } entity) + { + _ = TryPublishPlace(record, entity); + } + return true; + } + private bool TryPublishPlace(LiveEntityRecord record, WorldEntity entity) { if (!IsCurrent(record, entity)) diff --git a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs index e52dd6ab..b4f4777c 100644 --- a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs +++ b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs @@ -46,6 +46,18 @@ internal sealed class HeadlessRuntimePlacementProjectionSink return true; } + if (projection.Kind + is RuntimePlacementProjectionKind.WithdrawalRestored) + { + // Acknowledge-and-ignore for the same reason: the receipt rolls + // back the PRESENTATION half of a cancelled park's withdrawal, and + // a headless host has no graphical sidecar, plugin world state, + // effect poses, or visibility sinks to restore - Runtime already + // restored every canonical fact before publishing it. Refusing it + // would wedge the whole ordered stream. + return true; + } + RuntimePlacementProjectionToken token = projection.Token; RuntimeEntityDirectory directory = _runtime.EntityObjects.Entities; if (projection.Kind is RuntimePlacementProjectionKind.Place diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 41e796b9..461007f5 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -72,6 +72,34 @@ public enum RuntimePlacementProjectionKind /// see AcknowledgeProjection's dedicated branch. /// ExecutorCompleted, + /// + /// The receipt a restorable park published has + /// been ROLLED BACK by RestoreParkWithdrawal: canonical residency, + /// InWorld, the transient bits, and the object clock are whole + /// again at the entity's committed cell, so every presentation + /// registration that tore down must be re-installed + /// exactly as it stood. + /// + /// This exists because RestoreParkWithdrawal can only reach + /// CANONICAL state. The presentation half of a withdrawal (the graphical + /// bucket, the projection-visibility sinks, plugin world state/events, the + /// effect-pose registry, the local-player shadow) lives behind the host + /// sink, and its only mirror image is that sink's Place publication. Before + /// this receipt existed the rollback depended on a LATER + /// that a remote which stops moving never receives - ACE stops broadcasting + /// Positions for a stationary entity - leaving it simulated, collidable and + /// audible but invisible in the world and absent from the radar for the + /// rest of the session. + /// + /// Acknowledge-only in Runtime, exactly like + /// and : it is published after the parked + /// operation has already been cancelled and retired, so there is no + /// operation to resume or commit against - see AcknowledgeProjection's + /// dedicated branch. A host must NEVER refuse it: a false return leaves it + /// at the FIFO head and wedges the whole ordered stream, which is strictly + /// worse than the invisibility it repairs. + /// + WithdrawalRestored, } public readonly record struct RuntimePortalPlacementAuthority( @@ -1163,6 +1191,81 @@ internal sealed class RuntimeSetPositionState : IDisposable return token; } + /// + /// Publishes the exact inverse of the + /// withdrawal receipt whose CANONICAL half + /// just rolled back, on the SAME + /// ordered stream every Place/Withdraw/Discard receipt uses. + /// + /// Why a receipt rather than a wider rollback. + /// 's withdrawal has two halves. The canonical + /// half (InWorld, transient bits, object clock, residency, spatial + /// root) is Runtime-owned and restores + /// it directly. The PRESENTATION half was performed by the host sink that + /// consumed the Withdraw receipt - the graphical bucket, the projection + /// visibility sinks, plugin world state and events, the effect-pose + /// registry, the local-player shadow - and Runtime cannot and must not + /// reach any of it. Its one existing mirror image is the sink's own Place + /// publication, so the rollback names it the same way the teardown was + /// named: with an ordered receipt. + /// + /// Why not simply route the restore's SetFullCell through + /// CommitCanonicalCell so the graphical CellCommitted -> + /// RebucketLiveEntity recovery fires: measured, that recovery + /// restores the bucket, IsSpatiallyProjected, + /// IsSpatiallyVisible and the projection-visibility observers, but + /// it never touches the plugin world state, the world-event stream, or the + /// effect-pose registry - only the sink's Place publication does. It also + /// cannot fire at all in the shipped remote path, where the per-packet + /// prologue rebucket has already recommitted a NON-ZERO + /// record.FullCellId before the merge cancels the park, so the + /// restore's residency arm is skipped and there is no cell edge to + /// commit. + /// + /// NOT Operation-backed: the parked operation was removed and + /// retired by CancelCoreDeferred before this runs, so the token is + /// assembled from the canonical record's current facts exactly the way + /// assembles one, and + /// consumes it through the same + /// acknowledge-only branch. + /// + /// Ordering is the stream's, not ours: the cancelled park's own + /// Discard still sits at a LOWER sequence when this publishes, so the + /// synchronous dispatch below is a no-op and the host's per-frame + /// RetryPending pump delivers this receipt immediately after that + /// Discard drains. If the same packet then commits or re-parks the entity, + /// its Place/Withdraw lands at a HIGHER sequence and supersedes this + /// restoration in canonical order. + /// + private void PublishWithdrawalRestoration(RuntimeEntityRecord record) + { + if (record.Key is not { } key) + return; + PhysicsBody? body = record.PhysicsBody; + ulong sequence = checked(++_nextProjectionSequence); + var token = new RuntimePlacementProjectionToken( + sequence, + Revision: 1UL, + key, + record.PositionAuthorityVersion, + record.SpatialAuthorityVersion, + record.PlacementCommitVersion, + _entities.SessionLifetimeVersion, + record.FullCellId, + _physics.ExpectedCollisionGeneration(record.FullCellId), + Portal: default); + var snapshot = new RuntimePlacementProjectionSnapshot( + token, + RuntimePlacementProjectionKind.WithdrawalRestored, + body?.Position ?? Vector3.Zero, + body?.Orientation ?? Quaternion.Identity, + body?.CellPosition.Frame.Origin ?? Vector3.Zero, + body?.InContact ?? false, + body?.OnWalkable ?? false); + _pendingProjection.Add(sequence, snapshot); + PublishPlacement(snapshot); + } + /// /// F2: binds the ONE notification fired when a Kind ExecutorCompleted /// receipt is acknowledged (mirrors @@ -3185,11 +3288,16 @@ internal sealed class RuntimeSetPositionState : IDisposable return false; } if (pending.Kind is RuntimePlacementProjectionKind.Discard - or RuntimePlacementProjectionKind.ExecutorCompleted) + or RuntimePlacementProjectionKind.ExecutorCompleted + or RuntimePlacementProjectionKind.WithdrawalRestored) { // C0-1: an ExecutorCompleted receipt is never Operation-backed // (see PublishExecutorCompletion) - there is nothing to resume or - // commit against, exactly like Discard. + // commit against, exactly like Discard. A WithdrawalRestored + // receipt is published from RestoreParkWithdrawal, AFTER + // CancelCoreDeferred already removed and retired the parked + // operation (see PublishWithdrawalRestoration), so it is never + // Operation-backed either. _pendingProjection.Remove(token.Sequence); RetireQuiescenceProjectionSequence(token.Sequence); if (pending.Kind is RuntimePlacementProjectionKind.ExecutorCompleted) @@ -3450,6 +3558,20 @@ internal sealed class RuntimeSetPositionState : IDisposable /// cancelled park cannot leave the entity invisible and intangible with /// nothing able to wake it. /// + /// Presentation is rolled back too, through + /// . The canonical half + /// below is only one half of what 's Withdraw + /// receipt removed; the host sink that consumed that receipt also dropped + /// the graphical bucket, the projection-visibility sinks, plugin world + /// state/events, the effect-pose registry and the local-player shadow, and + /// nothing but that sink's Place publication re-installs them. Relying on + /// a LATER Place was the defect: a remote that parks on its final Position + /// and then stops moving never receives one (ACE stops broadcasting for a + /// stationary entity), so it stayed simulated, collidable, audible - and + /// invisible in both the world and the radar for the rest of the session. + /// The restoration receipt is published exactly when the entity ends this + /// method canonically whole, so the two halves can never disagree. + /// /// Residency specifically is re-tested against the live quiescence /// map HERE as well as at park time, because this runs on a later packet /// for a retained park - see the inline comment for the window. The @@ -3542,12 +3664,27 @@ internal sealed class RuntimeSetPositionState : IDisposable _physics.AcknowledgeSpatialProjection(record, spatial: true); residencyRestored = true; } + // The presentation rollback is gated on the entity actually ENDING + // this method canonically whole, not on `residencyRestored` alone. + // Those are different facts: in the shipped graphical remote path the + // per-packet prologue rebucket (LiveEntityNetworkUpdateController -> + // LiveEntityRuntime.RebucketLiveEntity) has already recommitted a + // non-zero FullCellId and re-acknowledged the spatial root BEFORE the + // merge cancels the park, so the arm above is correctly skipped while + // the entity is nonetheless whole and must be shown again. The + // converse - a quiescing prefix refusing residency (AP-136) - leaves + // FullCellId at zero, and the entity stays presentation-withdrawn to + // match, exactly as it stays canonically withdrawn. + bool canonicallyWhole = record.FullCellId != 0u + && record.PhysicsBody is { InWorld: true }; + if (canonicallyWhole) + PublishWithdrawalRestoration(record); // Issue #309's connected-gate confirmation signal; see the [park] // line's own comment in ParkDeferred. if (PhysicsDiagnostics.ProbeParkEnabled) { Console.WriteLine(FormattableString.Invariant( - $"[park-restore] guid=0x{record.ServerGuid:X8} restoreCell=0x{residentCellId:X8} inWorld={withdrawal.InWorld} residency={residencyRestored}")); + $"[park-restore] guid=0x{record.ServerGuid:X8} restoreCell=0x{residentCellId:X8} inWorld={withdrawal.InWorld} residency={residencyRestored} presentation={canonicallyWhole}")); } } diff --git a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs index 7398e43d..f956059d 100644 --- a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs +++ b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs @@ -121,6 +121,168 @@ public sealed class RuntimePlacementPresentationSinkTests Assert.Equal(Guid, Assert.Single(fixture.ClearedSelection)); } + /// + /// The invisible-recalled-remote regression, at the layer that produced + /// it. A restorable park publishes a Withdraw whose PRESENTATION + /// half only this sink performs; RestoreParkWithdrawal restores + /// canonical state and cannot reach any of it, and the per-packet + /// RebucketLiveEntity that used to mask the hole never runs for a + /// remote which parks on its final Position and then stops moving (ACE + /// stops broadcasting for a stationary entity). The entity stayed + /// simulated, collidable, and audible while absent from BOTH the world + /// render and the radar for the rest of the session. + /// + /// This test asserts on the presentation side specifically - + /// bucket residency, projection visibility, plugin world state, the + /// world-event replay set, the effect-pose registry, the local-player + /// shadow, and the visibility sinks. Re-checking InWorld, the + /// object clock, and residency (which the shipped Runtime tests already + /// cover) reproduces exactly the blind spot that shipped the bug. + /// + [Fact] + public void WithdrawalRestored_ReinstatesEveryPresentationRegistrationTheWithdrawalRemoved() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + WorldEntity entity = Assert.IsType(record.WorldEntity); + + AcDream.Plugin.Abstractions.WorldEntitySnapshot expectedSnapshot = + Assert.Single(fixture.WorldState.Entities); + LocalPlayerShadowState.Snapshot? expectedShadow = + fixture.LocalShadow.Current; + Assert.NotNull(expectedShadow); + Assert.Equal( + expectedSnapshot, + Assert.Single(CurrentEventMembership(fixture.WorldEvents))); + Assert.Equal(1, fixture.EffectPoses.Count); + Vector3 posedPosition = entity.Position; + Quaternion posedRotation = entity.Rotation; + + RuntimePlacementProjectionSnapshot withdraw = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Withdraw, + posedPosition, + posedRotation); + Assert.True(fixture.Sink.TryApply(in withdraw)); + Assert.Empty(fixture.WorldState.Entities); + Assert.Empty(CurrentEventMembership(fixture.WorldEvents)); + Assert.Equal(0, fixture.EffectPoses.Count); + + RuntimeOwnershipSnapshot beforeRestore = + RuntimeOwnershipSnapshot.Capture(fixture.Runtime, record); + RuntimePlacementProjectionSnapshot restored = Placement( + fixture, + record, + RuntimePlacementProjectionKind.WithdrawalRestored, + // Deliberately NOT the entity's pose: the receipt is the exact + // inverse of a withdrawal, which never moved the sidecar, so the + // restoration must not move it either. + new Vector3(-777f, -777f, -777f), + Quaternion.CreateFromAxisAngle(Vector3.UnitY, 1.25f)); + + Assert.True(fixture.Sink.TryApply(in restored)); + + // Graphical projection - what the world render and the radar read. + Assert.True( + record.IsSpatiallyProjected, + "restored park left the entity unprojected"); + Assert.True( + record.IsSpatiallyVisible, + "restored park left the entity invisible"); + Assert.Contains(record, fixture.Runtime.VisibleRecords); + Assert.True( + fixture.Spatial.IsLiveEntityProjectionResident( + record.ProjectionKey!.Value), + "restored park left the entity out of its draw bucket"); + Assert.Equal(SourceCell, entity.ParentCellId); + + // Sink-owned registrations - restored EXACTLY, not defaulted. + Assert.Equal( + expectedSnapshot, + Assert.Single(fixture.WorldState.Entities)); + Assert.Equal( + expectedSnapshot, + Assert.Single(CurrentEventMembership(fixture.WorldEvents))); + Assert.Equal(1, fixture.EffectPoses.Count); + Assert.True(fixture.EffectPoses.TryGetRootPose( + entity.Id, + out Matrix4x4 restoredPose)); + Assert.Equal( + Matrix4x4.CreateFromQuaternion(posedRotation) + * Matrix4x4.CreateTranslation(posedPosition), + restoredPose); + Assert.Equal(expectedShadow, fixture.LocalShadow.Current); + Assert.Equal( + [(record, false), (record, true)], + fixture.Visibility); + + // The sidecar pose is untouched by the restoration. + Assert.Equal(posedPosition, entity.Position); + Assert.Equal(posedRotation, entity.Rotation); + + // No canonical Runtime ownership moved. + Assert.Equal( + beforeRestore, + RuntimeOwnershipSnapshot.Capture(fixture.Runtime, record)); + + // Idempotent: re-applying the same immutable receipt (the documented + // IRuntimePlacementProjectionSink contract) changes nothing. + Assert.True(fixture.Sink.TryApply(in restored)); + Assert.Equal( + expectedSnapshot, + Assert.Single(fixture.WorldState.Entities)); + Assert.Equal(1, fixture.EffectPoses.Count); + Assert.True(record.IsSpatiallyVisible); + } + + /// + /// A WithdrawalRestored receipt must NEVER be refused. Its caller + /// (RuntimePlacementProjectionSubscription) treats a false return + /// as "leave at the FIFO head", which would wedge the ordered stream for + /// every entity - strictly worse than the invisibility the receipt + /// repairs. Every way the restore can decline is an entity that is gone, + /// displaced, or unmaterialized, i.e. one with no prior projection left. + /// + [Fact] + public void WithdrawalRestored_IsAcknowledgedEvenWhenTheProjectionIsGone() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + RuntimePlacementProjectionSnapshot restored = Placement( + fixture, + record, + RuntimePlacementProjectionKind.WithdrawalRestored, + Vector3.Zero, + Quaternion.Identity) with + { + Token = Placement( + fixture, + record, + RuntimePlacementProjectionKind.WithdrawalRestored, + Vector3.Zero, + Quaternion.Identity).Token with + { + SessionLifetimeVersion = + fixture.Runtime.SessionLifetimeVersion + 99UL, + }, + }; + + Assert.True(fixture.Sink.TryApply(in restored)); + Assert.Empty(fixture.Visibility); + } + + private static List + CurrentEventMembership(WorldEvents events) + { + var replayed = new List(); + void Handler(AcDream.Plugin.Abstractions.WorldEntitySnapshot snapshot) => + replayed.Add(snapshot); + events.EntitySpawned += Handler; + events.EntitySpawned -= Handler; + return replayed; + } + [Fact] public void Discard_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone() { diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index 0fba612b..99f8c789 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -1571,6 +1571,131 @@ public sealed class RuntimeSetPositionStateTests /// has to live at the shared CancelCore layer or the identical /// stranded-entity hole stays open on exactly the routes that produce it. /// + /// + /// The canonical rollback above is only HALF of what the park's Withdraw + /// receipt removed. Its presentation half - the graphical bucket, the + /// projection-visibility sinks, plugin world state/events, the effect-pose + /// registry, the local-player shadow - was performed by the host sink that + /// consumed that receipt, and RestoreParkWithdrawal cannot reach any + /// of it. Before this receipt existed the only thing that re-installed it + /// was a LATER Place, which a remote that parks on its final + /// Position and then stops moving never receives: ACE stops broadcasting + /// for a stationary entity. The observable result was a fully simulated, + /// collidable, audible player that was absent from both the world render + /// and the radar for the rest of the session. + /// + /// So the cancel must publish the inverse receipt itself, on the one + /// ordered stream, and it must be acknowledge-only: the parked operation + /// is already removed and retired by CancelCoreDeferred when this + /// publishes. + /// + [Fact] + public void CancellingWakeableParkPublishesTheWithdrawalRestorationReceipt() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000411Du, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + var placements = new PlacementObserver(); + using IDisposable subscription = + lifetime.Events.SubscribePlacement(placements); + + RuntimeSetPositionOutcome parked = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, parked.Status); + Assert.Equal( + RuntimePlacementProjectionKind.Withdraw, + Assert.Single(placements.Deltas).Placement.Kind); + + _ = lifetime.Physics.SetPosition.Forget( + record, + restoreCancelledPark: true); + + Assert.True(body.InWorld); + Assert.NotEqual(0u, record.FullCellId); + + // Two receipts are pending, in canonical order: the cancelled park's + // own Discard at the lower sequence, then the restoration. + Assert.Equal(2, lifetime.Physics.SetPosition.PendingProjectionCount); + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)); + Assert.Equal(RuntimePlacementProjectionKind.Discard, head.Kind); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + head.Token)); + + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restoration)); + Assert.Equal( + RuntimePlacementProjectionKind.WithdrawalRestored, + restoration.Kind); + Assert.Equal(record.Key, restoration.Token.Entity); + Assert.Equal(record.FullCellId, restoration.Token.ExactCellId); + Assert.Equal(body.Position, restoration.WorldPosition); + + // The host's per-frame RetryPending pump is what delivers it, exactly + // as it delivers any receipt that was not the head when published. + placements.Deltas.Clear(); + lifetime.Physics.SetPosition.RetryPendingProjections(); + Assert.Equal( + restoration, + Assert.Single(placements.Deltas).Placement); + + // Acknowledge-only: no operation backs it, and the stream drains. + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + restoration.Token)); + Assert.Equal( + 0, + lifetime.Physics.SetPosition.PendingProjectionCount); + } + + /// + /// AP-136's refusal arm stays exact: when the committed cell's prefix is + /// retiring, the rollback declines residency, and presentation must stay + /// withdrawn to match rather than showing an entity Runtime deliberately + /// left out of the world. + /// + [Fact] + public void CancellingParkIntoQuiescingPrefixPublishesNoRestorationReceipt() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000411Eu, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + + RuntimeSetPositionOutcome parked = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, parked.Status); + // Round 4 (D6)'s window: the prefix was clean when the park was taken + // (so the withdrawal WAS captured) and starts retiring before the + // cancel lands, which is only reachable because a retained park's + // restore runs on a later packet. + uint parkedCell = body.CellPosition.ObjCellId; + _ = lifetime.Physics.BeginCollisionPrefixQuiescence( + (parkedCell & 0xFFFF0000u) | 0xFFFFu, + collisionGeneration: 1UL, + includeOutdoorCells: true); + int pendingBefore = + lifetime.Physics.SetPosition.PendingProjectionCount; + + _ = lifetime.Physics.SetPosition.Forget( + record, + restoreCancelledPark: true); + + Assert.Equal(0u, record.FullCellId); + // Only the cancelled park's Discard rewrote an existing entry; no new + // restoration receipt was appended. + Assert.Equal( + pendingBefore, + lifetime.Physics.SetPosition.PendingProjectionCount); + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)); + Assert.Equal(RuntimePlacementProjectionKind.Discard, head.Kind); + } + [Fact] public void CancellingWakeableParkByExactTokenAlsoRestoresTheEntity() {