diff --git a/docs/research/2026-08-04-c4-route-4b-1-contract.md b/docs/research/2026-08-04-c4-route-4b-1-contract.md new file mode 100644 index 00000000..b4396216 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-4b-1-contract.md @@ -0,0 +1,174 @@ +# C4 route 4b-1 — remote placement infrastructure: pinned contract (2026-08-04) + +Scoping and the three-way split: +[`2026-08-04-c4-route-4b-scoping-and-split.md`](2026-08-04-c4-route-4b-scoping-and-split.md). + +**4b-1 builds the machinery and changes NO remote behaviour.** It has no +production caller, or is called for zero classifications. 4b-2 (far branch) and +4b-3 (teleport / cell-less) flip it on afterwards. + +The reason for landing it alone: 4b-1 is where the +park-withdraws-the-entity failure mode is decided, and that decision must be +reviewed on its own signal, not alongside a ~700-line class deletion. + +## Scope — build these five things + +1. **A per-entity remote placement owner** in `AcDream.Runtime`. +2. **A Position-time service-window guard**, with a Runtime-facing interface and + implementations for both hosts. +3. **The refuse-rather-than-park policy** (see "The central decision"). +4. **N3** — headless never calls `RetryPending` after construction. +5. **Parked-count observability** wired into the ownership ledger and + `report.json`. + +## The central decision — refuse, do not park + +A Runtime `DeferredCell` park **withdraws the entity from the world**: +`ParkDeferred` sets `body.InWorld = false`, clears `TransientStateFlags.Active`, +suspends the object clock, calls `WithdrawCanonical`, and publishes a `Withdraw`. + +`RuntimeEntityObjectLifetime`'s `Forget`-on-every-accepted-Position then kills +the park **without restoring any of that** — `CancelCoreDeferred` removes the +operation and rewrites the `Withdraw` into a `Discard`; it does not set +`InWorld` back, resume the clock, or re-enter residency. + +So packet N parks entity E (invisible AND intangible); packet N+1 ~150 ms later +Forgets the park; and **if N+1 classifies `Interpolate`, no placement runs and E +stays withdrawn indefinitely.** The producing sequence is mundane: a remote +appears beyond 96 m, walks toward you, crosses inside 96 m. + +**Therefore: a remote whose destination is not placeable now must NOT open a +park.** It keeps its last committed pose and waits. The next packet IS the +retry, because remote Positions are a 5-10 Hz stream. This is also retail-shaped +— retail's world is fully resident, so "arrived but not placeable" is +unrepresentable there. + +If you conclude the refusal cannot be expressed without touching +`RuntimeSetPositionState`'s park machinery, STOP and report rather than adding a +withdrawal-restore path inside a 5,652-line class. + +## Two transfer errors — named so they are not repeated + +Route 2's controller is the architectural model. **Two of its parts must NOT be +ported**, and both would look correct to an implementer copying it: + +- **The ack machinery.** `PositionEventOwed`, `positionEventOwed`, the + `SendPositionEvent` plumbing — retail's remote arm has NO + `SendPositionEvent`. `HandleReceivedPosition` @0x00453FD0 calls it only on the + local-player FORCE_POSITION branch. Delete the concept, do not carry it. +- **The re-issue funnel.** Route 2 re-issues because a ForcePosition is a + one-shot correction ACE never repeats, so a dropped one is lost. A remote + Position is a repeated stream; re-issuing packet N after N+1 has merged would + apply a pose the newer packet already superseded. Same class of error as the + route-2-to-4a rebucket mistake, in reverse. + +## Per-entity state + +Route 2's `_pending` is ONE slot and `RetainPending` throws on a second live +pending. That shape does not transfer. Required: + +- A per-key map, with route 2's single-owner invariant re-derived **per entity**. +- The ownership ledger count becomes a dictionary count that must converge to + zero at teardown, reset, and generation change. +- Bounded, non-allocating iteration for any pump — + `RuntimeFirstEntryDriveController._driveScratch` is the in-repo template. +- Per-entity currency: GUID reuse, incarnation change, generation change, an + entity torn down mid-drain, and two entities interleaved must all be safe. A + pure function does not by itself make the CALLER re-validate identity. + +## The service-window guard + +- **Headless has one**: `IHeadlessCollisionNeighborhood.IsWithinServiceWindow` + (`HeadlessSessionWorldProjection.cs:27`, impl `:245-257`) — a Chebyshev + `dx <= 1 && dy <= 1` test against `_requestedCenterLandblock`. Its only + consumer today is Create-time. +- **The graphical host has none.** It must be built from + `GpuWorldState.IsNearTier` / `IsNearTierOrPending`, `StreamingController.NearRadius` + and the observer centre, exposed to Runtime through an interface mirroring the + headless one. +- **Unproven, and you must establish it**: that near-tier residency is exactly + co-extensive with collision publication. The four gating call sites are + consistent with it; the retirement side is unverified. If it is not + co-extensive, say so and propose the correct predicate rather than shipping + the assumption. +- `ToCellessCreateRoute` does **not** apply. It produces `AwaitFreshPosition`, + its only consumer operates on a residence lease, and it refuses once + `FullCellId != 0`. A steady-state Position has no residence by construction. + 4b-1 needs its own "decline this placement" outcome. + +## N3 — fix regardless + +`HeadlessSessionEventRoute.Attach` constructs the subscription with +`retryPendingOnSubscribe: true` and that is the ONLY `RetryPending` call headless +ever makes. `PumpFirstEntry` calls `IsReady`, `DriveAll` and `Advance` — not +`RetryPending`. The graphical host binds it correctly per frame. +`RuntimePlacementProjectionSubscription.OnPlacement` only projects when the new +delta IS the FIFO head, so a declined head is never revisited. + +Add `RetryPending()` to the headless pump in the same order the graphical route +uses (drives first, retry last). This is a latent defect in shipped code; fix it +whether or not 4b-1 flips headless remotes. + +## Must prove, not assume + +**`ParkCollisionResidents` throws on overlap** — for every spatial root in a +retiring landblock prefix that holds an active operation +(`RuntimeSetPositionState.cs:3387-3395`). With N remotes holding operations, an +ordinary streaming retirement becomes session-fatal. It is unreachable today +only because steady-state remotes hold no operations. **4b-1 must demonstrate it +stays unreachable under the new design** — that is the concrete failure #277's +bound was protecting, and it is a gate item, not a note. + +## Do NOT touch + +- **AP-135's two writes** (`rmState.CellId`, `LastServerPos`/`LastServerPosTime`) + on the airborne branches. They are 4a-owned dispositions and AP-135 does not + retire with 4b. They sit inside the method 4b rewrites, which is the trap. +- **`RemoteTeleportController`, `RemoteTeleportPlacement`, + `remotePlacementRequired`** — 4b-3. +- **The legacy far halves** in either arm — 4b-2. +- **`ConstrainTo` arming.** Retail has exactly ONE site on the remote arm + (@0x00454272); all three nonzero-returning `MoveOrTeleport` branches funnel + through it. Do not add a second. 4b-2/4b-3 fold the existing arm outward. +- **Route 1's executor.** `RuntimeAcceptedPositionRouteRequests` is shared with + it; any change there is a regression in shipped functionality. + +## Contract + +1. The owner is Runtime-resident and presentation-independent; both hosts can + drive it. +2. It has **no production caller**, or is called for zero classifications. 4b-1 + changes no remote behaviour, and the existing connected routes must be + unchanged. +3. No ack. No re-issue funnel. Per-entity state. +4. A non-placeable destination is refused, not parked. +5. The ownership ledger includes the per-entity pending count and converges to + zero at teardown, reset, and generation change. +6. `ParkCollisionResidents`'s overlap throw is demonstrably unreachable. +7. Nothing in the "Do NOT touch" list changes. + +## Acceptance + +- Focused Runtime tests: per-entity independence, currency across GUID reuse / + incarnation / generation / teardown, the refusal outcome, ledger convergence, + and the service-window predicate on both hosts. +- **A test for the §"central decision" sequence specifically**: attempt a + placement whose destination is not placeable, deliver a second accepted + Position that classifies `Interpolate`, and assert the entity is **still in + the world** — `InWorld` true, clock running, residency intact. That is the + invisible-and-intangible failure, and it must be pinned before 4b-2 flips + anything on. +- Complete Release suite green. Baseline **10,938 passed / 4 skipped / 0 + failed**. Two known flakes, do NOT chase and do NOT conflate: **#302** + (`PortalProjectionTests.ClipToRegion_FrameOwnedStore_ReusesExactResultArray`, + a GC-allocation assertion in App.Tests) and **#308** + (`NakEmissionTests.LossSoak_...`, a wall-clock deadline in Core.Net.Tests that + fails only under full-suite load). If either appears, re-run and say which. +- No connected gate: 4b-1 changes no behaviour, so there is nothing for a user + to observe. Say so rather than inventing one. + +## Budget + +**~700-1,000 non-comment production lines.** Route 4a came in at 364, which was +91% of its ~400 budget — not "well under". If 4b-1 exceeds 1,000, stop and +report before continuing rather than pushing through.