fix(physics): restore presentation when a park is cancelled (#312)

Regression from 7f1c1f5a (C4 route 4b-2). A remote player who recalled in,
arrived, and stood still was permanently absent from the world render AND the
radar while remaining fully simulated — 71 healthy physics ticks with contact
and walkable, interpolation enqueues, equipment attached, chat visible.

Route 4b-2 is the first commit that lets an ordinary remote UpdatePosition open
a canonical SetPosition. A park publishes a synchronous Withdraw that tears down
presentation registrations; only TryPublishPlace restores them.
RestoreParkWithdrawal — added in the same slice — restores InWorld, the object
clock, and canonical residency, i.e. the Runtime half only. Eight Opus reviews
verified those three fields and the tests asserted exactly them, so the suite
stayed green while the entity was invisible.

Why it is intermittent: the presentation half IS restored incidentally by the
per-packet prologue rebucket for a MOVING remote. It only sticks when the
entity parks on its FINAL accepted Position and then goes idle, because ACE
stops broadcasting for a stationary entity, so no later packet arrives to
re-publish it and nothing else re-drives.

The fix publishes a RuntimePlacementProjectionKind.WithdrawalRestored receipt on
the one ordered placement stream, acknowledge-only in Runtime (the parked
operation is already retired by CancelCoreDeferred), which the App sink maps to
the exact inverse of its own TryPublishWithdrawal: the projection half (bucket,
IsSpatiallyProjected, IsSpatiallyVisible, spatial indexes, RefreshPresentation)
plus the publish half (_worldState, _worldEvents, _effectPoses,
_localPlayerShadow, visibility sinks). Applied with commitPose: false, because
the withdrawal never moved the sidecar; a test feeds a deliberately wrong
position to pin that.

Two alternatives were refuted on measurement, not preference. Routing the
restore's SetFullCell through CommitCanonicalCell cannot fire on the shipped
remote path at all — the prologue rebucket has already recommitted a non-zero
FullCellId before the merge cancels the park, so no cell edge remains — and it
never touches the publish half regardless. Extending RestoreParkWithdrawal
directly reduces to the same receipt, since Runtime must not reach behind the
host sink.

Gated on the entity ending the rollback canonically whole (FullCellId != 0 &&
InWorld) rather than on residencyRestored, which is false on the shipped remote
path and would have made the fix a no-op. AP-136's quiescing-prefix refusal arm
is preserved: no receipt, entity stays withdrawn.

Corrects my own framing of the defect: _worldState/_worldEvents/_effectPoses are
lost but are NOT what kills render and radar (_worldState is the plugin
IGameState; _effectPoses is the pose registry, not entity.MeshRefs). The
load-bearing casualties are the visibility sinks and the
IsSpatiallyProjected/IsSpatiallyVisible + bucket removal that gates the radar.

Register: AD-63 filed (selection deliberately not restored — user intent),
AP-136 amended (its "restored visible" claim covered only the canonical half;
the gap was a defect, not a divergence). ShadowObjectRegistry.Suspend stays
out of scope per AP-136.

Seven-revert discrimination table including one that proves the test is not
merely re-checking the bucket. Suite 11,023 passed / 4 skipped / 0 failed.

Live gate is user-run and folds into #309: two clients, ACDREAM_PROBE_PARK=1,
recall a remote in and let it stand still; acceptance is
[park-restore] ... presentation=True for that guid plus a visible model and a
radar blip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-04 11:21:45 +02:00
parent 204d0ae047
commit b1f914d508
10 changed files with 1086 additions and 12 deletions

View file

@ -228,6 +228,68 @@ task scope held `RuntimeSetPositionState.cs` off-limits for that session
**Acceptance:** A headless tick with N&gt;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).

File diff suppressed because one or more lines are too long

View file

