diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 12b2fe09..54403e6a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,155 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## C4 route 4b-1 review — park lifecycle — 2026-08-04 + +#309 and #310 filed from the route 4b-1 dual-review round; #311 filed from +the delta-review round on the same route's remediation. Evidence: +[`2026-08-04-c4-route-4b-1-review-findings.md`](research/2026-08-04-c4-route-4b-1-review-findings.md). + +## #309 — Cancelled lost-cell park re-shows the entity where retail would keep it hidden + +**Status:** OPEN +**Severity:** MEDIUM +**Filed:** 2026-08-04 +**Component:** physics / placement + +**Description:** When a `DeferredCell` park is cancelled by the accepted-Position +merge, we now roll the withdrawal back (`InWorld`, object clock, canonical +residency) so the entity is no longer stranded invisible-and-intangible. But the +entity becomes **visible immediately at the committed destination pose, without +collision**, whereas retail keeps it hidden and re-shows it only when the cell +loads. + +**Root cause / status:** Retail's lost-cell mechanism has no cancel at all. +`CPhysicsObj::SetPositionInternal` @0x00515BD0 commits the destination pose via +`store_position` @0x00515CE2 and registers the object with +`CObjectMaint::GotoLostCell` @0x00515CF2 (@0x00508210). That 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), re-placing at the pose `store_position` committed. +An update that performs no SetPosition leaves the registration untouched. + +So the retail-faithful end state is a park that **survives** cancellation. That +was implemented and reverted this round because it reverses a shipped, tested +invariant — `NewerPositionPickupAndParentEachCancelExactLostOperation` +(`tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`, helper +`VerifyPositionChannelCancellation`) asserts `Assert.False(IsDeferred(record))`, +i.e. a newer Position cancels the park — and because surviving parks broke +`GameRuntime` teardown convergence (stage 10). Re-deciding that invariant plus +teardown convergence is the blocker. + +Residual in practice: a remote at 5-10 Hz is superseded within ~150 ms. The case +that bites is a remote that teleports into a non-resident landblock and then +**stops moving**, because ACE stops broadcasting for a stationary entity. + +Also unrestored: the `ShadowObjectRegistry.Suspend` applied by +`WithdrawCanonical`. Un-suspending requires a real placement dispatch +(`ReplacePositionRows`), so the entity rejoins the collision broadphase on its +next placement rather than at cancel time. + +**Files:** `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` — +`ParkDeferred`'s `restorableOnCancel`, `Forget`, `RestoreParkWithdrawal`. + +**Acceptance:** Park survives a superseding no-placement Position and wakes via +`InitObjCell`-equivalent collision-generation arrival, with teardown converging +and the newer-Position invariant deliberately re-decided. + +**Connected check required — this is NOT a no-behaviour-change slice.** The +route 4b-1 contract's "no connected gate" line does not apply to the park fix. +`restorableOnCancel: true` is set in `SubmitPreparedPlacementCore`, the shared +core behind EVERY production placement, and the merge-time +`restoreCancelledPark: true` is on the accepted-Position path every remote and +the local player traverse. So shipped behaviour changes for any entity whose +placement parks. + +Proposed connected check (two clients, local ACE): +1. Walk the observed character to a landblock boundary so a remote sits in a + landblock the observer has not streamed, forcing a `DeferredCell` park. +2. Confirm the remote no longer vanishes permanently — the pre-fix symptom was + invisible AND intangible for the rest of the session. +3. Confirm it appears at the SERVER-authoritative destination pose, not at a + stale pre-park pose, and that it becomes collidable once the landblock + publishes. +4. Confirm the local player's own ForcePosition corrections (route 2) still + land unchanged — that path shares the same cancel. +Steps 1-3 are the user-visible acceptance for AP-136's residual. + +## #310 — Retained preparation retry stalls landblock retirement with no bound + +**Status:** OPEN +**Severity:** HIGH +**Filed:** 2026-08-04 +**Component:** physics / streaming + +**Description:** An entity holding a retained preparation retry keeps its +landblock prefix in placement debt, so +`TryAcquireCollisionPrefixMutationPermission` refuses on **every** poll and the +landblock never retires. There is no bound and no timeout. + +**Root cause / status:** `HasOldPrefixPlacementDebt` refuses permission while any +affected root holds an operation, so `LandblockRetirementStage.Physics` never +completes and the retirement coordinator simply retries forever. +`TickLostCellDeadlines` — the only expiry that could break the cycle — has **no +production caller**, so its deadline never fires. The only thing that clears the +debt is an inbound packet for that same entity, which is exactly what a +`RetrySetupUnavailable` on an asset that never loads does not produce. + +Pre-existing and independent of route 4b-1; 4b-1 does not bound it, it only +avoids widening it by declining to retain operations for destinations it cannot +service. Pinned by +`RuntimeCollisionPrefixQuiescenceTests.RetainedPreparationRetryStallsPrefixRetirementIndefinitely` +(1,000 consecutive refusals), which also shows retiring the operation is what +releases the prefix. + +Note this is also why `ParkCollisionResidents`'s overlap throw is unreachable: +permission is refused before `ParkCollisionResidents` is ever entered. + +**Files:** `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` — +`HasOldPrefixPlacementDebt`, `TryAcquireCollisionPrefixMutationPermission`, +`TickLostCellDeadlines` (uncalled). + +**Acceptance:** A retained preparation retry cannot block a landblock retirement +indefinitely — either the deadline is driven in production or the retirement can +proceed past stale placement debt. + +## #311 — RetryPendingProjections allocates a fresh array on every non-empty call + +**Status:** OPEN +**Severity:** LOW (perf, not correctness) +**Filed:** 2026-08-04 +**Component:** physics / headless + +**Description:** `RuntimeSetPositionState.RetryPendingProjections` (reached via +`RuntimePlacementProjectionChannel.RetryPending` → +`RuntimePlacementProjectionSubscription.RetryPending`) snapshots the entire +pending-projection dictionary into a fresh array on every call — +`_pendingProjection.Values.ToArray()`. C4 route 4b-1's N3 fix +(`HeadlessSessionEventRoute.RetryPending`, wired from `HeadlessSessionHost.Tick`) +now reaches this path every headless host tick instead of once per session — +new per-tick pressure K4's 30-session resource envelope was not measured with. + +**Root cause / status:** The empty-FIFO case is closed — +`RuntimePlacementProjectionSubscription.HasPendingReceipts` (added alongside +this issue) lets a host early-out before ever reaching +`RetryPendingProjections` when nothing is outstanding, which is the +overwhelming common case in steady state. The non-empty case still +allocates: every call that DOES have an outstanding receipt pays a fresh +`.ToArray()` copy. Closing it needs a non-allocating rewrite of +`RetryPendingProjections` itself (e.g. a reusable scratch buffer, mirroring +the `_driveScratch` pattern `RuntimeRemotePlacementDriveController.Advance` +and `RuntimeFirstEntryDriveController` already use) — deferred rather than +attempted in the C4 route 4b-1 delta-review session that filed this, whose +task scope held `RuntimeSetPositionState.cs` off-limits for that session +(a concurrent, separately-owned change was landing in the same file). + +**Files:** `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` — +`RetryPendingProjections`. Call site: +`src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs` — `RetryPending`. + +**Acceptance:** A headless tick with N>0 outstanding placement receipts does +not allocate a new array per tick. + ## 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 54a92dfa..cfef3311 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -159,7 +159,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 93 active rows (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) — 94 active rows (AP-136 filed 2026-08-04, C4 route 4b-1 review — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load; 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,6 +282,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 is scoped to the plain unplaceable-destination park only; quiescence/retirement parks are deliberately NOT restored, since re-admitting a spatial root into a retiring prefix blocks the retirement | A remote 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 (force a remote to park across a landblock boundary; confirm it no longer vanishes permanently, appears at the server-authoritative destination pose rather than a stale one, becomes collidable once the landblock publishes, and that route 2's local ForcePosition corrections are unchanged) | `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D/@0x00515CDA/@0x00515CE2/@0x00515CF2/@0x00515CF7/@0x00515D07); `CObjectMaint::GotoLostCell` @0x00508210; `CObjectMaint::InitObjCell` @0x00508260 (@0x00508296); `CPhysicsObj::reenter_visibility` @0x00516250 | ## 4. Temporary stopgap (TS) — 36 active rows (TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index 2b9ca433..07582f30 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -314,6 +314,23 @@ public sealed class RuntimeEntityDirectory record.SuspendObjectClock(); } + /// + /// Re-activates a clock suspended by , for + /// rolling back a withdrawal that is being cancelled rather than + /// completed. NOT an exact inverse — the retained sub-quantum time is + /// discarded, which is retail's set_active(1) rebase semantic; see + /// . Distinct from + /// , which is the + /// entering-the-world edge and additionally rebases the static/dynamic + /// quantum shape; a rollback re-activates a clock that never conceptually + /// left. + /// + public void ResumeObjectClock(RuntimeEntityRecord record) + { + EnsureKnown(record); + record.ResumeObjectClock(); + } + public void ResetObjectClockForEnterWorld(RuntimeEntityRecord record, bool isStatic) { EnsureKnown(record); diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index a3660e92..cc9d53cc 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -1706,7 +1706,17 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RuntimePlacementCancellationReceipt cancellation = default; if (acceptedPosition) { - cancellation = Physics.SetPosition.Forget(canonical); + // A CANCELLATION, not a withdrawal: a newer accepted Position + // supersedes the in-flight placement but the entity stays in the + // world. If that placement was a DeferredCell park, cancelling it + // without rolling the withdrawal back left the entity invisible + // AND intangible with nothing able to wake it - see + // RuntimeSetPositionState.Forget. Every OTHER Forget call in this + // class is a withdrawal transaction and deliberately does not opt + // in. + cancellation = Physics.SetPosition.Forget( + canonical, + restoreCancelledPark: true); } Entities.RefreshSnapshot( canonical, diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs index 1d8def85..66530f0f 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs @@ -97,6 +97,29 @@ public sealed class RuntimeEntityRecord ObjectClockEpoch++; } + /// + /// Re-activates a clock suspended by . + /// + /// Deliberately NOT an exact inverse: + /// preserves the + /// retained sub-quantum _pending time while + /// zeroes it, so resuming + /// DISCARDS whatever fraction of a quantum was outstanding at suspend. + /// That is the retail semantic, not an oversight — retail's + /// set_active(1) rebases update_time to the current timer so + /// the reactivation frame does not catch up suppressed time. + /// + /// The epoch is also asymmetric on purpose: suspend bumps + /// unconditionally, resume bumps only on a real inactive-to-active edge + /// (Activate's own return contract), so a redundant resume is not + /// observable as a clock change. + /// + internal void ResumeObjectClock() + { + if (ObjectClock.Activate()) + ObjectClockEpoch++; + } + internal void ResetObjectClockForEnterWorld(bool isStatic) { ObjectClock.ResetForEnterWorld(isStatic); diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 41d47d86..79eb7b18 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -410,6 +410,16 @@ internal sealed class RuntimeSetPositionState : IDisposable internal bool EnteringWorldFromCelllessResidence { get; set; } internal bool DormantLocalActivation { get; set; } + /// + /// The entity state overwrote, captured + /// BEFORE it snapped the body to the (unplaceable) destination and + /// ran . rolls + /// back to it when it cancels a wakeable park - see + /// for why the rollback target is + /// the PRE-PARK pose and never the destination. + /// + internal ParkWithdrawal ParkWithdrawal { get; set; } + /// /// #284: why this operation's mover preparation is currently parked, /// or when it is not. @@ -483,10 +493,22 @@ internal sealed class RuntimeSetPositionState : IDisposable DormantLocalActivation = false; ParkReason = RuntimeSetPositionParkReason.None; PreparedCommandAwaitingWithdrawalAck = null; + ParkWithdrawal = default; InPool = false; } } + /// + /// The exact set of entity state withdraws, + /// captured before it is overwritten so can roll a + /// cancelled park back instead of stranding the entity. + /// + private readonly record struct ParkWithdrawal( + bool Captured, + bool InWorld, + TransientStateFlags TransientState, + bool ClockActive); + private sealed class CollisionPrefixQuiescence { internal required RuntimeCollisionPrefixQuiescenceToken Token @@ -1288,8 +1310,15 @@ internal sealed class RuntimeSetPositionState : IDisposable ForgetPlacementCompletionCore(token); } + /// + /// — see . It + /// defaults to false so every shipped caller keeps its exact prior + /// behaviour; only a caller that is genuinely CANCELLING a placement + /// intent (rather than withdrawing the entity from the world) opts in. + /// internal RuntimePlacementCancellationReceipt ForgetExactPlacement( - in RuntimeEntityPlacementToken token) + in RuntimeEntityPlacementToken token, + bool restoreCancelledPark = false) { EnsureNotDisposed(); ForgetPlacementCompletionCore(token); @@ -1303,7 +1332,10 @@ internal sealed class RuntimeSetPositionState : IDisposable // against `_operations` immediately above with nothing reentrant in // between - passing it straight through is equivalent to (and safer // than) re-deriving it from `operation`. - return CancelCore(token.Entity, token); + return CancelCore( + token.Entity, + token, + restoreCancelledPark: restoreCancelledPark); } internal RuntimeEntityPlacementToken TryBeginExclusiveAuthoredPlacement( @@ -3016,7 +3048,10 @@ internal sealed class RuntimeSetPositionState : IDisposable _preparedMovers[operation.Key] = canonicalRequest; if (result.IsDeferred) - return ParkDeferred(operation, result); + // The one restorable park: the destination is not placeable right + // now. Every other ParkDeferred caller is a quiescence/retirement + // withdrawal - see ParkDeferred's restorableOnCancel doc. + return ParkDeferred(operation, result, restorableOnCancel: true); if (!CommitCanonical(operation, result)) { @@ -3302,22 +3337,155 @@ internal sealed class RuntimeSetPositionState : IDisposable return true; } + /// + /// Cancelling a wakeable lost-cell park ROLLS THE ENTITY BACK. Without + /// that, 's withdrawal (body.InWorld = + /// false, object clock suspended, + /// clearing canonical residency) outlived the operation that was the only + /// thing able to wake it: removes the + /// operation and restores none of that, so the entity was left invisible + /// AND intangible for the rest of the session. The producing sequence is + /// mundane - the accepted-Position merge + /// (RuntimeEntityObjectLifetime.TryApplyPosition) calls this on + /// EVERY accepted Position, so packet N parked and packet N+1 destroyed + /// the park ~150 ms later, before any collision-generation wake could + /// fire. + /// + /// This also closes the same hole on route 2's shipped + /// DeferredCell path, which was masked only by its re-issue funnel + /// - correct for a one-shot ForcePosition, absent for a repeated remote + /// stream. + /// + /// + /// distinguishes a CANCELLATION + /// from a WITHDRAWAL, and defaults to false so every shipped caller keeps + /// its exact prior behaviour. + /// + /// Only a cancellation may roll a park back. Most callers of this + /// method are withdrawal transactions - pickup, parent attach, explicit + /// withdrawal, position-channel teardown - which deliberately take the + /// entity OUT of the world and then hand-roll their own partial + /// re-withdrawal. Rolling a park back underneath one of those would set + /// body.InWorld = true and re-register the entity in + /// _spatialRoots, neither of which their cleanup undoes (it only + /// zeroes the clock and FullCellId) - leaving e.g. a picked-up item + /// simultaneously in inventory AND an InWorld cell-less spatial root + /// handed to the CPhysics::UseTime workset by + /// CopySpatialRootsTo, which filters only on key and + /// IsCurrent. + /// + /// The rollback exists because 's + /// withdrawal otherwise outlives the operation that was the only thing + /// able to wake it: restores neither + /// InWorld, the object clock, nor canonical residency, so the entity + /// was left invisible AND intangible for the rest of the session. The + /// accepted-Position merge produced that every ~150 ms - packet N parked, + /// packet N+1 destroyed the park before any collision-generation wake could + /// fire. It closes the same hole on route 2's shipped DeferredCell + /// path, which was masked only by its re-issue funnel. + /// internal RuntimePlacementCancellationReceipt Forget( RuntimeEntityRecord record, - bool releasePreparedMover = false) + bool releasePreparedMover = false, + bool restoreCancelledPark = false) { EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(record); RuntimePlacementCancellationReceipt receipt = default; if (record.Key is { } key) { + // Captured before CancelCore, which retires the operation to the + // pool and resets every field on it. + ParkWithdrawal withdrawal = + restoreCancelledPark + && _operations.TryGetValue(key, out Operation? parked) + && parked.WakeableLostCell + && ReferenceEquals(parked.Record, record) + ? parked.ParkWithdrawal + : default; receipt = CancelCore(key); if (releasePreparedMover) _preparedMovers.Remove(key); + if (withdrawal.Captured) + RestoreParkWithdrawal(record, withdrawal); } return receipt; } + /// + /// Undoes 's WITHDRAWAL - InWorld, the + /// transient-state bits, the object clock, and canonical residency - so a + /// cancelled park cannot leave the entity invisible and intangible with + /// nothing able to wake it. + /// + /// The committed pose is deliberately left alone. + /// snaps the body to the destination before + /// withdrawing, which is precisely retail's lost-cell behaviour: + /// CPhysicsObj::SetPositionInternal @0x00515BD0 calls + /// store_position @0x00515CE2 on the no-cell branch and returns + /// OK_SPE. Retail commits the destination pose and never rolls it + /// back, and route 2's shipped tests pin the same contract by capturing + /// the pose AFTER the park and asserting it survives the cancel. Residency + /// is therefore restored at the body's committed cell, keeping residency + /// and pose consistent, rather than at the stale pre-park cell. + /// + /// Re-entering at a destination whose collision is not yet published + /// cannot re-arm 's overlap throw: + /// that throw fires only for a spatial root still holding an ACTIVE + /// operation, and the cancel this restore follows has already retired the + /// operation. An ordinary retirement re-parks the entity through the + /// normal path instead. + /// + /// Residual divergence, measured against retail rather than + /// labelled retail-shaped. Retail would keep the object hidden and + /// re-show it on cell load - a lost registration is removed ONLY by + /// CObjectMaint::InitObjCell @0x00508260, which drains the lost + /// list and calls CPhysicsObj::reenter_visibility @0x00508296 + /// (@0x00516250). We instead re-show it immediately at the committed + /// destination pose, uncollidable until its landblock publishes. For a + /// remote at 5-10 Hz the next packet supersedes within ~150 ms; a remote + /// that teleports into a non-resident landblock and then STOPS MOVING + /// holds that state, because ACE stops broadcasting for a stationary + /// entity. The retail-faithful end state is a park that SURVIVES + /// cancellation, blocked on re-deciding the newer-Position-cancels-the-park + /// invariant pinned by + /// NewerPositionPickupAndParentEachCancelExactLostOperation and on + /// teardown convergence. See the divergence register row and its issue. + /// + /// The shadow-object suspension + /// applied is NOT lifted here: ShadowObjectRegistry.Suspend drops + /// the owner's cell rows and only a real placement dispatch + /// (ReplacePositionRows) rebuilds them, so un-suspending would mean + /// re-running a placement re-entrantly from inside a cancel. The entity is + /// restored visible and simulated and rejoins the collision broadphase on + /// its next placement - recorded with the divergence above, not papered + /// over. + /// + private void RestoreParkWithdrawal( + RuntimeEntityRecord record, + in ParkWithdrawal withdrawal) + { + if (!_entities.IsCurrent(record)) + return; + uint residentCellId = 0u; + if (record.PhysicsBody is { } body) + { + body.InWorld = withdrawal.InWorld; + body.TransientState = withdrawal.TransientState; + residentCellId = body.CellPosition.ObjCellId; + } + if (withdrawal.ClockActive) + _entities.ResumeObjectClock(record); + if (residentCellId != 0u && record.FullCellId == 0u) + { + _entities.SetFullCell( + record, + residentCellId, + (residentCellId & 0xFFFF0000u) | 0xFFFFu); + _physics.AcknowledgeSpatialProjection(record, spatial: true); + } + } + internal void LeaveWorld(RuntimeEntityRecord record) { EnsureNotDisposed(); @@ -4085,14 +4253,39 @@ internal sealed class RuntimeSetPositionState : IDisposable /// so derived false and /// the parked operation skipped the QuiescenceHeld stage entirely. /// + /// + /// opts this park into + /// 's rollback. It is true for exactly one caller: + /// the plain "destination is not placeable right now" park, which is the + /// one that strands an entity when a later packet cancels it. + /// + /// It is deliberately FALSE for every quiescence/retirement park. + /// Those withdraw the entity precisely because its own landblock prefix + /// is being retired or held, so restoring canonical residency there would + /// re-admit a spatial root into the prefix that is trying to quiesce and + /// block the retirement outright - a streaming stall traded for a + /// stranded entity. Those entities are withdrawn because their world is + /// going away, not because a destination was momentarily unavailable. + /// private RuntimeSetPositionOutcome ParkDeferred( Operation operation, in PhysicsSetPositionResult result, bool publishImmediately = true, ulong? collisionGenerationOverride = null, - uint? collisionPrefixOverride = null) + uint? collisionPrefixOverride = null, + bool restorableOnCancel = false) { PhysicsBody body = operation.Body!; + // Captured BEFORE the withdrawal below clears it. Only the + // WITHDRAWAL is captured, never the pose: the SnapToCell on the next + // line deliberately commits the destination pose and that commit + // stands, exactly as retail's store_position @0x00515CE2 commits it + // on the lost-cell branch and never un-commits it. + operation.ParkWithdrawal = new ParkWithdrawal( + Captured: restorableOnCancel, + InWorld: body.InWorld, + TransientState: body.TransientState, + ClockActive: operation.Record.ObjectClock.IsActive); body.Orientation = result.Orientation; body.SnapToCell( result.CellId, @@ -5216,18 +5409,31 @@ internal sealed class RuntimeSetPositionState : IDisposable private RuntimePlacementCancellationReceipt CancelCore( RuntimeEntityKey key, in RuntimeEntityPlacementToken expectedToken, - bool preserveLostFamily = false) + bool preserveLostFamily = false, + bool restoreCancelledPark = false) { if (!_operations.TryGetValue(key, out Operation? current) || current.Token != expectedToken) { return default; } + // Opt-in only - see Forget's doc comment for why a withdrawal must + // never roll a park back. This overload is the path route 2's + // controller and route 4b's remote controller cancel through + // (ForgetExactPlacement); they opt in, the initial-create residence + // and continuation executors deliberately do not. + RuntimeEntityRecord parkedRecord = current.Record; + ParkWithdrawal withdrawal = + restoreCancelledPark && current.WakeableLostCell + ? current.ParkWithdrawal + : default; _ = CancelCoreDeferred( key, cancelLostFamily: false, preserveLostFamily, out RuntimePlacementProjectionSnapshot? discard); + if (withdrawal.Captured) + RestoreParkWithdrawal(parkedRecord, withdrawal); return discard is { } projection ? new RuntimePlacementCancellationReceipt(projection) : default; diff --git a/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs b/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs index 5f5709ce..1af11389 100644 --- a/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs +++ b/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs @@ -307,8 +307,13 @@ public sealed class RuntimeAcceptedPositionDriveController _pending = null; RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; setPosition.ForgetPlacementCompletion(pending.Token); + // Cancellation, not withdrawal: the placement intent is abandoned but + // the local player stays in the world, so a DeferredCell park must be + // rolled back rather than left stranding the body. RuntimePlacementCancellationReceipt cancellation = - setPosition.ForgetExactPlacement(pending.Token); + setPosition.ForgetExactPlacement( + pending.Token, + restoreCancelledPark: true); if (cancellation.IsValid) setPosition.PublishCancellation(cancellation); } @@ -796,8 +801,11 @@ public sealed class RuntimeAcceptedPositionDriveController RuntimeSetPositionState setPosition, in RuntimeEntityPlacementToken token) { + // Cancellation, not withdrawal - see AbandonPending. RuntimePlacementCancellationReceipt cancellation = - setPosition.ForgetExactPlacement(token); + setPosition.ForgetExactPlacement( + token, + restoreCancelledPark: true); if (cancellation.IsValid) setPosition.PublishCancellation(cancellation); } diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs index a244db9c..57c5271c 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.cs @@ -319,6 +319,95 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests submitted.Status); } + /// + /// C4 route 4b-1 review finding B2, pinned. The contract asked whether + /// ParkCollisionResidents's overlap throw stays unreachable under a + /// design that lets remotes hold placement operations. It does, and for a + /// STRUCTURAL reason rather than an incidental one: + /// TryAcquireCollisionPrefixMutationPermission consults + /// HasOldPrefixPlacementDebt FIRST and refuses permission outright, + /// so ParkCollisionResidents is never entered while any affected + /// root holds an operation. (Additionally, a parked record has + /// FullCellId == 0, so it is not an affected resident at all.) + /// + /// The real hazard is not a throw, it is an unbounded streaming + /// stall — and this test PINS it as a stall, not as a pass. A retained + /// preparation retry keeps its landblock prefix in placement debt, so the + /// retirement is refused on EVERY poll and the landblock never retires. + /// There is no bound: the retirement coordinator simply retries, and + /// TickLostCellDeadlines — the only expiry that could break the + /// cycle — has NO production caller, so its deadline never fires. The only + /// thing that clears it is an inbound packet for that same entity, which + /// is exactly what a RetrySetupUnavailable on an asset that never + /// loads does not produce. + /// + /// This is a pre-existing hazard independent of route 4b-1, filed as + /// its own issue. 4b-1 does NOT bound it; it only avoids widening it, by + /// declining to retain operations for destinations it cannot service. + /// + [Fact] + public void RetainedPreparationRetryStallsPrefixRetirementIndefinitely() + { + using var fixture = new Fixture(); + RuntimeEntityRecord record = fixture.Add( + 0x7000300Bu, + 1, + CellP, + new Vector3(10f, 28f, 7f)); + + // A retained preparation retry: begun, never prepared — the shape a + // RetrySetupUnavailable on an asset that never resolves leaves behind. + RuntimeEntityPlacementToken retained = fixture.Lifetime.Physics + .SetPosition.BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(retained.IsValid); + + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL); + + // The production retirement path, polled hard. Permission is refused + // every single time; nothing in the system advances it. + for (int poll = 0; poll < 1_000; poll++) + { + Assert.False( + fixture.TryAcquire(token, out _), + $"retirement unexpectedly acquired permission on poll {poll}; " + + "if this now succeeds the stall has been bounded and " + + "this test's pinned decision must be revisited"); + } + + // ParkCollisionResidents was never entered, so its overlap throw could + // not fire — the contract's item 6, proven structurally. + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(record)); + Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out _)); + + // And the discriminator: retiring the retained operation is what + // releases the prefix. Once the debt is gone the ordinary two-phase + // handshake proceeds — ParkCollisionResidents withdraws the residents + // and permission follows the withdrawal acknowledgements — so drain + // those exactly as the production host does. + _ = fixture.Lifetime.Physics.SetPosition.ForgetExactPlacement(retained); + bool acquired = false; + for (int poll = 0; poll < 32 && !acquired; poll++) + { + acquired = fixture.TryAcquire(token, out _); + if (acquired) + break; + if (fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot projection)) + { + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(projection.Token)); + } + } + Assert.True( + acquired, + "clearing the retained preparation retry must let the prefix " + + "retire; if it does not, the stall has a second cause"); + } + [Fact] public void QueriedNeighborPrefixHoldsResultWithoutRequestDependency() { diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index 1cd31f01..0fba612b 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -1487,6 +1487,126 @@ public sealed class RuntimeSetPositionStateTests placed.Token)); } + /// + /// C4 route 4b-1 review, the decisive case. ParkDeferred withdraws + /// the entity from the world (InWorld = false, object clock + /// suspended, canonical residency dropped). The merge-time + /// Forget that every accepted Position performs + /// (RuntimeEntityObjectLifetime.TryApplyPosition) then removes the + /// operation and restores NONE of that, so the entity is left invisible + /// AND intangible with nothing left that could ever wake it. + /// + /// Retail cannot reach that state: CPhysicsObj::SetPositionInternal + /// @0x00515BD0 commits the destination pose via store_position + /// @0x00515CE2 and registers the object with + /// CObjectMaint::GotoLostCell @0x00515CF2 / @0x00508210, and the + /// ONLY remover of that registration is + /// CObjectMaint::InitObjCell @0x00508260, which drains the lost + /// list on cell load and calls CPhysicsObj::reenter_visibility + /// @0x00508296 / @0x00516250 for every object in it. A retail lost object + /// is never silently un-registered. + /// + [Fact] + public void CancellingWakeableParkLeavesEntityWithdrawnWithNothingToWakeIt() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000411Bu, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + + Assert.True(body.InWorld); + Assert.True(record.ObjectClock.IsActive); + Assert.Equal(SourceCell, record.FullCellId); + + RuntimeSetPositionOutcome parked = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, parked.Status); + Assert.True(lifetime.Physics.SetPosition.IsDeferred(record)); + Assert.False(body.InWorld); + Assert.False(record.ObjectClock.IsActive); + Assert.Equal(0u, record.FullCellId); + Vector3 parkedPosition = body.Position; + + // The merge-time cancel every accepted Position performs. + _ = lifetime.Physics.SetPosition.Forget( + record, + restoreCancelledPark: true); + + // The park is gone (the shipped newer-Position-cancels-the-park + // invariant, pinned by + // NewerPositionPickupAndParentEachCancelExactLostOperation) - so the + // entity MUST have been rolled back, or nothing is left to wake it. + Assert.False(lifetime.Physics.SetPosition.IsDeferred(record)); + Assert.True( + body.InWorld, + "cancelled park left the entity withdrawn: InWorld=false"); + Assert.True( + record.ObjectClock.IsActive, + "cancelled park left the object clock suspended"); + Assert.True( + record.FullCellId != 0u, + "cancelled park left the entity without canonical residency"); + Assert.True( + lifetime.Physics.IsSpatialRoot(record), + "cancelled park left the entity out of the physics workset"); + Assert.Equal( + TransientStateFlags.Active, + body.TransientState & TransientStateFlags.Active); + + // The pose committed at park time STANDS - retail's store_position + // @0x00515CE2 commits the destination on the lost-cell branch and + // never un-commits it. Residency follows the body's committed cell so + // the two cannot disagree. + Assert.Equal(parkedPosition, body.Position); + Assert.Equal(body.CellPosition.ObjCellId, record.FullCellId); + } + + /// + /// The sibling cancel entry point. Route 2's controller and route 4b's + /// remote controller both cancel a DeferredCell park through + /// ForgetExactPlacement, not through Forget, so the rollback + /// has to live at the shared CancelCore layer or the identical + /// stranded-entity hole stays open on exactly the routes that produce it. + /// + [Fact] + public void CancellingWakeableParkByExactTokenAlsoRestoresTheEntity() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000411Cu, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(token.IsValid); + RuntimeSetPositionOutcome parked = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(token, Command(CrossLandblockRequest())); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, parked.Status); + Assert.False(body.InWorld); + Assert.False(record.ObjectClock.IsActive); + Assert.Equal(0u, record.FullCellId); + + _ = lifetime.Physics.SetPosition.ForgetExactPlacement( + token, + restoreCancelledPark: true); + + Assert.False(lifetime.Physics.SetPosition.IsDeferred(record)); + Assert.True(body.InWorld, "exact-token cancel left the entity withdrawn"); + Assert.True( + record.ObjectClock.IsActive, + "exact-token cancel left the object clock suspended"); + Assert.True( + record.FullCellId != 0u, + "exact-token cancel left the entity without canonical residency"); + Assert.True(lifetime.Physics.IsSpatialRoot(record)); + } + [Fact] public void ReentrantNewerPositionDuringPickupDiscardSuppressesStalePickupDelta() { @@ -2406,6 +2526,7 @@ public sealed class RuntimeSetPositionStateTests "DormantLocalActivation", "ParkReason", "PreparedCommandAwaitingWithdrawalAck", + "ParkWithdrawal", "InPool", ]; string[] expectedFieldNames = expectedPropertyNames @@ -2985,6 +3106,38 @@ public sealed class RuntimeSetPositionStateTests .DeferredSetPositionCount); Assert.Equal(0, lifetime.Physics.CaptureOwnership() .LostCellDeadlineCount); + + // Pickup and Parent are WITHDRAWAL transactions, not cancellations: + // each hand-rolls its own partial re-withdrawal after the Forget, + // undoing the object clock and FullCellId but NOTHING else. The + // park-rollback the accepted-Position merge opts into must never fire + // underneath them - it would set InWorld and re-add the entity to + // _spatialRoots, neither of which their cleanup undoes, leaving a + // picked-up item both in inventory AND an InWorld cell-less spatial + // root inside the CPhysics::UseTime workset. The counts above cannot + // see either field, so assert them directly. + // + // Position is the one CANCELLATION here: a newer accepted Position + // supersedes the placement while the entity stays in the world, so it + // must roll back rather than strand the body. + if (channel is CancellationChannel.Position) + { + Assert.True( + record.PhysicsBody?.InWorld ?? false, + "the cancellation channel must roll the park back"); + Assert.True( + lifetime.Physics.IsSpatialRoot(record), + "the cancellation channel must restore the spatial root"); + } + else + { + Assert.False( + record.PhysicsBody?.InWorld ?? false, + "a withdrawal channel must not leave the body InWorld"); + Assert.False( + lifetime.Physics.IsSpatialRoot(record), + "a withdrawal channel must not leave the entity a spatial root"); + } } private static void CommitParent(