@ -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 14592617**
(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=<guid>` (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.

View file

@ -1181,6 +1181,23 @@ internal sealed class LiveEntityNetworkUpdateController
/// </para>
///
/// <para>
/// <b>Completion of the "already there" justification for the park case
/// (2026-08-04).</b> The no-op argument above is a claim about the four
/// cell-resolving-nothing outcomes only, and it does NOT extend to
/// <c>Deferred</c>. A park runs <c>WithdrawCanonical</c>, which ZEROES
/// <c>record.FullCellId</c>; <c>CommitCanonicalCell</c> early-returns only
/// on equality, so nothing about "the value is already there" survives a
/// park. <c>Deferred</c> 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
/// <c>RestoreParkWithdrawal</c> re-commits residency from that body cell
/// (or, in this controller's shipped order, leaves in place the full cell
/// the per-UP <c>RebucketLiveEntity</c> above committed before routing).
/// Adopting the wire cell into <c>RemoteMotion.CellId</c> afterwards would
/// contradict whichever of those two the entity actually holds.
/// </para>
///
/// <para>
/// Returns true when the wire cell was adopted.
/// </para>
/// </summary>

View file

@ -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,
};
}
/// <param name="commitPose">
/// False for a <see cref="RuntimePlacementProjectionKind.WithdrawalRestored"/>
/// receipt. That receipt is the EXACT inverse of
/// <see cref="TryApplyRuntimePlacementWithdrawal"/>, 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.
/// </param>
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;

View file

@ -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);
}
/// <summary>
/// Rolls back a <see cref="RuntimePlacementProjectionKind.Withdraw"/> this
/// sink already applied, after Runtime restored the canonical half of the
/// park it belonged to. Every registration
/// <see cref="TryPublishWithdrawal"/> dropped is re-installed by its exact
/// mirror image <see cref="TryPublishPlace"/> - the graphical bucket and
/// projection visibility through
/// <c>LiveEntityRuntime.TryApplyRuntimePlacementProjection</c>, then plugin
/// world state, the world-event stream, the effect-pose registry, the
/// local-player shadow, and the presentation visibility sinks here.
///
/// <para><b>Always acknowledges.</b> Like Discard and ExecutorCompleted
/// this receipt is not Operation-backed, and its caller
/// (<c>RuntimePlacementProjectionSubscription</c>) 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.</para>
///
/// <para><b>Selection is deliberately not re-established.</b> The
/// withdrawal's <c>_clearSelectionForUnavailableEntity</c> 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.</para>
/// </summary>
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))

View file

@ -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

View file

@ -72,6 +72,34 @@ public enum RuntimePlacementProjectionKind
/// see AcknowledgeProjection's dedicated branch.
/// </summary>
ExecutorCompleted,
/// <summary>
/// The <see cref="Withdraw"/> receipt a restorable park published has
/// been ROLLED BACK by <c>RestoreParkWithdrawal</c>: canonical residency,
/// <c>InWorld</c>, the transient bits, and the object clock are whole
/// again at the entity's committed cell, so every presentation
/// registration that <see cref="Withdraw"/> tore down must be re-installed
/// exactly as it stood.
///
/// <para>This exists because <c>RestoreParkWithdrawal</c> 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 <see cref="Place"/>
/// 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.</para>
///
/// <para>Acknowledge-only in Runtime, exactly like <see cref="Discard"/>
/// and <see cref="ExecutorCompleted"/>: 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.</para>
/// </summary>
WithdrawalRestored,
}
public readonly record struct RuntimePortalPlacementAuthority(
@ -1163,6 +1191,81 @@ internal sealed class RuntimeSetPositionState : IDisposable
return token;
}
/// <summary>
/// Publishes the exact inverse of the <see cref="ParkDeferred"/>
/// withdrawal receipt whose CANONICAL half
/// <see cref="RestoreParkWithdrawal"/> just rolled back, on the SAME
/// ordered stream every Place/Withdraw/Discard receipt uses.
///
/// <para><b>Why a receipt rather than a wider rollback.</b>
/// <see cref="ParkDeferred"/>'s withdrawal has two halves. The canonical
/// half (<c>InWorld</c>, transient bits, object clock, residency, spatial
/// root) is Runtime-owned and <see cref="RestoreParkWithdrawal"/> 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.</para>
///
/// <para><b>Why not simply route the restore's SetFullCell through
/// CommitCanonicalCell</b> so the graphical <c>CellCommitted</c> ->
/// <c>RebucketLiveEntity</c> recovery fires: measured, that recovery
/// restores the bucket, <c>IsSpatiallyProjected</c>,
/// <c>IsSpatiallyVisible</c> 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
/// <c>record.FullCellId</c> before the merge cancels the park, so the
/// restore's residency arm is skipped and there is no cell edge to
/// commit.</para>
///
/// <para>NOT Operation-backed: the parked operation was removed and
/// retired by <c>CancelCoreDeferred</c> before this runs, so the token is
/// assembled from the canonical record's current facts exactly the way
/// <see cref="PublishExecutorCompletion"/> assembles one, and
/// <see cref="AcknowledgeProjection"/> consumes it through the same
/// acknowledge-only branch.</para>
///
/// <para>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
/// <c>RetryPending</c> 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.</para>
/// </summary>
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);
}
/// <summary>
/// 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.
///
/// <para><b>Presentation is rolled back too, through
/// <see cref="PublishWithdrawalRestoration"/>.</b> The canonical half
/// below is only one half of what <see cref="ParkDeferred"/>'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.</para>
///
/// <para>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}"));
}
}

View file

@ -121,6 +121,168 @@ public sealed class RuntimePlacementPresentationSinkTests
Assert.Equal(Guid, Assert.Single(fixture.ClearedSelection));
}
/// <summary>
/// The invisible-recalled-remote regression, at the layer that produced
/// it. A restorable park publishes a <c>Withdraw</c> whose PRESENTATION
/// half only this sink performs; <c>RestoreParkWithdrawal</c> restores
/// canonical state and cannot reach any of it, and the per-packet
/// <c>RebucketLiveEntity</c> 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.
///
/// <para>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 <c>InWorld</c>, the
/// object clock, and residency (which the shipped Runtime tests already
/// cover) reproduces exactly the blind spot that shipped the bug.</para>
/// </summary>
[Fact]
public void WithdrawalRestored_ReinstatesEveryPresentationRegistrationTheWithdrawalRemoved()
{
Fixture fixture = Fixture.Create();
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
WorldEntity entity = Assert.IsType<WorldEntity>(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);
}
/// <summary>
/// A WithdrawalRestored receipt must NEVER be refused. Its caller
/// (<c>RuntimePlacementProjectionSubscription</c>) 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.
/// </summary>
[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<AcDream.Plugin.Abstractions.WorldEntitySnapshot>
CurrentEventMembership(WorldEvents events)
{
var replayed = new List<AcDream.Plugin.Abstractions.WorldEntitySnapshot>();
void Handler(AcDream.Plugin.Abstractions.WorldEntitySnapshot snapshot) =>
replayed.Add(snapshot);
events.EntitySpawned += Handler;
events.EntitySpawned -= Handler;
return replayed;
}
[Fact]
public void Discard_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone()
{

View file

@ -1571,6 +1571,131 @@ public sealed class RuntimeSetPositionStateTests
/// has to live at the shared <c>CancelCore</c> layer or the identical
/// stranded-entity hole stays open on exactly the routes that produce it.
/// </summary>
/// <summary>
/// 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 <c>RestoreParkWithdrawal</c> cannot reach any
/// of it. Before this receipt existed the only thing that re-installed it
/// was a LATER <c>Place</c>, 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.
///
/// <para>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 <c>CancelCoreDeferred</c> when this
/// publishes.</para>
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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()
{