From 71604331cfe1840565c32a1805b859bab9b95867 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 20:06:59 +0200 Subject: [PATCH] wip(physics): collision O(changed) delta-commit (O1-O3) - ON HOLD, feel-test failed Publication-throughput rework per the D2 design (docs/research/ 2026-08-02-collision-throughput-handoff/design-note.md): O1 per-prefix installed-key ledgers replacing the seal's full-map scans; O2 per- landblock delta commit (LandblockReplacementApplyCursor against the active root) replacing whole-world TransferTo; O3 empty staging root, commit-time reflood (CObjCell::init_objects 0x0052B420 -> recalc_cross_cells 0x00515A30), journal/peer-rebase machinery deleted (~1,900 lines net). Automated gates green: Runtime 999, Core physics 2,135, App 4,039/3, Headless 79, complete solution 10,812/0/4; lifecycle gate PASS (connected-world-gate-20260802-193029). Soak 194423: publication-side acceptance fully met (37 -> 4 failures, all convergence dims zero, loadedLandblocks baseline-identical, waitCue 6/9 -> 1/9). COMMITTED AS WIP ON USER DIRECTION - NOT ACCEPTED. The user feel-test FAILED on this tree: monsters still pop into existence at close range, monsters spawned mid-air far ahead, static placements visibly wrong, plus 243x "Landblock already has a full retirement receipt" InvalidOperationException catch-retry loop during origin recenter (launch-feeltest-oclone.log). The 4 remaining soak failures (pendingLandblockRetirements 131/122 at the Caul->Sawato stops) and the implementer's "exposed pre-existing" classification are under re-judgment against that loop. Dual reviews were dispatched and then stopped mid-flight on user direction; NO review has passed this commit. Full problem inventory + next-agent instructions: docs/research/2026-08-02-collision-throughput-handoff/. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 5 +- .../design-note.md | 411 ++ .../docs-drafts.md | 109 + .../implementer-progress.md | 4516 +++++++++++++++++ .../user-observations-feel-test.md | 30 + .../user-observations-smoke-test.md | 29 + .../Physics/CollisionWorldState.cs | 210 + src/AcDream.Core/Physics/PhysicsDataCache.cs | 256 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 607 +-- .../Physics/ShadowObjectRegistry.cs | 88 +- src/AcDream.Core/World/Cells/CellGraph.cs | 100 +- .../Physics/RuntimePhysicsState.cs | 970 +--- .../Physics/RuntimePhysicsStateTests.cs | 973 ++-- 13 files changed, 6410 insertions(+), 1894 deletions(-) create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/design-note.md create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/docs-drafts.md create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/user-observations-feel-test.md create mode 100644 docs/research/2026-08-02-collision-throughput-handoff/user-observations-smoke-test.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 5d4be4dc..d098eb42 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -59,8 +59,9 @@ What does NOT go here: streaming-convergence regression (its dedicated slice precedes C5) — re-observe after that slice; (b) no lateral glide when walking against impassable slopes — verify against open #269 (Campaign P slope-slide - residual) in the session before treating as new; (c) `/ls` command - reported non-working — identify which command surface at the session. + residual) in the session before treating as new; (c) ~~`/ls` command + reported non-working~~ — RESOLVED 2026-08-02: user confirmed `/ls` + works in-client; the earlier report was environmental noise. **Smoke-test additions (2026-08-02 evening, retail-UI session):** (d) monsters pop into existence late — appear on radar BEHIND the running player; (e) recall spends far longer in portal space than diff --git a/docs/research/2026-08-02-collision-throughput-handoff/design-note.md b/docs/research/2026-08-02-collision-throughput-handoff/design-note.md new file mode 100644 index 00000000..dffd67cc --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/design-note.md @@ -0,0 +1,411 @@ +# O(changed) collision clone — design note + +**Phase:** research + design only. No production edits, nothing staged, no probes left +behind. Worktree `C:\Users\erikn\.codex\worktrees\af5e\acdream`, branch +`codex/port-claude-agents`, HEAD `c52ce14a`. + +**Problem:** the collision-generation staging clone is O(resident world) per landblock +publication, so loading an N-landblock ring costs O(N²). The far ring never converges. +C3c made it user-visible (late monster pop-in, extended/stuck portal space, portal-exit +pop-in, failing nine-stop soak) but did not cause it. + +--- + +## (a) What the one-leaf-per-step invariant actually protects + +### It is a frame-time bound. Nothing else. + +The whole-world copy did not arrive with `6b28ff99`. It arrived one commit earlier, in +`be94bc9b` "fix(physics): activate collision generations atomically" (2026-07-31), as a +**synchronous** copy performed in a single call at admission: + +```csharp +// be94bc9b, PhysicsEngine.CreateCollisionStagingCopy +foreach ((uint id, LandblockPhysics landblock) in _landblocks) + staging._landblocks[id] = landblock; +staging.ShadowObjects.CopyCollisionStateFrom(ShadowObjects, stagingCache); +``` + +`6b28ff99` "make collision activation starvation-free" replaced that with +`CollisionStagingBuilder` (`src/AcDream.Core/Physics/PhysicsEngine.cs:785-941`), which +performs the *same* copy chopped into single leaves across frames. The retired AD-6 row +states the purpose verbatim +(`docs/architecture/retail-divergence-register.md:113`): + +> "Admission captures the active root in O(1); a stable landblock/owner slot suffix +> materializes non-target leaves incrementally, **so resident-world size cannot become a +> synchronous clone spike**." + +The committed test says the same thing three ways +(`tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:921-991`): + +| Assertion | line | What it pins | +|---|---|---| +| `Assert.InRange(admissionAllocation, 1L, 128L*1024L)` | :973 | admission allocates a constant | +| `Assert.Equal(0, prepared.Engine.LandblockCount)` | :974 | admission copies no resident landblock | +| `Assert.InRange(step.WorkUnits, 0, 1)` | :983 | **the copy is chopped to one leaf per host step** | +| `Assert.True(advances > residentLandblocks)` | :988 | it really walked the resident world | +| `Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount)` | :989 | the draft ends up holding the whole world | + +So the invariant protects **hitch avoidance**: a dense resident world must not produce +one long synchronous copy inside a single update step. It is a *scheduling* property +asserted as a *mechanism*, which is why the batching lever tripped it. + +### What it does NOT protect + +- **Not concurrent-reader isolation.** That is `CollisionWorldStateSlot.TransferTo`'s + single `Volatile.Write` (`src/AcDream.Core/Physics/CollisionWorldState.cs:66-84`). + And a full threading audit of every writer and reader of `CollisionWorldState` found + **no concurrent reader or writer exists**: `GameWindow` runs one Silk.NET loop thread; + `UpdateFrameOrchestrator.Tick` runs `_streaming.Tick()` → `DrainAndApply` and then the + live/physics/camera phases strictly sequentially on that thread; the only background + workers (`LandblockStreamer` worker thread, `EnvCellRenderer` `Parallel.ForEach`, + `ObjectMeshManager` `Task.Run`) never touch `PhysicsDataCache` / `CellGraph` / + `ShadowObjectRegistry` / `PhysicsEngine` — grep of `LandblockBuildFactory.cs` and + `LandblockMesh.cs` for those types returns zero hits. Every one of + `BeginCollisionAdmission` (:2040), `PrepareCollisionGeneration` (:2092), + `AdvanceCollisionGenerationPreparation` (:2123), `StageCollisionAssets` (:2218), + `AdvanceCollisionGenerationSeal` (:2298), `CommitCollisionGeneration` (:2382), + `CancelCollisionGeneration` (:2139) passes through + `RuntimePhysicsState.EnsureCollisionMutationThread` (:2857-2869). The + `ConcurrentDictionary` choices are load-bearing only for *single-threaded* + mutate-while-enumerating (`PhysicsDataCache.cs:958-984`, and the seal cursor holding a + live enumerator across frames at :1081-1160) — the cross-thread rationale in the + `CellGraph.cs:17` and `PhysicsDataCache.cs:14-20` doc comments is **stale after + 6b28ff99**. +- **Not admission fairness.** That is the separate journal/coalescing machinery + (`RuntimePhysicsState.cs:475-529`, research doc step 5) — the other half of what + "starvation-free" meant. It is orthogonal to the leaf metering and stays. + +### What the pre-6b28ff99 mechanism did + +`be94bc9b`'s commit was a **delta apply**, not a root swap: + +```csharp +// be94bc9b, PhysicsEngine.CommitLandblockReplacement — deleted by 6b28ff99 +DataCache.CommitLandblockReplacement(replacement.DataCache); // O(changed) +_landblocks[replacement.LandblockId] = replacement.Landblock; +ShadowObjects.CommitLandblockReplacement(replacement.Shadows); +``` + +`6b28ff99` replaced those three lines with +`stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld)` +(`PhysicsEngine.cs:304-321`). **That is the change that made the clone load-bearing.** +Before it, the clone was a build sandbox; after it, the clone *is* the world that gets +published, so every leaf not cloned is a leaf deleted from the world. + +Before `be94bc9b` the client mutated the active maps in place across many frames — the +genuinely non-equivalent state the research doc describes ("the active `PhysicsDataCache`, +`CellGraph`, `PhysicsEngine`, buildings, static shadows, and retained-object refloods +changed at different cursors", `docs/research/2026-07-31-atomic-collision-generation.md:12-16`). +**The atomicity requirement is "the multi-frame build must not be observable", not "the +whole world must be swapped".** A delta applied inside one synchronous update-thread call +satisfies it. + +### The cost is worse than F4 measured + +F4 attributed median 19,736 / p90 32,135 / max 38,021 leaves and median 3.64 ms per +publication to the staging clone. The **seal** does the same walk again: the replacement +builder holds live enumerators over four `_staging` maps *and* four `_active` maps +(`PhysicsDataCache.cs:1081, 1092, 1103, 1114, 1125, 1136, 1147, 1158`) plus two in +`CellGraph.cs:184, 203`, using `CapturePrefixOne` / `CaptureRemovalOne` — a full scan of +each map to find O(target) keys. Real per-publication cost is therefore roughly **2–3× +resident world**, not 1×. **Fixing only the clone leaves O(N²) in the seal.** Any design +that does not also scope the removal capture is not a fix. + +--- + +## (b) Candidate designs + +### D1 — Structural sharing (persistent/immutable `CollisionWorldState`) + +Replace the ~20 mutable maps with persistent maps (HAMT / `ImmutableDictionary`) so a +staging clone shares unchanged subtrees and copies only the changed path. + +- **Blast radius:** every read and write site of every map in `CollisionWorldState`, + `PhysicsDataCache`, `CellGraph`, `ShadowObjectRegistry`, `PhysicsEngine`. +- **Invariant changes:** none semantically; the root swap survives unchanged, so the + atomicity story is untouched. +- **Throughput:** admission O(1), clone O(1), commit O(changed · log N). Excellent on the + copy axis. +- **Why rejected:** it pays for the copy with the *query*. A HAMT probe is several times a + `Dictionary` probe and allocates on write; the resolver runs thousands of these per + frame at 30 Hz. Slice I's entire thesis is flat, integer-indexed, zero-allocation + collision (`docs/plans/2026-07-25-modern-runtime-slice-i.md`; I1 measured 0 B/resolve). + D1 optimizes the rare operation at the expense of the hot one and fights the I-series + architecture head-on. + +### D1b — Landblock-sliced root (per-prefix immutable slice + small map) + +Regroup the root so each landblock's cells / flat cells / EnvCells / buildings / terrain / +outdoor cells / `LandblockPhysics` live in one immutable `LandblockCollisionSlice`, and +the root becomes `Dictionary` (~625 entries). Commit = one dictionary +write per prefix. + +- **Blast radius:** every keyed read becomes mask + two probes; the seal's removal scans + collapse to "old slice vs new slice". The **shadow registry does not partition** — + `ShadowEntityCells`, `ShadowEntityShapes`, `ShadowEntityRegistrations`, + `ShadowOwnerVersions` are owner-keyed and owners legitimately span prefixes (that is + the whole retained-owner problem), so the shadow half needs a separate mechanism. +- **Invariant changes:** the atomic unit becomes the slice; the root swap disappears. +- **Throughput:** O(changed) by construction, and atomic even for a hypothetical + concurrent reader. +- **Verdict:** this is the right answer *if* concurrent readers existed. They do not. + Keep it on the shelf as the migration target should the runtime ever go multi-threaded; + do not pay its refactor cost now. + +### D2 — Per-landblock atomic unit: restore the delta apply *(recommended)* + +`CommitLandblockReplacement` drains the **already-existing** +`PhysicsEngine.LandblockReplacementApplyCursor` (`PhysicsEngine.cs:568-777`) against the +**active** root inside one synchronous call, instead of `TransferTo`. The staging root +becomes empty-at-admission (target content only); `CollisionStagingBuilder` phases 0–8 are +deleted. + +The delta record already exists and is already tested: `PreparedPhysicsDataCacheLandblock` +(`PhysicsDataCache.cs:1255-1270`) is exactly lists of key/value pairs to install and lists +of ids to remove, all target-scoped. The apply cursor already handles removals, installs, +terrain, the 0x40 synthesized outdoor cells, the landblock itself, and yields the reflood +owner ids to the caller at phase 12 (`PhysicsEngine.cs:702-712`). Today it is used to +rebase a committed peer delta into a *later draft*; pointing its `destination` at the +active engine is a constructor argument, not new machinery. + +**What breaks, honestly:** + +1. *Readers mid-query* — nothing. Single-threaded, evidenced above. A drained cursor + inside one call is indivisible with respect to every reader that exists. +2. *Re-entrancy* — real, and the audit flagged it: `OwnerMutated` / + `OwnerPrefixMembershipChanged` (`ShadowObjectRegistry.cs:86-87`) can fire mid-delta. + Precedent already exists: the commit brackets itself with + `_suppressCollisionOwnerJournal = true` (`RuntimePhysicsState.cs:2491-2501`). Extend + that bracket to cover the whole apply. +3. *Cross-frame enumerators* — the seal holds live enumerators over the **active** maps + across frames (`PhysicsDataCache.cs:1092, 1136, 1158`). A delta apply now mutates the + maps those enumerators walk. `ConcurrentDictionary` will not throw, but the observed + set is unspecified. **O1 below removes those enumerators entirely**, which is why O1 + must land first. +4. *The retirement machinery* — `LandblockRetirementCursor` (`PhysicsEngine.cs:348-...`) + currently retires from an off-side draft. Same cursor, destination becomes the active + root, still drained in one call. +5. *The reflood context* — the seal currently computes retained-owner refloods against a + full staging world. With an empty staging root that context is gone, so the reflood + moves to the commit call, against the now-current active world. **That is precisely + retail**: `CObjCell::init_objects` (0x0052B420) → `CPhysicsObj::recalc_cross_cells` + (0x00515A30), already the retail anchor cited on the AD-6 row. +6. *The peer-rebase / journal apparatus* — with no snapshot there is nothing to rebase. + `EnqueueCommittedRebase` (`RuntimePhysicsState.cs:563-578, 2502-2507`) and most of the + journal become dead. Delete them in the same slice; do not leave dead invariants + guarding a deleted mechanism. + +- **Throughput:** per publication ≈ target payload (~70–200 leaves at the measured + ~184 ns/leaf) + the owners touching the target, versus today's ~2–3 × 20,000. Roughly + **300× less work per publication**, and — decisively — **independent of resident-world + size**, so total ring load goes O(N²) → O(N). At the failing run's numbers that is + ~13.7 M leaf copies for a 625-landblock ring down to ~44 K. + +### D3 — Adjacency-scoped clone (the tempting middle ground) — **rejected as unsafe** + +Copy only leaves in the target's 3×3 landblock neighbourhood. One predicate change in +`CopyOneOutsideTarget` (`PhysicsEngine.cs:949-961`); clone drops ~20,000 → ~630 and +becomes O(1) in world size. + +Rejected for a structural reason worth stating plainly: **while commit is a whole-root +transfer, "clone less" means "delete more."** Anything not copied into the draft is absent +from the root that replaces the world. A partial clone is therefore a silent world-erasure +bug, not a perf tuning knob. Only after commit becomes a delta does bounded context become +safe — at which point D2 has already removed the need for it. It also leaves the seal's +O(world) scans untouched, so O(N²) survives regardless. + +--- + +## (c) Recommendation + +**Take D2, in three landable slices, with O1 first.** + +Rationale in one line: the delta-apply commit path is not a new invention — it is the +mechanism that shipped in `be94bc9b` and was deleted by `6b28ff99` to buy an atomicity +guarantee against concurrent readers that do not exist; restoring it makes the cost +O(changed) by construction and moves the client *toward* retail's `init_objects` shape, +not away from it. + +### Invariant-test replacement + +Delete from `DenseResidentWorldAdmissionIsConstantAndMaterializesOneLeafPerStep` +(`tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:921-991`) the three +assertions that pin the clone itself — `:983` `Assert.InRange(step.WorkUnits, 0, 1)`, +`:988` `Assert.True(advances > residentLandblocks)`, `:989` +`Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount)`. They assert the exact +mechanism being removed. + +Replace with `CollisionPreparationCostIsIndependentOfResidentWorldSize` — a strictly +stronger invariant, because it pins the *property* (bounded, world-size-independent work) +rather than a mechanism: + +``` +Run the full admission → preparation → seal → commit sequence twice, +at residentLandblocks = 32 and residentLandblocks = 256. + +Assert total preparation advances(32) == total preparation advances(256) // O(changed) +Assert total seal WorkUnits(32) == total seal WorkUnits(256) // closes the seal scans +Assert every step.WorkUnits <= K // K = retained per-step bound +Assert admissionAllocation in [1, 128 KiB] // kept from :973 +Assert prepared.Engine.LandblockCount == 0 after preparation completes // stronger than :974: + // the draft now holds ONLY the target +``` + +Add two more: + +- `CommitAppliesOneLandblockDeltaInASingleCall` — the engine-mutating + `CommitCollisionGeneration` call drains the apply cursor to `Completed` before it + returns; the active world holds no target-prefix content before it and the complete + target after it, with no observable intermediate. +- `CommitTimeRefloodMatchesPrecomputedReflood` — for a fixed scenario, the owner set and + each owner's resulting cross-cell set after a commit-time reflood are **equal** to what + the pre-change staged reflood produced. This is the proof that D2 is a scheduling + change and not a semantics change, and it is the test that makes the perf framing in + (d) legitimate. + +**Keep unchanged:** every `Assert.InRange(seal.WorkUnits, 0, 1)` at `:558, :667, :747, +:848, :1797, :2020, :2318, :2428, :3033` — the seal stays metered; the zero-managed-byte +commit assertions (the delta lists are built during seal, so the apply must still be +allocation-free); and `CommittedPreparationRevokesItsStagingCollisionRoot` (`:1664`) in +spirit — the staging root must still be revoked after commit, it simply no longer becomes +the active root. + +### Migration plan + +| Slice | Change | Gate | +|---|---|---| +| **O1** | Per-prefix installed-key ledger in `CollisionWorldState`, maintained by the install/remove paths. Rewrite the seal's ten full-map scans (`PhysicsDataCache.cs:1081-1160`, `CellGraph.cs:184, 203`) to enumerate that set. Removes the cross-frame active-map enumerators. **Behaviour-identical; a pure win that lands alone.** | existing suites green + the new seal-independence assertion | +| **O2** | `PhysicsEngine.CommitLandblockReplacement` drains `LandblockReplacementApplyCursor` against the active root instead of `TransferTo`. Extend the `_suppressCollisionOwnerJournal` bracket over the whole apply. Retirement cursor destination → active root. | focused Runtime physics suite + connected lifecycle gate | +| **O3** | Empty staging root: delete `CollisionStagingBuilder` phases 0–8. Move retained-owner reflood into the commit call (retail `init_objects` → `recalc_cross_cells`). Delete the now-dead peer-rebase/journal paths and their tests. | full ladder below | + +### Gate ladder (O3 closeout) + +1. **Focused:** Runtime physics collision-generation suite, App + `LandblockPhysicsPublisherTests`, Headless `HeadlessSessionHostTests`. +2. **Complete Release solution:** baseline to match or beat is **10,808 passed / 0 failed + / 4 skips** (`-m:1`, `ACDREAM_PAK_PATH`). +3. **Connected lifecycle/reconnect gate:** signature must hold — `Passed=true`, + `Failures=[]`, both sessions `ExitCode=0`, zero render-shadow mismatches, zero pending + deltas, graceful exits. +4. **Nine-stop soak must reach `Passed: true` with `Failures: []`.** The failing run is + `logs/connected-r6-soak-20260802-143157.report.json` (37 failures, `Passed: false`); + the passing baseline is `logs/connected-r6-soak-20260727-004942.report.json` + (`Passed: true`, commit `a9a822f2`). Concrete acceptance, per checkpoint: + + | Key | Failing run | Required | + |---|---|---| + | `resources.streamingWork.deferredCompletions` | 92–501 at 8/9 stops | `0` at all 9 | + | `resources.streamingWork.farBacklog` | same values | `0` at all 9 | + | `resources.streamingWork.pendingPublications` | `1` at 8/9 | `0` at all 9 | + | `resources.streamingWork.deferredAdoptedCpuBytes` | 1.5–8.5 MB | `0` at all 9 | + | `resources.streamingWork.oldestDeferredAgeMilliseconds` | 37,764–69,728 | `0` | + | `resources.loadedLandblocks` | 124–533 | **625** at the eight outdoor stops | + | `reveal.waitCueShown` | `true` at 6/9 | `false` at all 9 — *this is the user-reported "extended/stuck portal space"* | + | `streamingWork.lifetimeFrameOverrunCount` | 1,706 | materially lower | + | `streamingWork.maximumOperationStage` | `"publication-index-physics"` | must no longer name this stage | + + **`aerlinthe` (sequence 4) is the control, not a target.** It is the one indoor + destination and the one stop that is already clean in the failing run (374/173 vs the + baseline's 374/176, every streaming counter `0`) — precisely because an indoor + destination streams few landblocks, so O(N²) never bites. It must stay clean; do not + expect it to reach 625. + +5. **Frame time must not regress — and must recover.** Route-level `cpuUs` from + `frame-history-summary.json`, microseconds: + + | | p50 | p95 | p99 | p999 | + |---|---|---|---|---| + | baseline `20260727` | 9,730 | 41,262 | 44,875 | 63,474 | + | failing `20260802` | 17,001 | 47,434 | 57,061 | 102,876 | + + Gate on the sharper per-checkpoint window numbers: **`checkpointWindows[].metrics.cpuUs` + p99 within +10 % of the `20260727` baseline at every stop.** The worst offenders are + `caul-plateau` 101,408 → target ≈ 47,821; `caul-return` 103,309 → ≈ 46,938; + `caul-baseline` 108,148 → ≈ 43,664; `sawato-baseline` 49,748 → ≈ 10,592. Frame count + should recover toward the baseline's 35,492 frames / 498.5 s from the failing run's + 25,965 / 583.9 s. + +6. **Do NOT gate on these — retest only after convergence.** `trackedGpuBytes` (62 MB + failing vs 474 MB baseline), `meshRenderData` 588 vs 607, `meshEstimatedBytes` + 233.8 MB vs 268.6 MB, and the inverted CPU mesh-cache hit ratio + (3,666 hits / 5,689 misses vs 20,825 / 6,845) are all far-ring-never-converged + artifacts of the same mechanism. F4 already reached this conclusion; a leak + investigation before convergence is restored will chase a ghost. + +### Register / plan bookkeeping (same commit as the code) + +- **`docs/architecture/retail-divergence-register.md:113`** — the retired AD-6 row + describes the deleted mechanism verbatim ("one shared off-side `CollisionWorldState`", + "one zero-managed-byte volatile root transfer", the journal, the peer rebases). A + retired row still documents what shipped; leaving it describing a deleted clone is + exactly the out-of-sync failure the register rules forbid. Rewrite it to the delta-apply + mechanism. The row's retail anchor is already + `CObjCell::init_objects` → `recalc_cross_cells` (0x0052b420 / 0x00515a30) — the new + mechanism is **closer** to that anchor, so no new deviation row is created. +- **Judgment call for the implementer, do not assume:** the *original* AD-6 deviation was + "Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells`" + (`be94bc9b` register diff). If O3's commit-time reflood is again per-landblock rather + than per-cell, decide explicitly whether AD-6 must be un-retired or a successor row + added, and record the decision. Flagged, not decided here. +- **`docs/research/2026-07-31-atomic-collision-generation.md`** — steps 2, 3, 5, 6, 7, 8 + and most of the "Deterministic evidence" list describe the clone / journal / rebase. + Rewrite in the same commit. +- **`memory/project_collision_port.md`** — the 37-line block `6b28ff99` added is now wrong. +- **`claude-memory/project_physics_collision_digest.md`** — add two DO-NOT-RETRY entries: + (1) *"Do not re-introduce a whole-world staging clone. The atomic unit is the landblock + delta applied in one update-thread call; the runtime is single-threaded and the root + swap buys nothing."* (2) *"Batching N leaves per staging step is not a fix — measured + 1.8× at N=256, not convergence, and it trips the committed invariant test."* +- **`docs/ISSUES.md`** — F4 established this regression is not in the C3c diff, so it + needs its own issue id (the C3c smoke-test commit `c52ce14a` filed #279 for a different + finding). File it, and reference it from the O1/O2/O3 commit messages. +- **Rollback:** each slice lands as one commit with its own recorded `git revert` SHA, + per the Modern Runtime convention. + +--- + +## (d) Perf-work framing + +**This is modern-runtime infrastructure, not retail-scoped behaviour work.** The delta +apply installs the *identical* `PreparedPhysicsDataCacheLandblock` content that +`TransferTo` publishes today — the same cells, flat cells, EnvCell topology, buildings, +terrain, synthesized outdoor cells, landblock, and owner set. Only the path by which that +content reaches the active root changes, and only the amount of work done to get there. +Collision results, contact planes, walkable polygons, membership, and therefore game feel +are bit-identical. + +The project's render-perf-not-faithfulness-gated rule +(`claude-memory/feedback_render_perf_not_faithfulness_gated.md`) applies: throughput work +that is pixel- and feel-identical does not need a retail-behaviour gate. But because this +is collision, the acceptance bar is still the connected gates plus the user's visual pass +— green unit tests prove nothing about a streaming convergence bug. + +Two guards keep the framing honest: + +1. **The direction of travel is toward retail, not away.** Retail hydrates a cell + synchronously in `CObjCell::init_objects` and refloods the objects associated with it + via `recalc_cross_cells`. A per-landblock delta applied in one update-thread call is + the streaming-shaped version of exactly that. The whole-world clone was the adaptation; + removing it retires an adaptation rather than adding one. +2. **The one thing that could change feel is reflood timing** — owners near the target + re-flooding at commit rather than from a pre-computed staged set. + `CommitTimeRefloodMatchesPrecomputedReflood` (above) is the specific test that turns + that from an assumption into evidence. If that test cannot be made to pass, the perf + framing is void and the slice needs a behaviour gate. + +**Explicitly not a workaround.** Per the no-workarounds rule, note what this is *not*: no +suppression flag, no grace period, no budget loosening, no early-return guard at the +symptom. The root cause is an algorithm that is quadratic in resident-world size, and the +fix is to make it linear by restoring the per-landblock atomic unit the mechanism had +before `6b28ff99`. + +### Measure before and after + +A stripped-after probe should count, per publication: (clone leaves, seal leaves, apply +leaves) and wall-clock for each. F4 measured only the clone (median 19,736 / p90 32,135 / +max 38,021 leaves, median 3.64 ms, 1,584 preparations = 8.53 s CPU in one 4-minute capped +session). The seal was never measured and D2 must beat both. Expected after O3: clone +leaves 0, seal leaves ≈ target payload, apply leaves ≈ target payload, total per +publication well under 100 µs and flat as the ring fills. diff --git a/docs/research/2026-08-02-collision-throughput-handoff/docs-drafts.md b/docs/research/2026-08-02-collision-throughput-handoff/docs-drafts.md new file mode 100644 index 00000000..cc722d76 --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/docs-drafts.md @@ -0,0 +1,109 @@ +# Docs-commit drafts — collision publication-throughput fix (O1/O2/O3) + +Drafted per contract; NOT applied to the repo. Apply in the docs commit after +code review. Register judgment executed as pinned: AD-6 stays retired with a +successor note; the residual timing/order compression gets a NEW row (AD-62). + +--- + +## 1. `docs/architecture/retail-divergence-register.md` + +### 1a. Append to the retired ~~AD-6~~ row (line 113), at the end of column 2 + +> **Successor note (2026-08-02, collision publication-throughput fix +> O1/O2/O3):** the whole-world staging clone, the owner-mutation journal, the +> peer-rebase/retirement cursors, and the zero-managed-byte whole-root +> transfer this row describes were deleted. The shipped mechanism is now the +> per-landblock delta commit this row's retail anchor always pointed at: +> admission captures an O(1) empty target-only staging root +> (`PhysicsEngine.CollisionStagingBuilder`), the seal enumerates one prefix's +> installed keys through the `CollisionWorldState` per-prefix ledgers, and +> `PhysicsEngine.CommitLandblockReplacement` drains the sealed delta into the +> ACTIVE root in one synchronous update-thread call, recalculating every +> associated owner's cross-cells against the live world +> (`ShadowObjectRegistry.ApplyCommittedOwnerReplacement` + +> `RefloodPrefixOwnersAfterReplacement`; retail `CObjCell::init_objects` +> 0x0052b420 → `CPhysicsObj::recalc_cross_cells` 0x00515a30). Equivalence is +> pinned by `CommitTimeRefloodMatchesPrecomputedReflood`; world-size +> independence by `CollisionPreparationCostIsIndependentOfResidentWorldSize`. +> Residual timing/order compression vs retail: AD-62. + +### 1b. New row AD-62 (residual timing/order compression), adaptation class + +| AD-62 | **Adaptation.** Commit-time collision reflood granularity/order: retail runs `CObjCell::init_objects` per CELL at cell hydration and `CPhysicsObj::recalc_cross_cells` per object as each cell loads; acdream runs the equivalent once per LANDBLOCK replacement inside the single synchronous activation call, walking the sealed owner list then the live prefix-owner slots (per-landblock granularity matches the streaming unit, same compression `ShadowObjectRegistry.RefloodLandblock` has always carried). An owner becoming target-associated mid-publication refloods at activation (the prefix-slot sweep) rather than at its own cell's hydration instant; a stationary owner adjacent to the target whose flood would only change through building/EnvCell bridges can carry frame-stale cross-cells between the seal capture and the activation sweep (movers self-heal per `SetPositionInternal`). | `src/AcDream.Core/Physics/PhysicsEngine.cs` (`CommitLandblockReplacement`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`ApplyCommittedOwnerReplacement`, `RefloodPrefixOwnersAfterReplacement`) | Late/stale cross-cell rows for a non-moving seam object for a few frames around a landblock publication — an object collidable through a wall seam or briefly not collidable where new topology landed | Low | `CObjCell::init_objects` 0x0052b420; `CPhysicsObj::recalc_cross_cells` 0x00515a30; `CPhysicsObj::SetPositionInternal` tail 0x00515330 | + +--- + +## 2. `claude-memory/project_physics_collision_digest.md` — DO-NOT-RETRY additions + +> - **Do not re-introduce a whole-world staging clone for collision +> generations.** The atomic unit is the landblock delta applied in one +> update-thread call (`PhysicsEngine.CommitLandblockReplacement`); the +> runtime is single-threaded and a root swap buys nothing. The clone made +> ring load O(N²) (the C3c late-monster-pop-in / stuck-portal-space soak +> failure, issue #280). Deleted 2026-08-02. +> - **Batching N staging-clone leaves per step is not a fix** — measured 1.8× +> at N=256, not convergence, and it trips the committed one-work-unit seal +> invariant. The fix was removing the clone, not tuning it. + +## 3. `docs/research/2026-07-31-atomic-collision-generation.md` — update + +Add a banner at the top: + +> **SUPERSEDED IN PART (2026-08-02).** Steps 2 (whole-world staging clone), 3 +> (owner-mutation journal write-through), 5 (journal coalescing/compaction), 6 +> (peer rebases), 7 (draft retirement cursors), and 8 (whole-root transfer) +> describe machinery deleted by the collision publication-throughput fix +> (O1/O2/O3). The atomicity requirement they served — "the multi-frame build +> must not be observable" — is now met by one synchronous per-landblock delta +> apply under prefix quiescence with commit-time owner refloods against the +> live world (retail init_objects → recalc_cross_cells). The admission +> fairness half (quiescence, prefix mutation permissions, ordered activation) +> is unchanged and still accurate. Deterministic-evidence entries that name +> the journal/rebase/retirement tests refer to tests deleted with the +> machinery; their replacements are +> `CollisionPreparationCostIsIndependentOfResidentWorldSize`, +> `CommitAppliesOneLandblockDeltaInASingleCall`, and +> `CommitTimeRefloodMatchesPrecomputedReflood`. + +## 4. `memory/project_collision_port.md` + +Remove/replace the 37-line block `6b28ff99` added (the starvation-free clone +description) with a pointer to the new mechanism (same content as 1a). + +## 5. `docs/ISSUES.md` — file the regression as its own issue + +> - **#280 — OPEN → fixed pending review: collision staging clone made ring +> load O(N²)** (filed 2026-08-02). The per-publication whole-world staging +> clone (be94bc9b synchronous, 6b28ff99 metered) plus the seal's full-map +> scans cost ~2-3× resident world per landblock publication, so an +> N-landblock ring cost O(N²) and the far ring never converged. F4 measured +> median 19,736 leaves / 3.64 ms per publication; C3c made it user-visible +> (late monster pop-in, extended/stuck portal space, portal-exit pop-in, +> nine-stop soak failure 20260802-143157) but did not cause it. Fix: O1 +> per-prefix installed-key ledger; O2 per-landblock delta commit +> (restores be94bc9b's O(changed) apply); O3 empty staging root + +> commit-time reflood (retail init_objects → recalc_cross_cells) + journal/ +> rebase/retirement machinery deleted. Reference the O1/O2/O3 commits here +> when they land. + +## 6. Milestones/roadmap + +No phase-table change needed: this is Modern Runtime infrastructure follow-up +inside the active campaign context; the C3c smoke-test findings list in +ISSUES (#278 additions) should get items (d)/(e)/(f)-class re-observed after +the soak gate passes. + +## 7. Commit-message notes for the slice commits + +- O1: `fix(physics): #280 O1 - per-prefix installed-key ledger; seal scans and + landblock removals become O(prefix keys)` — behavior-identical; new test + CollisionSealWorkIsIndependentOfResidentWorldSize. +- O2: `fix(physics): #280 O2 - restore per-landblock delta commit (be94bc9b + shape) at PhysicsEngine.CommitLandblockReplacement` — notes: staging-slot + owner-list widening (direct-staged owners), staging-root revoke, zero-byte + commit asserts → O(target payload) bounds (commit-time reflood + dictionary + node inserts allocate; world-size independence pinned by the O3 test). +- O3: `fix(physics): #280 O3 - empty staging root; commit-time reflood + (init_objects → recalc_cross_cells); delete journal/rebase/retirement + machinery` — 9 mechanism tests deleted, 2 contract tests added. diff --git a/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md b/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md new file mode 100644 index 00000000..79d0499a --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md @@ -0,0 +1,4516 @@ +# Implementer progress — Runtime initial-placement continuation executor + +## Reading phase (complete) +- Read contract, runtime-surface.md, retail-notes.md in full. +- Read source: RuntimeInitialCreateResidenceState.cs (full, 960 lines), + InboundPhysicsStateController.cs (full, 903 lines), + RuntimeAuthoritativePositionRouteClassifier.cs (full, 580 lines), + RuntimeEntityObjectLifetime.cs (full, 2134 lines), + RuntimeEntityDirectory.cs (full), RuntimeEntityRecord.cs (full), + ParentAttachmentState.cs (full), RuntimeInitialCreateAdmissionFreezer.cs (full), + RuntimeSetPositionState.cs (targeted: struct defs, CaptureOwnership, + Begin*/Watch/IsCurrent/TryPeek/Consume/Forget*, PrepareMover/Submit/ + Apply/AcknowledgeProjection/PublishCancellation/Forget/LeaveWorld), + RuntimeInitialCreateResidenceStateTests.cs (full, 2657 lines - harness + helpers: Bind/Spawn/AttachDormantBody/Prepare/ApplyQueuedPosition/ + PositionUpdate/ApplyFreshSuccessor/ConvergeSessionClear/PositionAction/ + SetCompletedAdoptionRevision/EntityObserver/PlacementObserver). + +## Key design decisions made during reading +1. AdoptCompletedPlacement resolves runtime-surface.md 3.1 deadlock: + BeginAcceptedPlacementCore/TryBeginExclusiveAuthoredPlacement all reject + while HasRetainedCompletion(key) is true. Executor must call + ConsumeAcknowledgedPlacement directly (via new AdoptCompletedPlacement on + residence state) BEFORE any Position continuation can begin its own + placement. +2. Movement timestamp reconstruction: AcceptedPhysicsTimestamps has + ServerControlledMove but NOT Movement itself. Reasoned from the + "MOVEMENT_TS consumed before discovering SERVER_CONTROLLED_MOVE_TS stale" + comment: HasTimestampMutation==true always implies gate.MovementTimestamp + advanced to update.MovementSequence (movement gate checked first). So + ApplyAcceptedMotion sets MovementSequence=update.MovementSequence + unconditionally when retained, ServerControlSequence=retained + AcceptedTimestamps.ServerControlledMove exactly. Judgment call - documented + in code comment. +3. MirrorGateTimestamps (reads LIVE gate) must NOT be used by the executor + (would stomp other channels' Timestamps fields with the wrong values from + out-of-band-drift live gate). Refactored ApplyAcceptedMotion to a targeted + field update instead - verified behaviorally identical for the legacy path + since gate/snapshot stay in lockstep there. +4. Circular-ownership constraint: deferred-child replay needs + RegisterEntityWithInitialResidence (internal core with beginInitialResidence + semantics). Contract forbids passing RuntimeEntityObjectLifetime itself. + Resolution: lifetime passes a bound delegate + (WorldSession.EntitySpawn, bool) -> RuntimeEntityRegistrationResult into the + executor's ctor, mirroring the existing retirePriorProjection callback + pattern already used in RegisterEntityCore. Narrow single-purpose seam, + not a reference to the owning type. +5. Publish choke point: lifetime passes bound delegates for PublishEntity and + AcknowledgeProjectionAndPublish (private methods -> delegate via method + group, same pattern as existing callback threading). + +## Implementation phase - STARTING NOW +Next: refactor InboundPhysicsStateController.cs to extract ApplyAccepted* +methods shared by legacy TryApply* and the new executor. + +## Implementation progress (continued) +- InboundPhysicsStateController.cs refactored: ApplyAccepted{ObjDesc,Pickup, + CreateParent,Parent,Vector,State,Motion,Position} methods extracted, legacy + TryApply* re-expressed as gate+shared-apply. Removed now-unused + MirrorGateTimestamps. Build clean, 89-test focused gate green after refactor. + FOUND+FIXED a bug during this pass: ApplyAcceptedMotion's retainPayload:false + branch must ALSO stamp nested Physics.Timestamps.Movement/ServerControlledMove + (not just top-level fields) - two pre-existing tests + (RejectedServerControlStillMirrorsConsumedMovementTimestamp, + AutonomousLocalEchoRetainsPayloadButMirrorsAcceptedTimestamps) caught this; + fixed, full 829-test Runtime suite green again. +- RuntimeInitialCreateResidenceState.cs: added CompletedEntry.PlacementAdopted, + AdoptCompletedPlacement, ConsumeExecuted + + RuntimeInitialCreateResidenceExecutorReleaseStatus enum. Updated + IsCompletedCurrent to treat PlacementAdopted as satisfying the placement- + current check without re-querying RuntimeSetPositionState (since adoption + already consumed/removed that tracking entry). Updated AcknowledgeAdoption + to tolerate (skip) re-consuming an already-adopted placement. Build clean, + 48/48 residence tests green. +- New file src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs + written (full algorithm: reentrancy latch, per-key Progress with LeaseId + staleness discard, initial tail (adopt+hook+deferred-child replay), FIFO + drain for all 8 continuation kinds incl. SameIncarnationCreate envelope + staging with buffered publish, Position classify-at-execution-time + + placement Begin/Watch/yield/resume lifecycle, ConsumeExecuted release with + Revised-loop). Constructed in all 3 RuntimeEntityObjectLifetime ctors, + wired into BindEventContext, CaptureOwnership (new + InitialCreateExecutorProgressCount field), and the residence + Forget/Clear choke points (deterministic progress discard, not lazy). + Build clean, full 829-test baseline still green after wiring. + +## Judgment calls made (to report) +1. AdoptCompletedPlacement/ConsumeExecuted new API design (see code comments + in RuntimeInitialCreateResidenceState.cs) - resolves the 3.1 deadlock per + contract's exact prescription. +2. Registration callback threaded as a bound delegate + (WorldSession.EntitySpawn, bool) -> RuntimeEntityRegistrationResult from + the lifetime's ctor, NOT a reference to RuntimeEntityObjectLifetime itself + - satisfies "no circular ownership" per contract while still reaching + RegisterEntityWithInitialResidence for deferred-child replay. +3. Position continuation Rejected/RejectedData routes reuse + InboundPhysicsStateController.ApplyAcceptedPosition by passing + PositionTimestampDisposition.Rejected explicitly (that method internally + routes to the timestamp-only-stamp branch) - avoids a second, divergent + "timestamp only" implementation. +4. HasContact/forcePositionRotation/currentLocalVelocity all derived from + canonical.PhysicsBody when attached, falling back to Inputs.HasContact + (bodyless tests) - per contract's explicit "decide one way, document it, + test it" instruction for HasContact, extended consistently to the other + two live-body-derived facts. +5. ParentAttachmentState.Resolve/CommitProjection has zero callers anywhere + in AcDream.Runtime (grep-verified) - confirmed host-driven, not wired. + Parent/CreateParent continuation apply commits the position-timestamp-only + snapshot mutation + AdvancePositionAuthority/LeaveWorld/Forget/publish + only; does NOT invent a second resolve/commit path. +6. ObjectTableWiring.ApplyEntitySpawn (via RuntimeEntityObjectLifetime. + ApplyAcceptedSpawn) has zero callers anywhere in AcDream.Runtime + (grep-verified) - WeenieDescription tail action commits + RefreshSnapshot+AdvanceCreateAuthority+publish only, explicitly does NOT + drive the object table (host cutover work, out of scope). +7. ResidentCellCleanup implemented as an assert-the-invariant check + (claimed+celless must be IsDeferred) rather than building a destruction + mechanism; the "no cell claimed, no weenie" destruction-mark branch is + left as a recorded no-op (needs live object-table wiring not in scope). +8. Reentrant Execute() for the same RuntimeEntityKey while one is already on + the call stack fails closed via a HashSet latch. +9. Stale Progress (LeaseId mismatch, e.g. GUID/key reuse) is discarded AND + the CURRENT Execute call returns RejectedAuthority (not silently + re-created + retried in the same call) - matches contract's literal + "discard it and fail closed" wording; caller must retry once more. + +## Major design fix discovered during test-driven debugging +Found a genuine architecture conflict: RuntimeInitialCreateResidenceState's +IsCompletedCurrent staleness check compares LIVE record.PositionAuthorityVersion/ +CreateIntegrationVersion/FullCellId/PlacementCommitVersion against values +FROZEN at Complete()-time. But the executor's OWN Apply* methods legitimately +advance these SAME fields while draining (AdvancePositionAuthority, +AdvanceCreateAuthority, SetFullCell, the physics engine's own +AdvancePlacementCommit on a continuation's own SetPosition commit) - this +made Complete()/ConsumeExecuted incorrectly treat the executor's own +controlled progress as an external race and reject with RejectedAuthority. +Root-caused via a debug bisection test (temporarily added, then removed). + +Fix: added CompletedEntry.Expected{PositionAuthorityVersion, +CreateIntegrationVersion,FullCellId,PlacementCommitVersion} - a SEPARATE, +executor-maintained baseline distinct from the FROZEN receipt.Token (which +must stay byte-identical for Complete()'s token-identity match to keep +working across retries). IsCompletedCurrent now compares against +entry.Expected* instead of receipt.Token.*/receipt.FullCellId/ +receipt.PlacementCommitVersion directly. New method +AdvanceExecutorBaseline(record, token) re-syncs Expected* from the record's +CURRENT live values; the executor calls it (a) at the top of ExecuteCore's +loop before every Complete() call (covers a pending continuation placement's +host-driven commit, which happens BETWEEN Execute() calls), and (b) +unconditionally after every ApplyContinuation call in the drain loop (covers +in-call mutations before a yield). Verified this does NOT weaken the +EXISTING admission-slice regression tests (CorruptedPostAcknowledgementAuthorityRetiresProofAndLease +etc.) since those tests never call AdvanceExecutorBaseline - an external, +non-executor-driven bump to these fields is still correctly detected as +stale. All 48 residence tests + 16 new executor tests + 829 baseline pass +together (845 total). + +## Test suite status: 16/16 new tests green, 845/845 total Runtime tests green +Covered: A (2 tests: basic completion + hook; mixed simple continuation +order), C (2: envelope atomicity/buffered-publish/stage-order incl. +PreTailDescriptionAdaptation+Pickup decomposition; envelope retry +non-duplication), D (5: local ordinary interpolate, local teleport placement +lifecycle w/ yield+resume, remote near interpolate, remote far +SetPositionSimple+StopInterpolating, parented-initial AwaitFreshPosition/no +placement), E (2: deferred-child replay consumes exact AdmissionId + +registers child through canonical route; stale AdmissionId cannot consume a +replacement - via ParentAttachmentState directly), G (2: reentrant Execute +for same entity fails closed; reentrant delete of the parent during deferred +child replay abandons without resurrection), H (1: stale Progress LeaseId +via reflection-planted entry discards + fails closed + clean retry), I (2: +reset during AwaitingContinuationPlacement converges every ledger; dispose +after successful execution converges IsConverged). +NOT separately tested (time-budget / defensibility tradeoffs - noted for +final report): F's literal "failure injection between every envelope stage" +(only end-to-end retry-after-full-drain covered, not synthetic mid-stage +crash injection); J (relies on the existing assembly-level +RuntimeDependencyBoundaryTests, which automatically covers the new file - +no new file added since the check is assembly-wide, not per-file); the +ResidentCellCleanup destruction-mark branches (only the "claimed+resident" +and the assert-invariant path are exercised, not the "no cell, no weenie" +destruction-mark path, which is explicitly a recorded no-op pending future +object-table wiring per code comment); SameIncarnationCreate's Position +stage mid-envelope yield+resume (D covers standalone Position yield/resume; +the envelope's OWN Position-stage yield path shares the identical +ApplyPositionAction code but is not independently exercised by an envelope +test with a position stage). + +## ROUND 2: Reviewer feedback (Finding 1, Gaps 2-4, Finding 5 amendment) +Coordinator sent review findings after round-1 delivery. Working through: +- FINDING 5 (production regression, addressed FIRST since flagged most + urgent): ApplyAcceptedMotion's legacy caller was stamping + update.MovementSequence (wire's own stale/rejected value) instead of + gate.MovementTimestamp (post-call gate value) for the timestamp-only + branch. TryAcceptMovementEvent has 3 rejection flavors and only 1 of them + (stale ServerControlledMove) actually advances MOVEMENT_TS; the other two + (bad instance, stale MOVEMENT_TS) leave the gate untouched but the OLD + buggy code would still stamp the wire's stale value into the snapshot. + FIX: re-parameterized ApplyAcceptedMotion to take explicit + (movementSequence, acceptedServerControlledMove) inputs instead of + deriving movementSequence from `update` internally - ONE shared body, two + explicit-input callers. Legacy caller now passes + gate.MovementTimestamp/gate.ServerControlledMoveTimestamp (exact + pre-refactor behavior in all 3 flavors). Executor caller passes + action.Movement.Value.MovementSequence/action.AcceptedTimestamps.ServerControlledMove + (safe there since retention itself gates on genuine acceptance). Added 2 + regression tests to InboundPhysicsStateControllerTests.cs comparing the + FULL EntitySpawn before/after for (1) stale MOVEMENT_TS, (2) instance + mismatch - both assert byte-identical snapshots. 15/15 + InboundPhysicsStateControllerTests pass, 64/64 executor+residence tests + still pass after the signature change. + +- FINDING 1 fixed: narrowed the pre-Complete() AdvanceExecutorBaseline call + in ExecuteCore to only fire when progress.PendingContinuationPlacement.IsValid + (the only legitimate between-calls mutation window). Added 2 regression + tests (ExternalPositionAuthorityMutation.../ExternalFullCellMutation...) + that directly drive Complete()+AdoptCompletedPlacement to the + "completed+adopted, no pending placement" state, then externally mutate + PositionAuthorityVersion/FullCellId, then assert the NEXT Execute() call + observes RejectedAuthority, publishes nothing, and converges. VERIFIED + these tests actually catch the regression: temporarily reverted the guard + to unconditional, confirmed both tests fail (Completed instead of + RejectedAuthority), then restored the fix and confirmed they pass again. + 18/18 executor tests pass (16 + 2 new). + +- GAP 4 done: extended RuntimeInitialCreateExecutedAction with a new + optional ResidentCellCleanupDisposition field + + RuntimeResidentCellCleanupDisposition enum (ResidentUnmarked/ + DeferredUnderLostCellOwnership/NoCellClaimedDestructionMarked). + ApplyResidentCellCleanup now RETURNS the disposition instead of just + asserting. (a) ResidentCellCleanupUnmarksWhenCellClaimedAndAlreadyResident + - engine-backed, real initial placement, same-create Position stage + classifies Interpolate (SameIncarnationCreate source forces + effectiveContact=true, entity already resident so not cellless) -> no + placement needed, ResidentUnmarked recorded. (b) + ResidentCellCleanupFailsClosedWhenClaimedCelllessOutcomeIsNotUnderLostCellOwnership + - PickedUp initial (no placement), same-create Position with + UsePositionFromServer=false classifies NoPositionOperation (no + SetPosition begins at all) -> claimed+celless+not-deferred -> throws + InvalidOperationException, verified via Assert.Throws + message + content. (c) folded into the EXISTING envelope-atomicity test (added + assertion) since that entity already naturally has no claimed cell -> + NoCellClaimedDestructionMarked. 20/20 executor tests pass (18 + 2 new; + case (c) added to an existing test rather than a new one). + +- GAP 3 done, AND IT CAUGHT A REAL BUG: wrote + EnvelopePositionStageRequiringSetPositionYieldsResumesAndPublishesOnceAfterCompletion + (cellless -> SetPosition Position stage forces a mid-envelope yield, + drive prepare/submit/ack, resume, envelope completes). First run: only 1 + of 5 expected Updated events published (expected ObjDesc/Position/State/ + Vector/WeenieDescription). ROOT CAUSE: Publish()'s buffered branch stored + the PER-STAGE "matches" closure (captured at that stage's OWN commit + time, checking one specific AuthorityVersion field) into the buffer - + but WeenieDescription's own AdvanceCreateAuthority() call bumps SIX + authority-version fields at once (Position/State/Vector/Velocity/ + Movement/ObjDesc/CreateIntegration), which invalidated every EARLIER + buffered stage's captured version-equality check by the time the final + flush ran, even though nothing external raced - it was the executor's + OWN later, expected progression. FIX (root cause, not per-callsite + patch): Publish()'s buffered branch now stores a constant `true` + predicate instead of the per-field "matches" closure - IsCurrent (checked + unconditionally by PublishNow) is the only currency guard a buffered + entry needs, since envelope processing dispatches no event until the + flush (no reentrancy window mid-envelope except at a Position yield, + which is independently guarded by ApplyEnvelope's own IsCurrent check + + ResumePendingPlacement). The IMMEDIATE (non-buffered) path is unchanged - + still uses the real matches() check, appropriate there since publish + happens right after each standalone mutation. 21/21 executor tests pass + after the fix (20 + 1 new). + +- GAP 2 done: EnvelopeAbandonedDuringPositionStageYieldPublishesNothingAndConvergesEveryLedger + (delete the entity while AwaitingContinuationPlacement is pending + mid-envelope; asserts ONLY the delete's own Deleted event observed - none + of the envelope's already-committed-but-buffered stages ever publish - + and every ledger converges: residence/progress/active-operation/ + acknowledged-completion counts all zero; a stale retry with the original + token returns RejectedToken). Documented (large comment block, not a + fake test) exactly why the other 6 stage kinds have no reachable yield + boundary (pure synchronous in-memory mutations, no event dispatch until + the buffered flush, so no callback/reentrancy opportunity exists without + adding a diagnostic seam to production code, which is explicitly + forbidden). While building this test, ALSO found and fixed a real (if + currently redundant-with-existing-Forget) defensive gap: DiscardProgress/ + DiscardAll now also forget any in-flight CONTINUATION placement token + (distinct from the residence's own initial-lease placement) - verified + via revert that this specific delete-path test still passes WITHOUT the + fix (TryAcceptDelete's own unconditional Physics.SetPosition.Forget + already cancels any operation for that key), so this is honest + defense-in-depth (DiscardProgress owning cleanup of the state it + introduces) rather than a proven-necessary fix for THIS path - documented + as such in both the code comment and the test comment. + 22/22 executor tests pass (21 + 1 new). + +## ALL 4 REVIEW ITEMS (Finding 1, Gaps 2/3/4) COMPLETE, plus Finding 5 +(production regression) fixed first. + +## ROUND 2 FINAL GATES: ALL GREEN +1. Release build (AcDream.slnx): 0 errors, 21 pre-existing warnings. +2. Focused filter (Residence+Classifier+Executor): 111 passed (89 + 22). +3. Complete Runtime suite: 853 passed (829 + 24: 22 executor + 2 + InboundPhysicsStateController Finding-5 regressions). +4. git diff --check: exit 0, clean. Nothing staged. +Confirmed the narrowed rebaseline (Finding 1) did not break the existing +AwaitingContinuationPlacement yield/resume path - +LocalTeleportContinuationDrivesItsOwnAuthoredPlacementLifecycle still +passes throughout every round of changes. + +## FINAL STATUS (Round 2): ALL GATES GREEN +1. dotnet build AcDream.slnx -c Release: 0 errors, 21 warnings (all + pre-existing, in test projects untouched by this work - confirmed exact + match to the known-count in the task brief). +2. Focused filter (Residence+Classifier+Executor): 105 passed (89 baseline + + 16 new), 0 failed. +3. Complete AcDream.Runtime.Tests: 845 passed (829 baseline + 16 new), 0 + failed. +4. git diff --check: exit 0, clean (only pre-existing line-ending warnings + on files this session did not touch, or normal LF-will-become-CRLF + metadata notices on files this session DID touch - no whitespace-error + content). +Nothing staged (git diff --cached --stat empty) - primary agent to review +and commit. + +## ROUND 3: both independent reviews returned FAIL (3 blockers, 12 majors, +## mandated test-completion list). Combined directive at round3-fixes.md. +## Followed the section-D work order: A1/A2 first, then B1/B2/B3/B4, then +## A3/B5/B6/B9/B10, then B7/B8/B12, then B11, Section C tests throughout. + +### A1 (blocker) — snapshot lockstep. FIXED. +Root cause confirmed exactly as the review described: +InboundPhysicsStateController._snapshots (the legacy merge base) was NEVER +written by the executor's applies - they merged directly against +canonical.Snapshot via the STATIC ApplyAccepted* methods and called +RefreshSnapshot, but _snapshots[guid] stayed frozen at whatever it was when +residence began. The FIRST subsequent legacy TryApplyXxx call would then +re-merge onto that stale base and silently revert every drained fact. +Fix: added gate-less INSTANCE seam methods on InboundPhysicsStateController +(ApplyAccepted{ObjDesc,Pickup,CreateParent,Parent,Motion,State,Vector, +Position,WeenieDescription}Snapshot) that read _snapshots[guid] as the merge +base, run the existing shared static body, write the result back, and return +the merged value. Delegated through RuntimeEntityDirectory (new +ApplyAccepted*Snapshot wrappers calling _inbound.*) since the executor only +holds a RuntimeEntityDirectory reference, not the controller directly. Every +executor Apply*Action now calls the instance seam instead of the static +method. Added the mandated regression test +DrainedAppearanceAndPoseSurviveTheNextLegacyWireApply: drains a standalone +ObjDesc continuation (fresh BasePaletteId), then runs an ordinary legacy +TryApplyVector, asserts the drained palette survives in canonical.Snapshot +after the legacy apply. Verified this fails without the fix by tracing the +exact code path (old static-based merge would read the STALE _snapshots +base and RefreshSnapshot would revert the palette) - did not need to revert +code to prove it since the mechanism is unambiguous from the diff. + +### A2 (blocker) — WeenieDescription merge semantics. FIXED. +ApplyWeenieDescriptionAction now calls the new +ApplyAcceptedWeenieDescriptionSnapshot instance seam, which merges via the +EXISTING private static MergeUntimestampedCreate(retained: _snapshots[guid], +incoming: the raw WeenieDescription packet) instead of a wholesale +RefreshSnapshot of the raw packet. Added the mandated regression test +SameIncarnationWeenieDescriptionMergePreservesRetainedMotionTableId. First +draft of this test used BasePaletteId as the probe field and failed for an +UNRELATED reason I initially misread as a bug: BasePaletteId (and every +other field BuildSameGenerationEvents' Appearance construction touches) is +ALSO independently re-applied by the SAME envelope's OWN dedicated ObjDesc +stage (always present when incoming.Physics is not null) - so a standalone +ObjDesc drain's fresher palette is CORRECTLY superseded by the envelope's +own ObjDesc stage moments later, regardless of A2. Re-designed the test +around MotionTableId, which has NO dedicated envelope stage (WeenieDescription +is the ONLY place it can move) - this cleanly isolates +MergeUntimestampedCreate's "retained wins" rule. Test now: entry 1 = +standalone ObjDesc bump; entry 2 = SameIncarnationCreate whose raw incoming +MotionTableId differs from the entity's original; asserts the ORIGINAL +MotionTableId (retained) survives, not incoming's. + +### B12 (major) — object-table wiring. FIXED; corrected a false "zero +### callers" claim from Round 1/2. +Verified RuntimeLiveEntitySessionController.cs:81 (OnSpawned) drives +Entities.ApplyAcceptedSpawn(canonical, integrationVersion, canonical.Snapshot, +replaceGeneration: Inbound.Disposition is NewGeneration) for EVERY accepted +Create in the non-residence direct-host path - the prior claim of zero +callers was false. ApplyAcceptedSpawn lives on RuntimeEntityObjectLifetime +(needs the ClientObjectTable the executor has no reference to, and cannot +reference RuntimeEntityObjectLifetime directly - circular ownership, same +constraint as the existing _registerDeferredChild delegate). Threaded a new +constructor delegate Func _applyAcceptedSpawn, bound in all 3 RuntimeEntityObjectLifetime +ctors to (canonical, version, spawn, replaceGeneration) => +ApplyAcceptedSpawn(...). ApplyWeenieDescriptionAction now calls it with +replaceGeneration: false always - correct because this tail action ONLY ever +runs for an ExistingGeneration same-incarnation Create (a residence is +admitted into the SameIncarnationCreate FIFO only when preview is +ExistingGeneration; NewGeneration goes through the ordinary top-level +registration path, never this envelope). Added +WeenieDescriptionStageWiresTheObjectTableExactlyOnce: asserts +lifetime.Objects.ObjectCount increases by exactly 1 across a residence drain +whose envelope reaches WeenieDescription (a residence-pending admission +deliberately never wires the object table at the initial Create, so this is +the first point this guid's entry can appear). + +### B1 (major) — shared abandonment routine; typed ResidentCellCleanup +### abandonment; operation-slot-contention as non-abandonment. FIXED. +Added one Abandon(canonical, key) choke point: calls +_residences.Forget(canonical, ...) (retiring the RESIDENCE itself, not just +executor progress - closes a real bug where several rejection paths, +notably ConsumeExecuted's own final-length-mismatch RejectedAuthority branch +and AdoptCompletedPlacement's ConsumeAcknowledgedPlacement-failure branch, +left the completed residence entry sitting fully intact in _completed; a +retry would have re-fetched it via Complete() and REPLAYED every +already-applied continuation from sequence zero), publishes the cancellation, +then DiscardProgress(key) (idempotent defense-in-depth, matching every other +caller's pattern - covers Forget finding nothing to retire). Every +ad hoc "DiscardProgress(key); return RejectedAuthority;" site now routes +through Abandon. ApplyResidentCellCleanup no longer throws for the +claimed+celless+not-deferred invariant violation - returns +RuntimeResidentCellCleanupDisposition? (null signals Abandon); the envelope's +ResidentCellCleanup case checks for null and calls Abandon instead of +letting the exception escape Execute. Rewrote the existing test +ResidentCellCleanupFailsClosedWhenClaimedCelllessOutcomeIsNotUnderLostCellOwnership +from Assert.Throws to assert RejectedAuthority + full ledger convergence + +a stale-token retry returning RejectedToken (proving Abandon actually +retired the residence, not just discarded progress). +Operation-slot contention: added Progress.PositionMergeCommittedForRetry + +PositionMergeCommittedVersion. When TryBeginExclusiveAuthoredPlacement fails +AND the record/its own merge-committed version are still current, this is +NOT abandonment - returns AwaitingContinuationPlacement with +PositionMergeCommittedForRetry left true and NO PendingContinuationPlacement +set, so the NEXT ApplyPositionAction call for the SAME continuation/stage +skips the merge+publish entirely (route = progress.PendingContinuationRoute, +already classified) and retries ONLY the placement-begin - closing the +"double-publish on every retry" hole a naive full re-apply would open. Only +when the record is no longer current OR its PositionAuthorityVersion moved +past the committed merge's own value does this become abandonment. NOT +separately unit-tested (constructing a real operation-slot-contention +scenario needs a second concurrent SetPosition consumer occupying the same +key's slot, which none of the existing test harness helpers construct) - +flagged as a coverage gap in this report; the logic was verified by code +review against RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement's +exact failure conditions (_operations.ContainsKey(key) is the transient +case; HasRetainedCompletion/PositionAuthorityVersion mismatch inside +BeginAcceptedPlacementCore are the genuine-staleness cases my currency +recheck already covers). + +### B2 (major) — apply ordering: mutate -> rebaseline -> publish. FIXED. +Threaded `in RuntimeInitialCreateResidenceToken token` through +ApplyContinuation/ApplyEnvelope/ApplyPositionAction and all 8 non-Position +Apply*Action methods. Each now calls _residences.AdvanceExecutorBaseline +immediately after its own canonical mutation and BEFORE its own Publish call +(previously rebaseline happened in the OUTER drain loop, AFTER +ApplyContinuation returned - i.e. AFTER Publish had already run for the +non-buffered/immediate path, leaving a reentrant-retirement window where a +synchronous Publish observer could see the pre-mutation baseline and +misdetect staleness). Removed the drain loop's blanket +"_residences.AdvanceExecutorBaseline(canonical, token);" call after every +ApplyContinuation - each apply now guarantees its own baseline is current +before any observer can run, so no blanket re-sync belongs there. + +### B3 (major) — residence retirement callback. FIXED. +RuntimeInitialCreateResidenceState.BindRetirementNotification(Action< +RuntimeEntityKey>) - one optional callback invoked in BOTH private Retire +overloads (Entry and CompletedEntry), Forget (both branches), and Clear +(iterating every retired entry) - AFTER the dictionary mutation in each +case. RuntimeEntityObjectLifetime binds it in all 3 ctors, right after +constructing InitialCreateExecution, to +key => InitialCreateExecution.DiscardProgress(key). This closes the gap +where a residence retired through a path OTHER than the executor's own +explicit DiscardProgress call (e.g. TryGetTransaction/TryGetCurrent/Complete/ +AcknowledgeAdoption/AdoptCompletedPlacement/ConsumeExecuted's OWN internal +Retire calls on staleness) would leave the executor's progress AND its +separately-tracked pending continuation placement token orphaned. Kept +ForgetInitialCreateResidence's own explicit DiscardProgress call as harmless +idempotent defense-in-depth (covers the case where Forget finds nothing to +retire at all) and updated its comment to explain the relationship rather +than removing it. + +### B4 (major) — ResumePendingPlacement full record/projection agreement. +### FIXED. +Strengthened to match Complete()'s exact check set: projection.Entity, +SessionLifetimeVersion, PositionAuthorityVersion (BOTH against the +placement token AND against the LIVE canonical.PositionAuthorityVersion - +catches something ELSE moving the record since this placement began, which +the old check could not see), ExactCellId != 0 AND == canonical.FullCellId, +PlacementCommitVersion == canonical.PlacementCommitVersion. Mismatch now +routes through Abandon (was a bare RejectedAuthority before). Renamed the +local placement token variable to placementToken to avoid shadowing the +newly-threaded outer `token` parameter (then removed the parameter again +since the strengthened check never needed the residence token's own +SourcePlacementCommitVersion field - no natural equivalent baseline exists +for a CONTINUATION's placement the way the residence's own token has one +for the INITIAL placement, and adding an unused parameter was worse than +omitting it). + +### A3 (blocker) — HasContact from wire IsGrounded. FIXED. +Removed RuntimeInitialCreateExecutionInputs.HasContact entirely (record now +just UsePositionFromServer/PlayerDistance). ApplyPositionAction's hasContact +now reads `action.Position!.Value.IsGrounded` (WorldSession.EntityPositionUpdate +already carries this field, PositionPack bit 0x4, server-asserted contact at +admission time) - no PhysicsBody?.InContact derivation, no inputs fallback. +Confirmed WorldSession.EntityPositionUpdate.IsGrounded already exists and is +populated by BuildSameGenerationEvents (hardcoded true for +SameIncarnationCreate-sourced positions - matches retail passing arg5=true +directly for that source) and by every test's PositionUpdate helper. Fixed +6 call sites across the test file that constructed +RuntimeInitialCreateExecutionInputs with the now-removed named parameter; +each one's semantic intent (contact true/false) was already independently +preserved by the underlying WorldSession.EntityPositionUpdate.IsGrounded +value at each site, confirmed individually before dropping the parameter +(no test's PASS/FAIL meaning changed). + +### B5 (major) — remove HasAnimations SameIncarnationCreate short-circuit. +### FIXED. +hasAnimations is now `canonical.Snapshot.MotionTableId is {} m && m != 0u` +unconditionally - the `action.PositionSource is SameIncarnationCreate ||` +short-circuit is gone. (Note: the CLASSIFIER's OWN, separate +`effectiveContact = Source is SameIncarnationCreate || HasContact` +short-circuit for the non-local branch is untouched - that is a DIFFERENT +mechanism the review did not flag, confirmed by rereading +RuntimeAuthoritativePositionRouteClassifier.cs's ClassifyAcceptedPosition +before making this change.) + +### B6 (major) — thread route flags through position apply + trace. FIXED. +InboundPhysicsStateController.ApplyAcceptedPosition gained +installPlacementFrame/clearParent bool params. installPlacementFrame gates +the placement-id computation (previously unconditional whenever disposition +was Apply); clearParent gates whether ParentGuid/ParentLocation/ +Physics.Parent get nulled (previously unconditional always-null). Legacy +TryApplyPosition passes true/true (exact prior behavior, verified by +re-deriving the original unconditional logic under installPlacementFrame=true +matches it exactly). The executor passes +installPlacementFrame: route.ApplyPlacementFrameBeforeRouting, +clearParent: route.UnparentBeforeRouting directly - confirmed by rereading +the classifier that UnparentBeforeRouting is false ONLY for the +ForcePosition branch and true for every other accepted route, matching +retail's Gate-A-returns-before-unset_parent structure exactly (no OR/ +redundant condition needed, unlike the pinned hint's phrasing - the route's +own flag already encodes the full decision). Added a NEW execution-rejected +stamp variant (see B10) that also needed these two params threaded through +for its own call site. Extended RuntimeInitialCreateExecutedAction with +ConstrainPhase/StopInterpolating/ZeroVelocity/PreserveHeading/ +SendPositionImmediately/UnparentBeforeRouting, all pulled from the route in +BuildPositionTrace (both the accepted-merge and resume call sites already +pass a fully-populated route, including the RejectedAuthority/RejectedData +factory routes' all-false/None defaults). + +### B7 (major) — deferred-child replay via whole-bucket detach. FIXED. +Added ParentAttachmentState.DetachDeferredCreates(parentGuid) -> +ImmutableArray: atomically removes and returns the +ENTIRE queued bucket for one parent (retail: PartArray::add_child-owning +CreateObject handler detaches the whole netblob list before dispatching, +pseudo-C ~93617 - detach IS the consume, no separate peek-then-remove). +ReplayDeferredChildren rewritten to call this once and iterate the detached +snapshot, rechecking _entities.IsCurrent(canonical) per iteration (unchanged +abandonment semantics) - this structurally eliminates the old peek/consume +loop's stale-AdmissionId race entirely (a Create arriving for the SAME +parent during replay now enqueues into a BRAND NEW queue instance, since the +old one was already removed from the dictionary). Kept TryPeekDeferredCreate/ +ConsumeDeferredCreate/ContainsDeferredCreate/CancelDeferredChildGeneration - +still used by other invariants and by +StaleAdmissionIdCannotConsumeAReplacementQueuedAfterThePeek, which tests +ParentAttachmentState directly (Section C matrix item: the round3-fixes +text asked E-scenarios to go through the executor path - added a NEW +executor-path test, MultipleChildrenQueuedBehindOneMissingParentReplayInFifoOrderThroughTheExecutor, +covering 2 children queued behind one missing parent, both replaying in +order once the parent registers, rather than migrating the existing +ParentAttachmentState-direct test, since that one specifically exercises +the AdmissionId-staleness invariant which is a ParentAttachmentState-level +contract independent of the executor). + +### B8 (major) — rename unreachable ResidentCellCleanup disposition. FIXED. +NoCellClaimedDestructionMarked -> CelllessNoWeenieMarkUnreachable, with an +updated doc comment citing HandleCreateObject retail-notes.md function 1 +lines ~93942-93943 and the shape guarantee at +RuntimeInitialCreateResidenceState.cs:277-284 +(RuntimeInitialCreateResidenceContinuation.HasValidShape enforces +Actions[^2].Kind is WeenieDescription for every admitted envelope, so +retail's matching "no weenie" condition can never be true through this +exact construction). Updated the one test reference +(SameIncarnationEnvelopePublishesNothingUntilEveryStageCommitsThenPublishesInStageOrder). + +### B9 (major) — Parent continuation execution-time revalidation. FIXED. +New ApplyParentContinuation wrapper (called from ApplyContinuation's Parent +case instead of ApplyParentAction directly): re-checks +_entities.TryGetActive(parentGuid) + incarnation match at EXECUTION time; on +mismatch, re-enqueues via _entities.ParentAttachments.Enqueue(parentUpdate) +and records a routine trace entry (Completed, not abandonment) instead of +running ApplyParentAction against a parent that may have been deleted or +replaced between admission and this drain reaching it. Added +ParentContinuationRevalidatesLiveParentAtExecutionAndReDefersOnMismatch: +admits a Parent continuation while the parent is still active, deletes the +parent before the drain runs, asserts the drain still converges cleanly and +the update lands back in ParentAttachmentState's unresolved queue +(UnresolvedRelationCount == 1) rather than crashing or committing against a +gone parent. +(Envelope-side note: RuntimeInitialCreateTailActionKind.Parent has no case +in ApplyEnvelope's switch at all - only CreateParent does, confirmed by +rereading RuntimeInitialCreateResidenceState.cs's HasValidShape/ +SameCreateStage: the position-branch stage only ever admits +CreateParent/Pickup/Position, never standalone Parent - so B9 only applies +to the standalone top-level continuation kind, which is where the fix +landed.) + +### B10 (major) — new stamp variant for execution-time-rejected retained +### Position. FIXED. +Added InboundPhysicsStateController.ApplyAcceptedPositionExecutionRejectedSnapshot +(+ its RuntimeEntityDirectory delegate): stamps Position/Teleport/ +ForcePosition timestamp channels (all three the gate genuinely advanced at +ADMISSION time) without installing any pose/parent/placement field - +distinct from the EXISTING ApplyAcceptedPositionTimestampOnly (the +ADMISSION-time-gate-Rejected case, where only ForcePosition can have moved). +ApplyPositionAction's `!route.Accepted` branch now branches on +action.PositionDisposition: Rejected (admission itself rejected) uses the +existing Rejected-forced merge; Apply/ForcePosition (admission accepted, but +EXECUTION-time classification now rejects) uses the new stamp variant. Not +independently unit-tested with a NEW dedicated test (constructing a retained +action whose ADMISSION disposition is Apply/ForcePosition but whose +EXECUTION-time classification genuinely rejects needs a live-input/record +mismatch crafted between admission and drain - flagged as a coverage gap; +the code path was verified by direct code review of both branches against +InboundPhysicsStateController.ApplyAcceptedPositionTimestampOnly's own +existing doc comment, which independently documents the same +admission-vs-execution distinction this fix formalizes). + +### B11 (major) — Progress creation timing. FIXED. +Execute/ExecuteCore restructured: an existing Progress for a mismatched +LeaseId is discarded AND the call fails closed immediately (preserves the +EXISTING contract/test +StaleProgressLeaseIdIsDiscardedAndFailsClosedThenRetrySucceedsCleanly's +"fail THIS call, retry succeeds fresh" two-step semantics - my first attempt +at B11 broke this test by silently continuing forward in the SAME call +after discarding stale progress; caught immediately by the full-suite run, +reverted to the fail-closed-then-retry shape). A FRESH Progress for the +CURRENT lease id is never created until _residences.Complete(...) actually +reports Completed - PendingPlacement/RejectedToken/RejectedAuthority +outcomes on a call with no PRIOR progress now leave the ownership ledger +(ProgressCount) completely untouched, rather than a placeholder Progress +object sitting in _progress for a residence that has not even resolved its +own initial placement yet. + +## Test suite status after Round 3 +- 5 NEW tests added (all Round 3): DrainedAppearanceAndPoseSurviveTheNextLegacyWireApply + (A1), SameIncarnationWeenieDescriptionMergePreservesRetainedMotionTableId + (A2), WeenieDescriptionStageWiresTheObjectTableExactlyOnce (B12), + MultipleChildrenQueuedBehindOneMissingParentReplayInFifoOrderThroughTheExecutor + (B7), ParentContinuationRevalidatesLiveParentAtExecutionAndReDefersOnMismatch + (B9). +- 1 EXISTING test rewritten from Assert.Throws to typed-abandonment + assertions (ResidentCellCleanupFailsClosedWhenClaimedCelllessOutcomeIsNotUnderLostCellOwnership, + covers B1's ResidentCellCleanup-abandonment path). +- 1 EXISTING test's enum reference updated + (SameIncarnationEnvelopePublishesNothingUntilEveryStageCommitsThenPublishesInStageOrder, + B8 rename). +- 6 EXISTING call sites fixed for the removed HasContact parameter (A3) - + none of their PASS/FAIL semantics changed, only the construction syntax. +- Complete AcDream.Runtime.Tests: 858 passed (853 Round-2 baseline + 5 new), + 0 failed. + +## Known gaps NOT covered by a new dedicated test (time-budget / +## defensibility tradeoffs, reported honestly rather than papered over): +- B1's operation-slot-contention retry path (transient + TryBeginExclusiveAuthoredPlacement failure while the record stays + current) - needs a second concurrent SetPosition consumer occupying the + same key's slot; none of the harness helpers construct that scenario. + Verified by code review against RuntimeSetPositionState's exact failure + conditions instead. +- B10's execution-time-rejected (admission accepted, live classification + rejects) Position stamp path - needs a live-input/record mismatch crafted + between admission and drain. Verified by code review + cross-reference + against ApplyAcceptedPositionTimestampOnly's existing analogous doc + comment instead. +- B2's reentrant-retirement-window closure is a structural/ordering fix + (mutate -> rebaseline -> publish) proven correct by the FULL 858-test + suite staying green (nothing in the existing suite depends on the OLD + ordering) rather than by a dedicated synthetic-reentrancy test - building + a true concurrent-observer reentrancy test that could only pass with the + NEW ordering and fail with the OLD one was judged lower value than the + other coverage gaps given the remaining time budget. +- Section C's full 9-sub-matrix enumeration from round3-fixes.md was not + exhaustively built out; the 5 new tests target the SPECIFIC new/changed + behaviors (A1/A2/B7/B9/B12) plus B1's rewritten test, prioritized over + broad matrix completeness under this round's time budget. + +## ROUND 4: combined re-review findings (round4-fixes.md, R4-1..R4-15). +## Smaller than Round 3; same bar. Implemented in this order: R4-4 (field- +## masked baseline enum/method - foundational, everything else built on it), +## R4-1 (deferred-child replay containment/restore), R4-5+R4-6 (Parent +## discard + trace enums), R4-2 (ResumePendingPlacement forget-before-abandon), +## R4-3 (WeenieDescription apply-window reorder + result check), R4-7/R4-8 +## (test-only trace-flag/wire-vs-body fixes), R4-9 (two mandated tests), +## R4-10 (captured-key threading), R4-11 (ApplyStateAction bool fix), +## R4-12/R4-13 (doc comments + fallback + structural test pin), R4-14 (doc +## comment), R4-15 (register-rows-draft.md rewrite). + +### R4-1 (deferred-child replay containment). FIXED. +ReplayDeferredChildren rewritten: (a) each child's `_registerDeferredChild` +call now runs inside try/catch - an exception records +RuntimeDeferredChildReplayOutcome.Rejected and the loop continues with the +next entry (was: an uncontained exception would have escaped Execute +entirely and stranded every remaining sibling). (b) mid-loop abandonment +(entity no longer current, e.g. a reentrant delete/reset from an earlier +sibling's own registration callback) now calls the NEW +ParentAttachmentState.RestoreDeferredCreates(parentGuid, remainder) - a +new method that PREPENDS the exact unprocessed remainder (original +DeferredParentCreate records, so original AdmissionIds are preserved) ahead +of anything enqueued for the same parent guid after the detach - before +returning false. Previously the whole detached array was simply dropped on +the floor on abandonment; this was a genuine data-loss bug (retail's queued +blobs live on CObjectMaint per-GUID and survive the object; our own +GUID-keyed persistence design already assumed this but the code broke it). +Tests: DeferredChildReplayContainsOneChildsThrowingRegistrationAndContinuesWithSiblings +(3 children behind one parent; child 2's registration is forced to throw +via a reflection-swapped _registerDeferredChild delegate - the standard +fault-injection pattern this file already used for +SetCompletedAdoptionRevision; child 1 and 3 still register, trace shows +Rejected for child 2, no exception escapes Execute) and +DeferredChildReplayRestoresTheUnprocessedRemainderWhenTheParentIsDeletedReentrantlyMidReplay +(2 children; child 1's registration callback reentrantly deletes the +PARENT; child 2's raw Create is confirmed back in the bucket via +ContainsDeferredCreate; asserted residence-lease-count == 1, matching the +EXISTING DeleteDuringDeferredChildReplayAbandonsExecutionWithoutResurrection +precedent - that "1" is child 1's own never-executed residence, not a +leak; first draft of this test wrongly asserted 0 and had to be corrected +after tracing the precedent test's own comment). + +### R4-2 (ResumePendingPlacement leaked the acknowledged completion on +### both failure arms). FIXED. +Both failure arms (projection/record mismatch; ConsumeAcknowledgedPlacement +failure) now call `_physics.SetPosition.ForgetExactPlacement(placementToken)` ++ `PublishCancellation` BEFORE clearing progress.PendingContinuationPlacement +and calling Abandon (forget -> clear -> Abandon, exactly as pinned). +Verified ForgetExactPlacement's own ForgetPlacementCompletionCore already +removes the _acknowledgedPlacementCompletions entry unconditionally (read +the source directly) - no separate ForgetPlacementCompletion call was +needed. Tests: +ExternalFullCellMutationDuringAwaitingContinuationPlacementForgetsThePendingPlacementAndAllowsAFreshOneToBegin +(the reviewer's exact scenario: drive a local-teleport continuation to +AwaitingContinuationPlacement, complete/acknowledge its placement, THEN +mutate FullCellId externally, assert the NEXT Execute fails closed, +AcknowledgedPlacementCompletionCount == 0, and a FRESH +TryBeginExclusiveAuthoredPlacement for the same key succeeds - proving no +retained-completion leak) and +ThirdPartyTryGetTransactionRetireWhileAwaitingContinuationPlacementDiscardsExecutorProgressAndForgetsThePendingPlacement +(R4-9a, see below). VERIFIED both regression tests actually catch the bug: +temporarily stripped the forget/publish calls from both failure arms (kept +a backup copy of the file), reran the R4-2 test - confirmed FAIL +(AcknowledgedPlacementCompletionCount stayed 1, not 0) - then restored the +fix and confirmed both tests pass again. + +### R4-3 (ApplyWeenieDescriptionAction object-table apply window/result). +### FIXED. +Reordered to AdvanceCreateAuthority -> AdvanceExecutorBaseline -> +_applyAcceptedSpawn -> (false result -> return false, letting the EXISTING +caller route to Abandon) -> buffered publish. Previously the rebaseline ran +AFTER _applyAcceptedSpawn and the bool result was silently discarded - +mirrors RuntimeLiveEntitySessionController.cs:87's own gate on that same +call's result (a nested replacement re-entering from within +ObjectTableWiring.ApplyEntitySpawn's own synchronous ObjectAdded/ +ObjectUpdated dispatch must invalidate the remaining tail). Tests: +ObjectTableSubscriberReenteringAWireApplyDuringIngestDoesNotRetireTheResidenceAndTheEnvelopeCompletes +(subscribe to lifetime.Objects.ObjectAdded, reentrantly call TryApplyVector +from inside it - residence survives, envelope completes) and +NestedReplacementDuringObjectTableIngestAbandonsTheWeenieDescriptionStageWithNoFurtherStages +(same subscription point, but reentrantly RegisterEntity a NEWER +incarnation for the SAME guid - Execute returns RejectedAuthority, no +further stages ran). Both use ClientObjectTable's plain C# events directly +(ObjectAdded/ObjectUpdated) rather than reflection - much simpler than +initially planned once I found these were public events. + +### R4-4 (field-masked executor baseline precision). FIXED - foundational, +### done FIRST since every apply method's shape changed. +Added [Flags] enum RuntimeExecutorBaselineFields (PositionAuthorityVersion/ +CreateIntegrationVersion/FullCellId/PlacementCommitVersion) on +RuntimeInitialCreateResidenceState.cs; AdvanceExecutorBaseline now takes a +`fields` parameter and only copies the named field(s) from the live record +into the CompletedEntry's Expected* baseline. Traced every apply method's +ACTUAL field mutations against RuntimeEntityRecord.cs's own method bodies +before assigning masks (not guessed): ObjDesc/Movement(both branches)/ +State/Vector move NONE of the four tracked fields (their own +AdvanceXxxAuthority methods only bump ObjDescAuthorityVersion/ +MovementAuthorityVersion+MovementCommitVersion/StateAuthorityVersion+ +PhysicsStateMutationVersion/VectorAuthorityVersion+VelocityAuthorityVersion +respectively - VelocityAuthorityVersion is NOT one of the four tracked +fields) - AdvanceExecutorBaseline calls REMOVED entirely at these 4 sites. +Position/Parent/CreateParent (applied branch) move PositionAuthorityVersion +only. Pickup moves PositionAuthorityVersion + FullCellId (SetFullCell(0,0)). +WeenieDescription's AdvanceCreateAuthority moves PositionAuthorityVersion + +CreateIntegrationVersion (confirmed against its exact body). The pre- +Complete pending-placement-window rebaseline in ExecuteCore now masks +FullCellId + PlacementCommitVersion only (the sole legitimate between-calls +mutation, RuntimeSetPositionState's own commit machinery). R4-5's NEW +Parent-discard branch (ApplyParentPositionTimestampOnly/ +ApplyCreateParentPositionTimestampOnly) correctly calls NO +AdvanceExecutorBaseline at all - traced that ApplyPositionTimestampOnly +(the shared static body both go through) only writes PositionSequence/ +nested Physics.Timestamps.Position, never any AdvanceXxxAuthority method. +Test: FieldMaskedBaselinePrecisionDetectsAnExternalPositionRaceDuringAnUnrelatedObjDescPublish +(FIFO = ObjDesc then Vector on a parented/no-placement residence; an +observer bumps PositionAuthorityVersion externally during ObjDesc's OWN +publish; asserts the drain detects this at ConsumeExecuted - RejectedAuthority, +not Released - proving ObjDesc's own apply correctly did NOT blanket-rebaseline +and silently absorb the race). + +### R4-5 (stale-parent DISCARD, not re-Enqueue) + R4-6 (trace enums). FIXED. +Added RuntimeParentRelationOutcome{Applied, DiscardedStaleParent} and +RuntimeDeferredChildReplayOutcome{Registered, ReDeferred, Rejected}, both +threaded onto RuntimeInitialCreateExecutedAction (renamed the old +`bool DeferredChildRegistered` field to `RuntimeDeferredChildReplayOutcome? +DeferredChildOutcome`, added a new `RuntimeParentRelationOutcome? +ParentRelationOutcome` field). ApplyParentContinuation's stale-parent +branch (standalone Parent) no longer calls ParentAttachments.Enqueue - +instead calls the NEW ApplyParentPositionTimestampOnly (the SAME +ApplyAcceptedParentSnapshot->ApplyPositionTimestampOnly merge body +ApplyParentAction's own first step already used, but stops there - no +AdvancePositionAuthority/LeaveWorld/Forget/publish) and traces +DiscardedStaleParent. Added the ANALOGOUS revalidation to the envelope's +CreateParent stage too (this never existed before at all - Round 3 B9 only +touched the standalone Parent kind, explicitly noting the envelope path +had no revalidation) via a new ApplyCreateParentContinuation wrapper + +ApplyCreateParentPositionTimestampOnly helper; CreateParentUpdate carries +no ParentInstanceSequence at all (confirmed from its own record definition +and the TryApplyCreateParent doc comment: "unlike standalone ParentEvent it +carries no parent INSTANCE_TS"), so only addressability is revalidated +there, never incarnation match. UPDATED the existing Round 3 B9 test +(renamed ParentContinuationRevalidatesLiveParentAtExecutionAndReDefersOnMismatch +-> ...AndDiscardsOnMismatch): asserts ParentRelationOutcome.DiscardedStaleParent +in the trace and UnresolvedRelationCount == 0 (was asserting 1, i.e. the +OLD re-defer semantics) - this was the ONE pre-existing test that broke +after the R4-5 rewrite, exactly as expected, and was updated per the +directive's explicit instruction. + +### R4-7 (test-only: wire-IsGrounded-vs-body-contact). FIXED. +Parameterized the PositionUpdate test helper with `isGrounded = true` +(default preserves every existing call site's behavior). Deleted the 3 +stale `ForceContact(canonical, inContact: true)` calls + their misleading +"matching this test's ... premise" comments at LocalOrdinaryPosition.../ +RemoteNearContactPosition.../RemoteFarPosition... - none of them affect +routing anymore since Round 3 A3 (HasContact reads ONLY wire IsGrounded). +Added 2 new disagree-both-ways tests: +LocalOrdinaryPositionRouteFollowsWireGroundedTrueWhenBodyContactIsFalse +(wire true / body forced false -> Interpolate, i.e. route follows wire) and +RemotePositionRouteFollowsWireGroundedFalseWhenBodyContactIsTrue (wire +false / body forced true -> NoPositionOperation). Kept the ForceContact +helper itself (still used by these 2 new tests to construct the +disagreement). + +### R4-8 (D-matrix trace-flag assertions). FIXED. +Added the missing ConstrainPhase/UnparentBeforeRouting/HookPhase/ +ZeroVelocity/StopInterpolating assertions to the 5 existing D-matrix tests +(local ordinary, local teleport, remote near, remote far, projectile) per +each route's own classified values (cross-checked against +RuntimeAuthoritativePositionRouteClassifier's exact returned route structs, +not guessed). + +### R4-9 (two missing mandated tests). FIXED. +(a) ThirdPartyTryGetTransactionRetireWhileAwaitingContinuationPlacementDiscardsExecutorProgressAndForgetsThePendingPlacement: +drives a local-teleport continuation to AwaitingContinuationPlacement +(placement begun+watched, NOT yet acknowledged), externally bumps +PositionAuthorityVersion, then calls +lifetime.InitialCreateResidences.TryGetTransaction(canonical, out _) +directly (the third-party path, not through Execute) - asserts it returns +false (staleness detected), then asserts executor progress/residence-lease/ +active-operation/watch/acknowledged-completion counts are ALL zero (B3's +notification edge correctly forgot the pending continuation placement, not +just the residence's own initial-lease placement) and a fresh placement +can begin. (b) is the ExternalFullCellMutationDuringAwaitingContinuationPlacement... +test already covered under R4-2 above. + +### R4-10 (captured-key threading through every Abandon call site). FIXED. +Threaded `RuntimeEntityKey key` as an explicit parameter through +ApplyContinuation/ApplyParentContinuation/ApplyEnvelope/ApplyPositionAction/ +ResumePendingPlacement (all captured ONCE in ExecuteCore from +`canonical.Key is not { } key` at the very top). Replaced all 19 +`canonical.Key ?? default` occurrences (18 Abandon call sites + the +ExecuteCore Released-branch receipt construction) with the threaded `key`. +This is not purely cosmetic: `canonical.Key ?? default` re-derives the key +from the LIVE record at each call site, which produces `default` (WRONG - +does not match the Progress dictionary's actual key) if canonical.Key has +already gone null (e.g. LocalEntityId released) by the time Abandon runs - +DiscardProgress(default) would silently fail to clean up the REAL stale +Progress entry. The threaded key is trusted/stable for the whole Execute +call. ApplyPositionAction's own top guard (`canonical.Key is not {} key`) +was simplified to `canonical.Key != key` since key is now a parameter, not +a fresh pattern-bind. + +### R4-11 (ApplyStateAction BecameHidden currency-failure now returns +### false, not true). FIXED. +Changed `return true;` to `return false;` in the BecameHidden branch's +currency-failure check - the EXISTING caller (`if (!ApplyStateAction(...)) +return Abandon(...)`) already converts false -> Abandon correctly, so no +other change was needed. Documented via a new doc comment on the method +explaining WHY this specific path is not independently unit-tested with a +live reentrancy seam: traced RuntimeCollisionReportingState.LeaveWorld -> +ForceEnd -> EndExpiredObjectCollisions and confirmed it returns immediately +whenever `_owners` has no established collision record for this key (line +~1115-1119) - which is ALWAYS true for a residence-fresh entity that has +never run a real collision batch, so no observer callback can ever fire +from this call site through this harness. Did not fake a seam; documented +per the round's explicit escape valve for this exact finding. + +### R4-12 (ForcePosition parent-retention doc + structural test pin). FIXED. +Added a code comment at InboundPhysicsStateController.ApplyAcceptedPosition's +`parentGuid` computation citing retail Gate A (retail-notes.md function 3, +"GATE A: local-player force-position self-echo shortcut" - the early return +before CPhysicsObj::unset_parent). Extended the EXISTING ForcePosition test +(ForcePositionContinuationRecordsSetPositionSimpleWithPreservedHeadingAndNoParentClear): +attaches a parent via lifetime.Entities.TryCommitParent BEFORE the +ForcePosition update (gate-satisfying positionSequence match verified +against Spawn()'s own default), then asserts BOTH Position and +ParentGuid/ParentLocation are non-null after the drain - pinning the +deliberate combined shape deliberately, per the directive. + +### R4-13 (HasAnimations Physics?.MotionTableId fallback). FIXED. +`hasAnimations` in ApplyPositionAction now reads +`(canonical.Snapshot.MotionTableId ?? canonical.Snapshot.Physics?.MotionTableId) +is {} m && m != 0u` - exact pinned formula. No dedicated new test (NOTE +priority per the directive); existing HasAnimations-adjacent tests +(ForcePosition non-animated route etc.) continue to pass unchanged since +top-level MotionTableId is populated in every existing test fixture. + +### R4-14 (AwaitingContinuationPlacement doc comment). FIXED. +Added a doc comment on the enum value explaining the two distinct flavors +sharing this one status (ordinary token-available case vs Round 3 B1's +operation-slot-contention case where TryGetPendingContinuationPlacement +returns false) and the correct caller action for each. + +### R4-15 (register-rows-draft.md rewrite). FIXED - scratchpad-only, no +### docs/ edits. +Row A: dropped the MoveOrTeleport co-anchor (confirmed retail-notes.md +never decompiled MoveOrTeleport's own internals; the ONLY confirmed +HasAnims call site is inside HandleReceivedPosition itself, line ~92992), +reworded "running cycle" -> "non-empty animation queue (anim_list.head_ != +0)", updated the divergence formula text to match R4-13's new fallback. +Row C: broadened from "the 3 no-placement routes" to "NO route runs a live +ConstrainTo, including SetPosition routes" per the directive, citing the +3 ConstrainTo call sites at retail-notes.md lines ~93007/~93024/~93041. +Row D: rewrote across the THREE ApplyResidentCellCleanup branches - +claimed+celless+undeferred is now correctly described as a typed +ABANDONMENT (not "a recorded fact" - that was wrong even before Round 4, +since Round 3 B1 already changed this to Abandon; the register draft just +hadn't caught up), the deferred flavor correctly delegates retail's +AddObjectToBeDestroyed (93933) to the lost-cell/deferred owners, and the +claimedCell==0 branch is explicitly marked NOT a divergence (structural +shape guarantee, nothing to diverge from). Row E: softened the "no retail +per-step notice" claim - retail DOES emit exactly ONE notice +(ECM_Physics::SendNotice_CreateObject, ACCObjectMaint::CreateObject +0x00558870 step 11) per Create, just not N per-internal-step; kept the +consumer-facing risk warning unchanged. Row F: REMOVED from the register +draft entirely (internal refactor debt between two not-yet-unified +Position-apply paths, not a retail divergence) - replaced with (a) a code +comment on InboundPhysicsStateController.TryApplyPosition's doc comment and +(b) a new "ISSUES draft" section in the scratchpad file for eventual +docs/ISSUES.md inclusion. Added Row G (new): the executor's canonical-cell +semantics (refreshPosition: false), previously only a code comment, now +also a proper register row citing the classifier comment + retail +HandleReceivedPosition-never-writes-a-resident-cell fact. + +## ROUND 4 FINAL GATES: see final report for exact totals. + +--- + +# CUTOVER SLICE C0 — Runtime bridge + live inputs (new implementer session) + +Worked from c0-contract.md (pinned), 2026-08-02-placement-cutover.md, +2026-08-02-cutover-route-inventory.md, 2026-08-02-runtime-continuation- +executor-handoff.md. HEAD at start 27e05b99. + +## Discovery that changed my design: the observer/sink pipe already exists +Before designing C0-1, grepped for `IRuntimePlacementObserver`/ +`IRuntimePlacementProjectionSink` production usage - found +`RuntimePlacementProjectionSubscription` (Runtime-owned, +`IRuntimePlacementObserver`), `RuntimePlacementPresentationSink` (App), +`HeadlessRuntimePlacementProjectionSink` (Headless) ALL already exist and +are production-wired (`GraphicalSessionEventRoute`/`HeadlessSessionHost`). +The cutover-route-inventory.md's "zero production IRuntimePlacementObserver" +claim is stale - superseded by a slice landed after that doc. What's still +genuinely dormant: nothing ever PUBLISHES into the channel in production, +because `Execute`/`RegisterEntityWithInitialResidence` still have zero +production callers. This meant C0-1 could NOT touch App/Headless sink +implementations (hard rule anyway) - the new `ExecutorCompleted` Kind will +sit unhandled by those sinks (return false) until a LATER cutover slice +updates them, but since Execute has no production caller today this never +fires in production. Documented this explicitly in code comments. + +## C0-1: executor → placement-channel completion bridge. DONE. +Design (pinned contract's suggested shape, exactly): extended the +vocabulary, not the plumbing. New `RuntimePlacementProjectionKind. +ExecutorCompleted` (append-only, no exhaustive-switch breaks found anywhere +in src/tests via grep). New `RuntimeSetPositionState.PublishExecutorCompletion +(record, portal=default)`: builds a FRESH token via the SAME +`_nextProjectionSequence` counter every other publish uses (preserves +temporal/exact-head ordering), but derived from the CANONICAL RECORD's +current authority/version/cell facts (`_entities.SessionLifetimeVersion`, +`record.FullCellId`, `_physics.ExpectedCollisionGeneration(record.FullCellId)`) +rather than an Operation snapshot - correct because by the time Execute +reaches `Released`, the residence's/continuation's own operation is already +gone (adopted/acknowledged earlier in the SAME drain). `AcknowledgeProjection` +gained one extra Kind in its existing `Discard`-only fast path +(`Discard or ExecutorCompleted` -> remove + retire quiescence, no Operation +lookup) - there is no operation backing an ExecutorCompleted receipt, exactly +like Discard. +Correlation (the "reachable from/correlated with" ask): did NOT put the rich +internal `RuntimeInitialCreateExecutionReceipt` on the PUBLIC +`RuntimePlacementProjectionSnapshot` (would need public exposure of an +entire internal enum/struct family, or risk CS0053 inconsistent-accessibility +if done wrong) - instead the executor keeps a private +`Dictionary` overwritten +per-key on each completion (bounded by live entity count, never +accumulates), queried via `TryGetCompletionReceipt(in RuntimePlacementProjectionToken)` +which verifies BOTH Entity and Sequence match before returning true - a +host/test correlates purely through the PUBLIC token identity every other +Kind already uses. The executor calls `PublishExecutorCompletion` exactly +once, at `ExecuteCore`'s `Released` exit (canonical still provably current +there). +Tests: `PublishExecutorCompletion_PublishesAcknowledgeOnlyReceiptAndConverges`, +`PublishExecutorCompletion_RespectsExactHeadOrderingAcrossEntities` +(RuntimeSetPositionStateTests.cs, isolated unit level); +`ExecutorCompletion_PublishesOnTheSamePlacementStreamCorrelatedWithTheFullReceipt`, +`ExecutorCompletion_ObservedOnlyAfterAnyContinuationPlacementInFifoOrder` +(RuntimeInitialCreateContinuationExecutorTests.cs, full Execute-drain +integration, the second proving continuation-Place-then-ExecutorCompleted +FIFO ordering). + +## C0-2: Runtime-side live inputs. DONE. +UsePositionFromServer: grepped named-retail decomp for +`CommandInterpreter::UsePositionFromServer`/`SetAutonomyLevel`/ +`autonomy_level` - found `result = this->autonomy_level != 2` (pseudo-C +699510), default `autonomy_level = 2` at construction (699752) AND at +`command_line_autonomy_level` (1088429, itself `0x2` by default) - autonomy +is a STARTUP/command-line-only knob in retail; no in-game caller of +SetAutonomyLevel exists anywhere in the decomp. Added the exact mirror to +`RuntimeCharacterState` (the "character-option owner" the contract named): +`FullAutonomyLevel=2u` const, `AutonomyLevel` (Volatile-read uint, +default 2), `UsePositionFromServer => AutonomyLevel != FullAutonomyLevel`, +`TrySetAutonomyLevel(level)` (rejects >2, exact retail rule). Reset in both +`ResetSession`/`Dispose`; added `AutonomyIsDefault` to +`RuntimeCharacterOwnershipSnapshot`/`IsConverged`. +PlayerDistance: grepped `LiveEntityNetworkUpdateController.cs` (App, +read-only) for the legacy remote path's own distance basis (cutover-routes.md +route 4) - confirmed `Vector3.Distance(worldPos, localPlayerPos)` where +`localPlayerPos = _playerController?.Position ?? Vector3.Zero` (the live +PHYSICS-CONTROLLER position, never a record snapshot). Bound source: +`RuntimeLocalPlayerMovementState.Controller?.Position ?? Vector3.Zero` - +same `PlayerMovementController` type. +Executor: `RuntimeInitialCreateContinuationExecutor.BindLiveInputs(Func, +Func)` (nullable seams, throws on double-bind matching +`BindGeneration`'s convention), `ResolveInputs(canonical, inputs)` computes +the EFFECTIVE `RuntimeInitialCreateExecutionInputs` ONCE per `Execute` call +(bound source wins; unbound falls back to the caller struct field-by-field) - +PlayerDistance uses THIS entity's own currently-accepted position +(`Snapshot.Physics?.Position ?? Snapshot.Position`, the same field +`CanonicalSetupTableId`-adjacent code already trusts) vs the bound live +player position. Documented the one-shot-per-Execute-call granularity as +inherited from the EXISTING `inputs` parameter shape, not a new limitation +I introduced - out of C0-2's scope to refine to per-continuation freshness. +`RuntimeEntityObjectLifetime.BindLiveInputs` forwards to the executor +(mirrors `BindEventContext`'s existing fan-out shape). `GameRuntime.cs` wires +the REAL sources right after `BindEventContext`, since `RuntimeCharacterState`/ +`RuntimeLocalPlayerMovementState` are constructed AFTER `RuntimeEntityObjectLifetime` +in `GameRuntime`'s own sequence (verified exact construction order first). +Tests: `RuntimeCharacterStateTests.cs` (`AutonomyLevel_DefaultsToFullAndMirrorsRetailUsePositionFromServer`, +`ResetSession_RestoresAutonomyLevelToFull`); +`RuntimeInitialCreateContinuationExecutorTests.cs` +(`BindLiveInputs_DrivesClassificationFromTheBoundSourcesInsteadOfTheCallerStruct` +- proves bound-wins AND live-read-not-cached-at-bind-time by flipping the +captured bool between two entities' drains; +`BindLiveInputs_ThrowsOnASecondBindAndUnboundExecutorsUseTheCallerStructUnchanged`). + +## C0-3: exact-Setup mover chain end-to-end. DONE. +New `RuntimeSetPositionState.TryPrepareAndSubmitAuthoredPlacement(record, +token, operationKind, flags, IPreparedCollisionSource, gameTime, out outcome, +placementClass=Ordinary, portal=default, ...scatter/shadow-offset params)`: +reads the CANONICAL Setup table id via the EXISTING private +`CanonicalSetupTableId(record)` (same field `CapturePreparationAuthority` +already trusts - never a caller-supplied id), takes the retail "genuine no +Setup" dummy path (`RuntimeSetPositionMoverSetup.ResolvedAbsent`) when that id +is 0, else calls `collisionSource.ReadSetupCollision(setupTableId)` and maps +Missing/Corrupt -> `RetrySetupUnavailable` (per `RuntimeSetPositionMoverSetup`'s +own doc-comment distinction between "not arrived yet" and "resolved absent" - +never manufactures a fallback while a real read is in flight) or Loaded -> +`RuntimeSetPositionMoverSetup.Resolved(id, data)`, then chains straight into +the EXISTING `PrepareMover` -> `SubmitPreparedPlacement`. Pure wiring - zero +changes to `PrepareMover`/`RuntimeSetPositionMoverPreparer.TryBuild`/ +`SubmitPreparedPlacement`'s own validation semantics (per the contract's +explicit "wiring, not behavior change" constraint) - confirmed by re-reading +both untouched. +Tests (RuntimeSetPositionStateTests.cs, new `FakeCollisionSource : +IPreparedCollisionSource` test double, only `ReadSetupCollision` implemented +- others throw `NotSupportedException` since C0-3 exercises only that one): +`TryPrepareAndSubmitAuthoredPlacement_ChainsSetupReadThroughPrepareMoverToSubmit` +(authored two-sphere Setup reaches `SubmitPreparedPlacement` and +`TryGetPreparedMoverSphereCount` byte-exactly == 2, matching the existing +preparer tests' own expectations) and +`TryPrepareAndSubmitAuthoredPlacement_YieldsRetryOnAMissingSetupReadWithoutMutatingStage` +(Missing status -> `RetrySetupUnavailable`, operation stays +`AwaitingPreparation`/`IsPlacementCurrent` true, no prepared-mover sphere +count recorded - genuinely retryable, not a dead token). + +## C0-4: TryCommitParent/CommitWithdrawal cancellation asymmetries. DONE, +## both confirmed at source exactly as the inventory claimed. +(a) `RuntimeEntityObjectLifetime.TryCommitParent` (944-974 pre-fix) had +NEITHER `ForgetInitialCreateResidence` NOR `Physics.SetPosition.Forget` - +confirmed by direct read, contrasted against the sibling +`CommitPositionChannelUpdate` (used by `TryApplyParent`/`TryApplyCreateParent`) +which has BOTH. Fixed: added the identical +`ForgetInitialCreateResidence` -> `Physics.SetPosition.Forget` -> +`PreferCancellation` -> pass to `AcknowledgeProjectionAndPublish` sequence. +Deliberately did NOT add `Physics.CollisionReports.LeaveWorld` (present in +`CommitPositionChannelUpdate` but outside the contract's explicit +"residence/placement-family cancellation" scope, and I have no retail +citation that a STAGED parent-attach commit should also force a collision +leave-world at this exact point) - flagging this as a considered, deliberate +non-addition rather than an oversight. +(b) `CommitWithdrawal` (1401-1422 pre-fix) called `ForgetInitialCreateResidence` +but not `Physics.SetPosition.Forget` - confirmed by direct read, contrasted +against `TryApplyPickup`/`CommitAcceptedParentCellless`/`TryAcceptDelete` +which all cancel both. Fixed symmetrically (added the ordinary Forget + +PreferCancellation into the existing `cancellation` variable already threaded +to `AcknowledgeProjectionAndPublish`). +Tests, three total, each begins an ACTUAL in-flight SetPosition operation +that has already reached `SubmitPreparedPlacement`'s pending-Place stage (a +still-`AwaitingPreparation`, never-submitted operation produces NO Discard +receipt at all when cancelled - `CancelCoreDeferred` only converts an +EXISTING pending projection into a Discard; there is nothing to discard if +nothing was ever published - this cost one debugging round, see below): +`RuntimeInitialCreateResidenceStateTests. +TryCommitParent_CancelsActiveInitialResidenceAndItsPendingPlacement` +(residence's OWN placement, still unacknowledged, is the thing cancelled - +both Forget calls fire but only one finds anything, `PreferCancellation` +picks it, exactly one Discard observed); `RuntimeSetPositionStateTests. +TryCommitParent_CancelsASeparateActiveOrdinaryPendingPlacement` and +`CommitWithdrawal_CancelsAnActiveOrdinaryPendingPlacementSymmetricallyWithPickup` +(plain `RegisterEntity`, no residence at all - isolates the SECOND, +previously-missing Forget call specifically). All three assert the Discard +sits at the SAME sequence as the original Place (Revision bumped), then +explicitly acknowledge it and assert `PendingProjectionCount == 0` - a +cancelled-but-unacknowledged receipt stays IN the pending set (replaced, not +removed) until a host consumes it, same as every other in-flight-cancel path +in this codebase. + +## Debugging round (all 4 caught by the focused-filter run, all root-caused +## and fixed, not worked around): +1. Three C0-4 tests initially asserted a Discard would be published from + cancelling a placement operation still in `AwaitingPreparation` + (never submitted) - traced `CancelCoreDeferred` and confirmed it only + converts an EXISTING `_pendingProjection` entry to Discard + (`operation.ProjectionSequence != 0UL` gate); an unpublished operation + just disappears from `_operations` with no receipt, which is CORRECT + (nothing was ever promised to a host). Fixed the TESTS to reach + `SubmitPreparedPlacement`'s pending-ack stage first, not the production + code. +2. Two of those same tests then asserted `PendingProjectionCount == 0` + immediately after cancellation - wrong; a Discard REPLACES the pending + entry at the same sequence (Revision+1), it does not remove it. Fixed the + assertions to expect 1, then explicitly acknowledge, then expect 0. +3. `TryCommitParent_CancelsActiveInitialResidenceAndItsPendingPlacement`'s + `Prepare(..., RuntimeSetPositionMoverSetup.ResolvedAbsent)` failed with + `InvalidData` because `Spawn(guid, 1)` in that file defaults + `setupId: 0x02000001u` (nonzero), mismatching `ResolvedAbsent`'s claimed + "no Setup at all". Fixed by passing `setupId: null` explicitly (matching + the file's OWN existing convention for this exact scenario, e.g. + `ResetSnapshotsAllResidenceOwnersBeforeReentrantDiscardObserver`). +4. `BindLiveInputs_DrivesClassificationFromTheBoundSourcesInsteadOfTheCallerStruct` + failed `AcknowledgeProjection` on a SECOND entity's placement - not exact + head. Root cause: the FIRST entity's full drain published its own + `ExecutorCompleted` receipt (C0-1) which I never acknowledged before + moving on to the second entity - this is CORRECT exact-head behavior + (great incidental proof C0-1's ordering guarantee holds), not a bug. + Fixed the test to peek+acknowledge the first completion before + proceeding. + +## Final gates (all green) +1. `dotnet build AcDream.slnx -c Release`: 0 errors, 21 warnings (all + pre-existing, identical set to the executor handoff's baseline - zero + new warnings from this slice). +2. Focused filter (RuntimeInitialCreateContinuationExecutorTests| + RuntimeInitialCreateResidenceStateTests|RuntimeSetPositionStateTests| + RuntimePlacementProjectionSubscriptionTests|RuntimeCharacterStateTests| + RuntimeEntityObjectLifetimeTests): 247/247 passed. +3. Complete `AcDream.Runtime.Tests`: 916/916 passed (903 baseline + 13 new: + 2 C0-1 unit + 2 C0-1 integration + 2 C0-2 executor + 2 C0-2 character-state + + 2 C0-3 + 1 C0-4 residence + 2 C0-4 set-position-state). +4. Complete solution (`dotnet test AcDream.slnx -c Release -m:1`): every + project passed - App 4027/3 skip, Bake 15, Cli 4, Content 124, Core.Net + 762, Core 4242/1 skip, Headless 76, Runtime 916, UI.Abstractions 543. + 0 failed anywhere. +5. `git diff --check`: clean (only pre-existing LF-will-become-CRLF + metadata notices, no whitespace-error content). +6. `git status`: exactly the 9 files this slice touched, plus the 8 + pre-existing protected dirty paths untouched (never staged/committed). + +## Files changed (Runtime + Runtime.Tests only, no App/Headless production, +## no staging/commits) +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs (C0-1 Kind+publish+ + ack branch; C0-3 chain method; +`using AcDream.Content;`) +- src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs + (C0-1 completion-receipt correlation + publish call site; C0-2 + BindLiveInputs/ResolveInputs) +- src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs (C0-2 + BindLiveInputs forwarder; C0-4 TryCommitParent/CommitWithdrawal fixes; + +`using System.Numerics;`) +- src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs (C0-2 autonomy + level/UsePositionFromServer) +- src/AcDream.Runtime/GameRuntime.cs (C0-2 wiring the real sources; + +`using System.Numerics;`) +- tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs (C0-1, + C0-3, C0-4 tests + FakeCollisionSource; +`using AcDream.Content;`/ + `AcDream.Content.Pak;`) +- tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs + (C0-1, C0-2 tests + PlacementObserver fake) +- tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs + (C0-4 residence-side test) +- tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs (C0-2 + autonomy tests) + +## Still dormant / no production caller flipped (per pinned scope) +Nothing in App/Headless was touched; `Execute`/`RegisterEntityWithInitialResidence` +still have zero production callers (unchanged from the executor handoff). +`RuntimePlacementPresentationSink`/`HeadlessRuntimePlacementProjectionSink` +will need an `ExecutorCompleted`-handling branch added when a LATER cutover +slice (C1+) actually starts calling `Execute` in production - flagging this +explicitly as the next slice's concern, not a gap in C0. + +**SUPERSEDED by the review fix round below**: the App/Headless sink +untouched-claim above no longer holds - F1 sanctioned a scoped exception +(exactly 3 sink files). See the fix-round section for the full disposition. + +--- + +# C0 REVIEW FIX ROUND (F1-F5) + +Both independent reviews returned FAIL with converging findings. Per the +coordinator: all five retail semantic questions verified CLEAN (autonomy_level +!= 2 derivation exact; distance basis matches retail + legacy path fallback; +mover chain preserves prerequisite-B exactness; ExecutorCompleted +unambiguously acknowledge-only; the LeaveWorld omission in TryCommitParent is +REQUIRED per retail set_parent 0x00515A90:283832-283833's single gated +leave_world - a second one would double-leave-world with no retail +counterpart). + +## F1 (MAJOR, arch) - sink acknowledge-and-ignore. FIXED. SCOPE EXPANSION +## SANCTIONED for exactly 3 files, no route flips, no other App/Headless changes. +Root cause: `HeadlessRuntimePlacementProjectionSink.cs:51` (`is not Place -> +false`), `RuntimePlacementPresentationSink.cs:89-96` (`_ -> false`), +`LiveEntityRuntime.cs:969-976` (`_ -> false`) all silently reject +ExecutorCompleted, and `RuntimePlacementProjectionSubscription` treats a +false return on the FIFO head as "leave pending" - the first ExecutorCompleted +reaching a production sink (at a future cutover slice) would permanently wedge +the entire ordered placement stream behind it. Fixed all three: added an +explicit `if (Kind is Discard or ExecutorCompleted) return true;`-shaped +early return BEFORE each file's record-lookup/portal-shape gate (never +letting ExecutorCompleted depend on a lookup that can legitimately fail for +unrelated reasons). Provably inert today - `PublishExecutorCompletion` has +zero production callers (`Execute`/`RegisterEntityWithInitialResidence` are +both unreached) - documented in both the code comments and the new tests. +Tests: `RuntimePlacementPresentationSinkTests.ExecutorCompleted_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone` +(App, mirrors the existing Discard test exactly, proves ack regardless of a +completely bogus/stale token) and +`HeadlessSessionHostTests.ExecutorCompletedReceiptIsAcknowledgeOnlyRegardlessOfRecordValidity` +(Headless, mirrors `PlacementReceiptUsesExactIncarnationAndDiscardIsAckOnly`'s +stale-incarnation half). + +## F2 (MAJOR, both reviewers) - completion-receipt lifecycle. FIXED. +Four sub-fixes, all landed: +1. **Register-before-publish**: `PublishExecutorCompletion` gained a + `beforePublish: Action?` callback invoked + AFTER the token is added to `_pendingProjection` but BEFORE + `PublishPlacement`'s synchronous observer dispatch. The executor's + `ExecuteCore` Released case now registers `_completionReceipts[key]` + inside that callback (capturing `receipt` via a local `completedReceipt` + - an `out` parameter cannot be captured by a lambda) - a subscriber + reading the correlation back from inside its OWN `OnPlacement` now always + finds it. +2. **Ack-driven removal**: new `RuntimeSetPositionState.BindExecutorCompletionAcknowledgement(Action)` + (mirrors `RuntimeInitialCreateResidenceState.BindRetirementNotification`'s + existing one-bound-delegate shape), invoked from `AcknowledgeProjection`'s + ExecutorCompleted branch the moment a host acknowledges. Bound in all 3 + `RuntimeEntityObjectLifetime` constructors to + `InitialCreateExecution.ForgetCompletionReceipt(key, sequence)` - a new + executor method that removes exactly the matching (key, sequence) entry + (exact-sequence-checked, so a NEWER completion under a reused key survives). +3. **DiscardProgress/DiscardAll**: both now unconditionally reap + `_completionReceipts` (Remove/Clear respectively) - DiscardProgress + removes it EVEN WHEN `_progress` no longer tracks the key (the drain + already removed its own Progress entry before publishing the completion), + proven by a dedicated test. +4. **Ownership/convergence**: added `PendingCompletionReceiptCount` to the + executor and folded it into + `RuntimeEntityObjectOwnershipSnapshot`/`IsConverged` (appended as the last + positional field with a `= 0` default, following this record's own + established extension convention) - chosen semantics: non-zero while + unacknowledged, zero exactly at acknowledge, mirroring + `RuntimeSetPositionOwnershipSnapshot.PendingProjectionAcknowledgementCount`'s + existing "unacknowledged receipt is outstanding debt, gated by + IsConverged" shape (documented as such, in contrast with the adjacent + diagnostic-only `ReplayFailureCount`). +Tests (`RuntimeInitialCreateContinuationExecutorTests.cs`): +`ExecutorCompletion_ReceiptIsReadableFromWithinTheSameSynchronousOnPlacementDispatch`, +`ExecutorCompletion_ConvergenceLedgerCountsAnUnacknowledgedReceiptAsOutstandingDebtUntilAcknowledged`, +`ExecutorCompletion_CorrelationEntryIsReapedByDiscardProgress`, +`ExecutorCompletion_CorrelationEntryIsReapedByDiscardAll` (the latter two +split into single-entity tests after discovering combining them with a +second entity in the SAME lifetime hit exact-head contention - see debugging +notes below). + +## F3 (MAJOR, arch) - nullable local-player-position fallback. FIXED. +`GameRuntime.cs:280`'s `context.Movement.Controller?.Position ?? Vector3.Zero` +fabricated a distance basis of literal (0,0,0) whenever the login-window +drain ran before the local player's own controller existed - a nearby remote +entity could misclassify as >96m and hard-snap where retail would +interpolate. Fixed per the contract's own fallback rule: `_localPlayerPosition` +is now `Func?` (was `Func?`), `BindLiveInputs`'s parameter +type updated to match, `ResolveInputs` now does +`_localPlayerPosition?.Invoke() is { } localPlayerPosition` (both "unbound" +AND "bound-but-returns-null" fall back to the caller struct's PlayerDistance +identically), and `GameRuntime.cs` now binds +`() => context.Movement.Controller?.Position` directly (a `PlayerMovementController?.Position` +already yields `Vector3?` via null-conditional propagation - no `?? Vector3.Zero` +needed or wanted). Test (both directions, per the ask): +`BindLiveInputs_PlayerDistanceIsReadFromANonNullBoundSourceAndFallsBackToTheCallerStructWhenNull` - +entity 1 has a bound NON-null near position (proves the bound value, not the +caller struct's far value, wins -> Interpolate); entity 2 flips the SAME +bound source to null (proves it falls back to the caller struct's far value, +not Vector3.Zero -> SetPositionSimple/StopInterpolating). + +## F4 (MINOR) - TryCommitParent comment narrowed. FIXED. +Rewrote the C0-4(a) comment: no longer claims "the SAME flow every sibling +relation commit uses" wholesale (which would imply LeaveWorld too) - now +states the Forget/PreferCancellation sequence is shared for THIS part of the +job, and explicitly documents the deliberate LeaveWorld omission citing +retail `set_parent` (0x00515A90, lines 283832-283833)'s single gated +leave_world call, which this method's staged/deferred-replay commit already +represents - a second LeaveWorld here would double-leave-world with no +retail counterpart. + +## F5 (NOTEs). FIXED. +(a) `TrySetAutonomyLevel`'s doc comment now notes retail's setter ALSO sends +`SendAutonomyLevelEvent` (pseudo-C 699550) and that any FUTURE host exposure +of this setter must carry the equivalent outbound event, not just the field +write. +(b) `FullAutonomyLevel`'s doc comment corrected from "No in-game caller of +CommandInterpreter::SetAutonomyLevel exists" (implying zero callers anywhere) +to the precise claim: exactly ONE retail caller exists, the startup +construction path at pseudo-C 94102 (the constructor's own default at 699752 +is a direct field write, not a SetAutonomyLevel call, so it doesn't count as +a second caller). + +## Debugging round for the F2/F3 tests (both caught by the test run, both +## root-caused, not worked around) +1. `ExecutorCompletion_CorrelationEntryIsReapedByDiscardProgressAndDiscardAll` + (combined, single lifetime, two entities) failed at the SECOND entity's + `CompleteInitialPlacement` - root cause: the FIRST entity's + ExecutorCompleted receipt was left UNACKNOWLEDGED in `_pendingProjection` + (by design, to prove DiscardProgress reaps the correlation cache + independent of the normal ack path) - but that means it permanently sat + at the exact head, blocking ANY later entity's Place receipt from ever + being acknowledged (DiscardProgress only touches the correlation cache, + never `_pendingProjection` itself - a deliberate, narrow scope). Fixed by + splitting into two single-entity tests (`...ReapedByDiscardProgress`, + `...ReapedByDiscardAll`), each with its own fresh lifetime - eliminates + the exact-head contention entirely rather than working around it. +2. `BindLiveInputs_PlayerDistanceIsReadFromANonNullBoundSourceAndFallsBackToTheCallerStructWhenNull` + hit the SAME class of bug for the SAME reason (entity 1's completed drain + left an unacknowledged ExecutorCompleted blocking entity 2). Fixed by + inserting an explicit peek+acknowledge of entity 1's completion between + the two entities (matching the pattern already established in + `BindLiveInputs_DrivesClassificationFromTheBoundSourcesInsteadOfTheCallerStruct` + from the prior round). + +## Fix-round final gates (all green) +1. `dotnet build src/AcDream.Runtime/AcDream.Runtime.csproj -c Release`: 0/0. +2. `dotnet build tests/AcDream.Runtime.Tests -c Release`: 0/0. +3. Complete `AcDream.Runtime.Tests`: 921/921 passed (916 + 5 new: 4 F2 + 1 F3; + see above for exact names). +4. `dotnet build tests/AcDream.App.Tests -c Release`: 0 errors, 3 pre-existing + warnings (CS8767, unrelated to this change). +5. `dotnet build tests/AcDream.Headless.Tests -c Release`: 0/0. +6. `dotnet test tests/AcDream.App.Tests -c Release --no-build`: 4028/4028 + passed, 3 skips (4027 baseline + 1 new: the F1 App-sink test). +7. `dotnet test tests/AcDream.Headless.Tests -c Release --no-build`: 77/77 + passed (76 baseline + 1 new: the F1 Headless-sink test). +8. `dotnet build AcDream.slnx -c Release --no-incremental` (clean rebuild for + an authoritative count): 0 errors, EXACTLY 21 warnings (matching the + documented baseline precisely - zero new warnings across the whole fix + round, including the 3 sanctioned sink-file edits). +9. Complete solution (`dotnet test AcDream.slnx -c Release -m:1`): every + project green - App 4028/3 skip, Bake 15, Cli 4, Content 124, Core.Net + 762, Core 4242/1 skip, Headless 77, Runtime 921, UI.Abstractions 543. 0 + failed anywhere. +10. `git diff --check`: clean (only pre-existing LF/CRLF metadata notices; + confirmed by grepping the raw output for anything OTHER than that + pattern - zero matches). +11. `git status`: exactly the C0 file set plus 5 NEW files from this fix + round (the 3 sanctioned sink files + their 2 test files), plus the 8 + pre-existing protected dirty paths untouched. Nothing staged. + +## Files touched in THIS fix round (in addition to the C0 file set above) +- src/AcDream.App/World/LiveEntityRuntime.cs (F1) +- src/AcDream.App/World/RuntimePlacementPresentationSink.cs (F1) +- src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs (F1) +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs (F2: beforePublish + param + BindExecutorCompletionAcknowledgement + ack-branch notification) +- src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs + (F2: ForgetCompletionReceipt/PendingCompletionReceiptCount/DiscardProgress+ + DiscardAll reap/register-before-publish call site; F3: nullable field/ + ResolveInputs) +- src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs (F2: ownership + snapshot field + CaptureOwnership wiring + acknowledgement binding; F3: + BindLiveInputs signature; F4: comment) +- src/AcDream.Runtime/GameRuntime.cs (F3: nullable binding call site) +- src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs (F5a/F5b: comments) +- tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs (F1 + test) +- tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs (F1 test) +- tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs + (F2 x4 + F3 x1 tests) + +--- + +# C2 — placement allocation budget (new implementer session) + +Worktree: C:\Users\erikn\.codex\worktrees\af5e\acdream (branch codex/port-claude-agents) +HEAD verified at session start: 6460596b56cd72a2c6d96e757b33da879a805b6d + +## Baseline reproduction + +- `dotnet build AcDream.slnx -c Release` green, 21 pre-existing warnings in + unrelated files (not introduced by this session). +- Ran `WarmedImmediateCommitAllocationIsMeasuredBeforeProductionCutover` + (tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs:253). + Baseline via temporary forced-failure instrumentation (reverted before any + real edits; git diff was clean after revert): **2032 B/op**, stable across + 3 repeat runs. NOT 1880 as the stale research doc + (docs/research/2026-07-31-canonical-set-position.md lines ~326-333) + states -- the number drifted upward since C0 landed additional + bookkeeping (ExecutorCompleted receipt plumbing etc). This is already + within 16 bytes of tripping the 2048 cap on its own -- confirms urgency. + +## Bisection (temporary instrumentation, reverted before real edits) + +Added static accumulator fields + GC.GetAllocatedBytesForCurrentThread() +brackets around: Apply() -> BeginAcceptedPlacementCore (BEGIN) and +SubmitPreparedPlacementCore (SUBMIT); inside SubmitPreparedPlacementCore: +prefix-to-SetPosition-call (PREFIX), the `_physics.Engine.SetPosition(...)` +call itself (SETPOSITION), CommitCanonical (COMMIT), PublishProjection +(PUBLISH), tail/Outcome (TAIL); AcknowledgeProjection (ACK, wrapped core in +try/finally). + +Result (per-op, averaged over 1000 measured iterations, 64 warmups): +``` +TOTAL=2032 BEGIN=752 SUBMIT=1160 SETPOSITION=584 COMMIT=0 PUBLISH=208 ACK=120 +PREFIX=144 MID=0 TAIL=0 +``` +Outer brackets are self-consistent to the byte: BEGIN+SUBMIT+ACK = +752+1160+120 = 2032 = TOTAL exactly. The inner SUBMIT subdivision only sums +to 936 (144+584+0+208), leaving ~224 B/op I could not pin down further +within budget (possibly SortedDictionary rebalancing spillover attributed +oddly across adjacent brackets, possibly a measurement-granularity artifact +of many small brackets in one method -- the coarse brackets are trustworthy, +the finest subdivision is not). Decided not to chase further since the two +big, well-understood root causes below match the project's established +"pool the envelope, cache the delegate" pattern and account for the +majority of the budget. + +## Root causes identified (confirmed by direct code reading + the + bisection above) + +1. **BEGIN (752 B, `BeginAcceptedPlacementCore`)**: `new Operation { ... }` + (private sealed class Operation, ~25 properties incl. embedded + RuntimeSetPositionCommand / PhysicsSetPositionResult structs) allocated + FRESH on every accepted-placement call; the old Operation for the same + entity key is simply dropped (`_operations[key] = replacement;`) and + becomes garbage every single call. + +2. **SETPOSITION (584 B, two call sites: SubmitPreparedPlacementCore + ~line 2402 and RetryDeferred ~line 3536)**: + `report => _physics.HandleSetPositionCollisions(operation.Record, ..., + canonicalCommand.GameTime, ...)` is a closure capturing `this` + + `operation` (+ `canonicalCommand` at the first site) -- a fresh + compiler-generated display-class allocated on EVERY call. Confirmed + every field the closure reads is already reachable from `operation` + alone (`operation.Command.GameTime == canonicalCommand.GameTime` because + `operation.Command = canonicalCommand;` runs earlier in the same + method) -- the closure captures nothing that isn't already sitting on + `operation`. Fixable with ONE delegate cached for the RuntimeSetPositionState + instance's lifetime, reading the "current operation" off a small + reusable Stack pushed/popped around the SetPosition call + (defends against theoretical re-entrant/nested SetPosition calls inside + PhysicsEngine -- TransitionScratchArena's ActiveDepth/Capacity gate + implies nesting is possible at that layer, even though + HandleSetPositionCollisions itself never calls back into + RuntimeSetPositionState). + +## Plan +1. Fix the closure (lowest risk, no behavior change) -- both call sites. +2. Pool the Operation object (`_operationPool`, convert `required {get;init;}` + to settable + add a `Reset(...)`, return retired Operations to the pool + at every site that currently discards one for good). Audit every + `_operations.Remove(...)` / displacement site so nothing else still + holds the recycled instance (per "no workarounds": a pool that + resurrects stale state is worse than the allocation it replaces). +3. Re-measure; tighten the gate to the new number with justified headroom. + +## Fixes implemented + +1. Cached collision-report delegate (fixes the closure at both + `_physics.Engine.SetPosition` call sites: SubmitPreparedPlacementCore and + RetryDeferred). Added `CollisionCallbackContext` (readonly record struct) + plus `Stack _collisionCallbackContexts` plus + `Func + _handleSetPositionCollisionsCallback` (built ONCE in the constructor, + bound to instance method `HandleSetPositionCollisionsCallback` which + reads `_collisionCallbackContexts.Peek()`). Each call site now does + Push(context) / try { SetPosition(request, cached delegate) } / finally + { Pop() }. The stack (not a single field) defends nested/re-entrant + SetPosition calls at the PhysicsEngine layer. + +2. Operation pooling (fixes `new Operation` in + `BeginAcceptedPlacementCore`). Converted every `Operation` property from + `required ... { get; init; }` to plain `{ get; set; }`, added + `ResetAllFieldsToDefault()`, added `_operationPool` (`Stack`, + capped at 64), `RentOperation()` / `RetireOperationToPool(Operation)`. + `BeginAcceptedPlacementCore` now rents+field-sets instead of + `new Operation {...}`. Operations are retired at the 3 places an + Operation is permanently removed from `_operations`: both branches inside + `AcknowledgeProjection` Place/non-lost-cell paths, and inside + `CancelCoreDeferred` (the single removal chokepoint every `CancelCore` + overload funnels through). + + CRITICAL BUG FOUND AND FIXED during verification: the first pass reset + fields at RETIREMENT time (inside RetireOperationToPool). This broke + `ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation` - + `CommitCanonical` remote branch builds a `ContactCommitGuard` that + captures the live `Operation`, then invokes `remote.HitGround()` / + `LeaveGround()` - and retail lets that callback synchronously call BACK + INTO `BeginAcceptedPlacement` for the SAME entity, which displaces and + retires the very operation the guard is holding, mid-guard. + `guard.IsCurrent()` (`IsCanonicalPlacementCommitCurrent`) reads that + operation ORIGINAL PositionAuthorityVersion/SpatialAuthorityVersion AFTER + the callback returns - resetting those fields at retirement time zeroed + them out from under the still-executing outer frame, making an in-flight + valid commit look stale and silently dropping its shadow update (test + caught it: shadow.Position stayed at the pre-spawn 10,20,7 instead of the + committed 13,18,~6.95). Fix: move the reset from RetireOperationToPool to + RentOperation (reset happens the moment an instance is about to be + handed out for reuse, not the moment it is taken out of `_operations`). + A retired-but-not-yet-rented instance now keeps its true last-known field + values until something actually reuses it - any outer frame with a + captured reference gets a brief, safe, read-only window on stale-but- + correct data instead of zeroed garbage. Full 921/921 Runtime tests pass + after this fix (before the fix: 920/921, this exact test failing). + + Lesson worth carrying into memory: reentrant displaced-operation pooling + must reset at RENT time, never at RETIREMENT time, whenever a captured + reference (guard/closure/local) might still read the object fields after + retirement but before the next real use. + +3. Eliminated LINQ `.First()` boxing on `_pendingProjection` + (SortedDictionary<ulong, RuntimePlacementProjectionSnapshot>). Added + `FirstPendingProjection()` using a plain `foreach` (resolves to the + concrete struct-returning `GetEnumerator()`, not the boxing + `IEnumerable<T>` interface one that `Enumerable.First<T>` + forces). Replaced all 3 call sites (`TryPeekProjection`, + `AcknowledgeProjection` guard, `HasPendingProjectionThrough`). + +## Root cause: SortedDictionary Add allocations (about 208 B/op) and + AcDream.Core PhysicsEngine internals (about 520 B/op) - NOT fixed + +Confirmed via targeted diagnostic instrumentation (added, measured, fully +reverted before finalizing - git diff was clean after each revert): +- `_pendingProjection.Add(sequence, snapshot)` (PublishProjection) costs + about 208 B/op - SortedDictionary red-black tree node allocation, inherent + to the BCL type with no pooling hook. Replacing `_pendingProjection` data + structure to chase this would touch 10+ methods relying on its ordering + and FIFO-peek semantics (quiescence tracking, withdrawal acknowledgement, + etc.) - judged too invasive and risky for the remaining reward given the + result is already well under the 2048 cap. +- `_physics.Engine.SetPosition` (AcDream.Core/Physics/PhysicsEngine.cs) + costs about 520 B/op split as INIT=144 (InitializeSetPositionTransition), + INNER=344 (SetPositionInternal/scatter solve), FINAL=32 (the + `queryFootprint.OrderedIds.ToImmutableArray()` call - a genuine single- + element ImmutableArray materialization from the outdoor-adjustment query + footprint, not an artifact). RENT=0 (Transition pooling already zero- + alloc). This lives entirely inside AcDream.Core, shared physics + infrastructure used far beyond RuntimeSetPositionState - out of this + slice Runtime-only hard-rule scope, and not touched. +- Confirmed `HandleSetPositionCollisionReports` / + `RuntimeCollisionReportingState.HandleReports` (Runtime-side) do NOT + allocate in the test steady state (no collisions ever occur - + collidedObjectIds stays empty, no OwnerState ever gets created for this + entity) - ruled out as a contributor. + +## Final verification + +- `dotnet build AcDream.slnx -c Release`: 0 errors, 21 pre-existing warnings + (all in files untouched by this session - confirmed identical to the + pre-session baseline build). +- `WarmedImmediateCommitAllocationIsMeasuredBeforeProductionCutover`: passes + at the new `Assert.InRange(allocated / iterations, 1L, 1_536L)` gate. + Measured value stable at exactly 944 B/op across 5+ repeat runs (down + from 2032 B/op baseline, 53.5% reduction). 1,536 keeps about 60% headroom. +- Complete `AcDream.Runtime.Tests`: 921/921 pass (0 skips). +- Complete solution (`dotnet test AcDream.slnx -c Release -m:1`): every + project green - App 4028/3 skip, Bake 15, Cli 4, Content 124, Core.Net + 762, Core 4242/1 skip, Headless 77, Runtime 921, UI.Abstractions 543. + 10,716 total, 0 failed, 4 skipped (all pre-existing skips). +- `git diff --check`: clean (only the same pre-existing LF/CRLF metadata + notices on the 8 protected dirty files from before this session; grepped + for anything else - zero matches). +- `git status`: exactly 2 files changed by this session + (src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs, + tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs) plus + the 8 pre-existing protected dirty paths, untouched, exactly as they were + at session start. Nothing staged, nothing committed, HEAD unchanged at + 6460596b56cd72a2c6d96e757b33da879a805b6d. + +## Files touched this session +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs (net +340/-69 lines + vs the C0 baseline: Operation class made poolable plus + ResetAllFieldsToDefault, CollisionCallbackContext plus cached delegate + plus stack, RentOperation/RetireOperationToPool, FirstPendingProjection, + BeginAcceptedPlacementCore restructure, both SetPosition call sites, both + AcknowledgeProjection removal branches, CancelCoreDeferred retirement + call site) +- tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs + (26 lines: only the regression gate comment and threshold, from 2_048L to + 1_536L - zero other test changes; the fix required no test edits beyond + the gate itself, confirming the pooling/delegate/LINQ changes are fully + behavior-preserving) + +# C2 review-fix round (F1-F4) + +Both independent reviews FAILED the original C2 landing with four findings. +Fixed all four; re-verified B/op unchanged (944, same as before this round) +and the complete Runtime + solution suites green. + +## F1 (MAJOR, retail) - CommitCanonical post-callback reads/writes + +Root cause: CommitCanonical read operation.PositionAuthorityVersion/ +SpatialAuthorityVersion/Command.GameTime/PreviousContact/PreviousOnWalkable/ +Key/Command.ShadowWorldOffsetX/Y AFTER invoking the ground-edge HitGround/ +LeaveGround callbacks (via PhysicsObjUpdate.CommitSetPositionContactTransition). +A synchronous cancel-then-begin (or begin-twice) chain for the SAME entity +retires-then-rents (LIFO) the SAME Operation instance mid-callback, so those +post-callback reads could observe a reset-or-repurposed operation. + +Fix: hoisted every scalar CommitCanonical still needs into locals BEFORE the +callback (operationKey, positionAuthorityVersion, spatialAuthorityVersion, +sourceVelocityAuthorityVersion, commandGameTime, previousContact, +previousOnWalkable, shadowWorldOffsetX/Y). ContactCommitGuard now captures +positionAuthorityVersion/spatialAuthorityVersion as plain values instead of +holding an Operation reference. IsCanonicalPlacementCommitCurrent's Operation +parameter was replaced with the two explicit scalar parameters. + +FALSE START (caught by full-suite regression, not left in): first pass ALSO +added an operationToken identity comparison inside +IsCanonicalPlacementCommitCurrent, intending to detect the exact repurposing. +This broke two EXISTING tests +(ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation and, +after fixing that, exposed a status regression in the same test) because +retail's OWN contract is that an in-flight ground-edge commit for a +DISPLACED operation must still complete its physical settle (contact +transition + shadow sync) - adding identity-based rejection there +incorrectly aborted a commit the test explicitly requires to succeed. +Reverted the token check from IsCanonicalPlacementCommitCurrent entirely +(doc comment there now explains why identity must NOT be checked at that +layer). The REAL self-aliasing hazard was two levels up: `BeginAcceptedPlacementCore`'s +final `IsCurrent(replacement)` check (after PublishPlacement can reentrantly +retire+rent the SAME `replacement` instance for an inner Begin) and +`SubmitPreparedPlacementCore`/`RetryDeferred`'s post-CommitCanonical decision +of Cancelled-vs-Committed (CommitCanonical can legitimately succeed for a +displaced operation - the OUTER caller's own invocation still must not +publish a Place projection nobody will ever acknowledge). Fixed both by +comparing a LOCAL, pre-reentrancy token/parameter (`token` already a +parameter in SubmitPreparedPlacementCore; hoisted `operationToken` added to +RetryDeferred; the `token` local already existed in +BeginAcceptedPlacementCore) against a FRESH `_operations` lookup - never +against the potentially-repurposed operation reference's own fields. + +Regression test: ReentrantCancelThenBeginRecyclesInstanceButCollisionReportUsesPreCallbackValues. +Drives Cancel(publishWithdrawal:false) then BeginAcceptedPlacement from +inside OnHitGround - LIFO pool guarantees the SAME instance is retired then +immediately rented for the new ("recycled") operation, a strictly more +adversarial recycle than the pre-existing test. Asserts (via +CollisionReportObserver subscribed to CollisionReports) that the +environment-collision report's RecipientWasInContact reflects the ORIGINAL +pre-callback PreviousContact, and that the report fires at all (proving +PreviousOnWalkable also carried through correctly - the report only fires +when !previousOnWalkable && body.OnWalkable, so a corrupted +PreviousOnWalkable would silently suppress it). Verified DISCRIMINATING: reverted +the hoist locally, confirmed the test fails (empty report collection), then +restored the fix. + +## F2 (MAJOR arch + MINOR retail) - pool invisible to reset/dispose ledger + +_operationPool was never cleared by ClearOwnedState (called from both +ResetSession and Dispose), so up to 64 pooled instances could retain full +previous-generation entity graphs across a session boundary. Added +`_operationPool.Clear()` inside ClearOwnedState (comment explains why this +is safe unlike RetireOperationToPool: a session clear cannot be reentered +from inside itself). Added `PooledOperationCount` to +RuntimeSetPositionOwnershipSnapshot (new trailing field, single +construction site updated), deliberately EXCLUDED from IsConverged (doc +comment explains pooled idle capacity is legitimate mid-session). + +Regression tests: OperationPoolClearsOnResetSession, +OperationPoolClearsOnDispose - both drive an Apply+Acknowledge cycle to get +>=1 pooled operation, assert PooledOperationCount >= 1, then call +ResetSession/Dispose and assert it drops to exactly 0. Verified +DISCRIMINATING: commented out the `_operationPool.Clear()` line, confirmed +both tests fail (1 instead of 0), restored the fix. + +## F3 (MINOR arch) - no-self-aliasing invariant + InPool guard + +Reordered BeginAcceptedPlacementCore: RentOperation() now happens AFTER the +displaced operation's CancelCoreDeferred retire (previously rent happened +first). This lets a single-entity churn cycle legitimately reuse the exact +retired instance (LIFO) instead of drawing a different one - safe because +every field this method needs from `displaced` was already captured into +locals before the retire point (unchanged from the original C2 landing). +Added `Operation.InPool` (bool, defaults false): set true in +RetireOperationToPool right before pushing, cleared in +ResetAllFieldsToDefault (called from RentOperation right after popping). +RetireOperationToPool now throws InvalidOperationException if called on an +instance that is already InPool (double-retire without an intervening rent +would silently duplicate the instance in the pool stack). + +This reorder, on its own, reintroduced a DIFFERENT self-aliasing hazard at +BeginAcceptedPlacementCore's own final line (`IsCurrent(replacement) ? token +: default`) - caught by the EXISTING test +ReentrantBeginDuringDiscardCannotBeOverwrittenByOuterBegin (a +PublishPlacement-triggered reentrant Begin during a Discard notification can +retire+rent the SAME `replacement` instance for an inner operation, making +`IsCurrent(replacement)` a self-referential tautology that wrongly reports +"still current"). Fixed by comparing `_operations[key].Token` against the +LOCAL `token` (captured at function entry, immune to reentrant corruption) +instead of calling `IsCurrent(replacement)`. + +No new dedicated F3 test beyond the reflection test (F4) and the +pre-existing ReentrantBeginDuringDiscardCannotBeOverwrittenByOuterBegin, +which now exercises the corrected self-aliasing check directly. + +## F4 (MINOR arch) - two remaining new Operation() sites + completeness net + +Converted the two surviving `new Operation { ... }` object-initializer +sites (inside ParkCollisionResidents and CreateWithdrawalOperation) to +RentOperation() + field assignment, so all construction flows one path. +Both call sites confirmed safe to route through the pool (no live displaced +operation exists at either construction point - ParkCollisionResidents +explicitly skips keys already in `_operations`; CreateWithdrawalOperation's +sole caller, Cancel, always runs CancelCoreDeferred against the same key +immediately before). + +Added OperationResetAllFieldsToDefaultTouchesEveryDeclaredField: a +reflection-based test (Operation is `private`, so ONLY reflection can reach +it from a test project even with InternalsVisibleTo) comparing +`typeof(Operation).GetFields(Instance|NonPublic|Public)` against a +hardcoded, maintained list of the 33 expected backing-field names (derived +from a plain property-name list transformed to `k__BackingField` +form). Verified DISCRIMINATING: added a temporary dummy property to +Operation, confirmed the test fails with a clear collection-diff showing +the new backing field, removed it. + +## Final verification + +- Release build: 0 errors, 21 pre-existing warnings (unchanged from + baseline, zero new). +- WarmedImmediateCommitAllocationIsMeasuredBeforeProductionCutover: still + passes at the 1,536L gate. Re-measured exact value 3x: 944 B/op, IDENTICAL + to before this fix round - confirms every F1-F4 hoist/guard is + stack-only/negligible (one extra bool field, one extra int in a value-type + snapshot struct - no heap allocation added). +- Complete AcDream.Runtime.Tests: 925/925 (921 + 4 new: 1 F1 regression + 2 + F2 reset/dispose + 1 F4 reflection). +- Complete solution (dotnet test AcDream.slnx -c Release -m:1): every + project green - App 4028/3 skip, Bake 15, Cli 4, Content 124, Core.Net + 762, Core 4242/1 skip, Headless 77, Runtime 925, UI.Abstractions 543. + 10,720 total, 0 failed. +- git diff --check: clean (same pre-existing LF/CRLF metadata notices on the + now-9 touched-by-someone files - the 8 pre-existing protected dirty paths + plus RuntimeSetPositionState.cs itself; RuntimeSetPositionStateTests.cs + shows no notice at all). +- git status: still exactly the 2 files this session owns + (RuntimeSetPositionState.cs, RuntimeSetPositionStateTests.cs) plus the 8 + pre-existing protected dirty paths untouched. Nothing staged, nothing + committed, HEAD unchanged at 6460596b56cd72a2c6d96e757b33da879a805b6d. + +## Files touched this fix round +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs (net diff vs C2 + landing: 800 lines changed - Operation.InPool + doc, ResetAllFieldsToDefault + InPool line, CommitCanonical hoisting rewrite, ContactCommitGuard/ + IsCanonicalPlacementCommitCurrent signature change, IsVelocityCurrent + overload, BeginAcceptedPlacementCore reorder + final-check fix, + SubmitPreparedPlacementCore + RetryDeferred post-commit currency checks, + RuntimeSetPositionOwnershipSnapshot.PooledOperationCount + + ClearOwnedState pool clear, ParkCollisionResidents + CreateWithdrawalOperation + routed through RentOperation) +- tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs (net + diff: 255 lines - 4 new tests + System.Reflection using) + +# C2 round 3 (rent-after-retire regression) + addendum (A1/A2) + +The F3 reorder (rent AFTER retire, so a single-entity churn cycle reuses +the exact retired instance) makes `IsCurrent(Operation)` (ReferenceEquals + +six field comparisons read off the SAME instance) a tautology once a +reentrant cancel-then-begin recycles that instance for a different logical +operation at the same key. Every one of the ~20 `IsCurrent(operation)` call +sites needed auditing: either convert to a captured-token-vs-fresh-lookup +check, or prove (with a per-site comment) that no reentrancy point +intervenes between the last fresh lookup and the check. + +## The fix + +Extracted `IsOperationStateConsistent(Operation)` from `IsCurrent`'s six +non-identity field comparisons. `IsCurrent(Operation)` is now +`_operations.TryGetValue(operation.Key, out current) && +ReferenceEquals(current, operation) && IsOperationStateConsistent(operation)` +- same behavior as before, just factored so the new helper below can share +the state check. Added: + +``` +private bool IsCurrentByToken( + RuntimeEntityKey key, + in RuntimeEntityPlacementToken capturedToken, + [NotNullWhen(true)] out Operation? operation) +``` + +Does a FRESH `_operations.TryGetValue` + `current.Token == capturedToken` + +`IsOperationStateConsistent(current)`, and hands back the fresh (possibly +different-instance) `Operation` on success. `[NotNullWhen(true)]` lets +callers reuse a single `out` binding across an `if (!IsCurrentByToken(...)) +return ...;` guard without a separate null-forgiving cast. + +`CancelCore(Operation expected, ...)` (ReferenceEquals shape - the OTHER +regression source the reviewer named, "shares the shape with a worse +outcome: cancelling the newer operation") became +`CancelCore(RuntimeEntityKey key, in RuntimeEntityPlacementToken +expectedToken, bool preserveLostFamily = false)`: fresh +`_operations.TryGetValue(key, ...)` + `current.Token != expectedToken` -> +no-op. All 5 callers converted (ForgetExactPlacement, +RetireDormantLocalActivation, RetireDormantLocalActivationToken, +SubmitPreparedPlacementCore's post-CommitCanonical-failure branch, +RetryDeferred's equivalent) - each now passes its own captured +`(key, token)` instead of an `Operation` reference. + +## The exact bug the reviewer traced (Cancel path) - FIXED + +`Cancel(record, bool)` creates a withdrawal operation, installs it at the +key, then calls `PublishPlacement(cancelledOld)` (reentrancy point: an +`IRuntimePlacementObserver` subscriber can call Begin/Cancel for the same +entity from inside this synchronous dispatch), then previously checked +`IsCurrent(operation)` against the now-possibly-recycled reference. Fixed: +capture `operation.Token` into a local BEFORE `PublishPlacement`, then +`IsCurrentByToken(key, capturedToken, out operation)` afterward. + +## Subtler hazard found during the audit, not named by the reviewer: +## RebindQuiescedDeferredOperations + +`foreach (Operation operation in _operations.Values.ToArray())` snapshots +live REFERENCES. An earlier iteration's `RetryDeferred` call (itself +reentrancy-exposed) can recycle a LATER iteration's not-yet-reached +Operation instance before the loop reaches it - a stale-reference bug +independent of the Cancel/CancelCore ones. Fixed by snapshotting +`(Key, Token)` VALUE pairs first, then resolving fresh via +`IsCurrentByToken` at the top of each iteration instead of trusting the +snapshotted reference. + +## Addendum A1 (retail MINOR) - CommitCanonical's 4 post-callback writes + +`CommitCanonical`'s tail (`operation.ExactCellId/Result/WakeableLostCell/ +EnteringWorldFromCelllessResidence = ...; CancelLostFamilyDeadlines(operation)`) +still targeted the poolable instance directly. With rent-after-retire, a +nested cancel-then-begin recycles the instance before this block runs, and +a bare Begin never advances PlacementCommitVersion - invisible to the +settle-layer record-state checks - so the writes could land on the WRONG +(freshly-begun) operation. Fixed: hoisted `operationToken = operation.Token` +(already had `operationKey` from F1) at the SAME pre-callback point as +every other F1 hoist, then gated the whole write block behind a fresh +`_operations.TryGetValue(operationKey, out currentOperation) && +currentOperation.Token == operationToken` check - skip the writes (not the +whole commit) if the token no longer matches, matching retail's +already-unconditional physical settle (only Runtime's OWN bookkeeping is +conditional). The final `IsCanonicalPlacementCommitCurrent(..., +requireSpatialRoot: true)` return is UNCHANGED - still identity-agnostic, +per the layer-separation rule below. + +## Addendum A2 (doc hygiene) - stale comment on RetireOperationToPool + +The old comment claimed `IsCanonicalPlacementCommitCurrent` "additionally +compares the operation's Token by value" - that guard was a round-3 FALSE +START (see the F1 section above) that was reverted before F1 even landed; +the comment was never updated and contradicted the real mechanism. +Rewritten to state the true safety contract: hoisted locals (F1) + +call-site captured-token-vs-fresh-lookup (this round), with the settle +path deliberately identity-agnostic per retail's unconditional +SetPositionInternal completion. + +## Layer separation (unchanged, reaffirmed by the retail reviewer) + +`IsCanonicalPlacementCommitCurrent` takes NO identity/Token parameter, by +design - retail's SetPositionInternal settle completes unconditionally for +a displaced operation (see +ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation, from the +F1 false-start above). Identity gates belong ONLY at Runtime-owned +publication/cancellation/ownership decisions (SubmitPreparedPlacementCore's +Cancelled-vs-Committed decision, Cancel's withdrawal publish, CancelCore's +match check, CommitCanonical's A1 bookkeeping-write gate) - never at the +settle/currency check itself. Did not touch this layer in round 3 beyond +re-confirming it via the reverted false-start being re-tried and failing +the same way it did in round 2 (not re-attempted this round - the round-2 +false-start's lesson was already incorporated). + +## Per-site audit table (all sites this class calls +`IsCurrent(Operation)`/`ReferenceEquals` on an Operation across a +reentrancy-exposed span) + +CONVERTED to captured-token-vs-fresh-lookup (IsCurrentByToken or +CancelCore(key, token)): +1. `Cancel(record, bool)` post-`PublishPlacement(cancelledOld)` check - the + reviewer's named bug. +2. `SubmitPreparedPlacementCore` post-`_physics.Engine.SetPosition(...)` + check (first of two, immediately after the collision-callback-bearing + call). +3. `SubmitPreparedPlacementCore` post-`SetPosition` second check (after the + `TryGetBlockingQuiescence`/deferred branch, before `CommitCanonical`). +4. `SubmitPreparedPlacementCore`'s `CancelCore(token.Entity, token)` call on + `CommitCanonical` failure - the reviewer's named CancelCore-shape bug. +5. `RetryDeferred`'s two analogous post-`SetPosition` checks (same shape as + #2/#3, via hoisted `operationToken`). +6. `RetryDeferred`'s `CancelCore(operationToken.Entity, operationToken)` + call on `CommitCanonical` failure (same shape as #4). +7. `RebindQuiescedDeferredOperations`'s per-entry resolve - converted from a + snapshotted-REFERENCE loop to a snapshotted-(Key,Token) loop + fresh + `IsCurrentByToken` (the subtler hazard found during the audit, not named + by the reviewer). +8. `ForgetExactPlacement`'s `CancelCore(token.Entity, token)` call. +9. `RetireDormantLocalActivation`'s `CancelCore` call. +10. `RetireDormantLocalActivationToken`'s `CancelCore` call. +11. `CommitCanonical`'s A1 bookkeeping-write gate (fresh lookup + + `currentOperation.Token == operationToken` before the + ExactCellId/Result/WakeableLostCell/EnteringWorldFromCelllessResidence/ + CancelLostFamilyDeadlines writes) - addendum A1. + +PROVEN SAFE WITH COMMENT (fresh lookup + Token/state check on the same line +or immediately prior, nothing reentrant intervenes before the check runs): +12. `IsPlacementCurrent` - fresh lookup + Token check inline, no + reentrancy between them. +13. `PrepareDormantLocalActivationOwnership` - fresh lookup + Token check + earlier in the same method, dormant-family code with no production + callers. +14. `PrepareMover` - same shape as #13. +15. `IsExactPreparedPlacementCurrent` - same shape. +16. `TryEvaluateDormantLocalActivation` - ReferenceEquals variant, safe + because `handleCollisions: null` on this call means nothing reentrant + can run before the check. +17. `IsDormantLocalActivationPrephaseCurrent` - fresh lookup + Token check + earlier in the same method. +18. `IsDormantLocalActivationResponseCurrent` - same shape. +19. `CommitDormantLocalActivationPostCollision` - entry AND final-return + checks, one comment block covering both; dormant family, no production + callers. +20. `IsDormantLocalActivationCommitCurrent` - same shape as #17/#18. +21. `IsExactDormantLocalActivationCurrent` - same shape. +22. `SubmitPreparedPlacementCore`'s own entry `ownsToken` check - the + function's own validation, nothing reentrant between the fresh lookup + and this check. +23. `AcknowledgeProjection`'s entry check - fresh lookup + + ProjectionSequence check, no reentrancy in between. +24. `CommitCanonical`'s entry check (`!result.IsCommitted || + !IsCurrent(operation)`) - every caller (SubmitPreparedPlacementCore, + RetryDeferred) passes an operation freshly re-verified immediately + before calling CommitCanonical. +25. `CommitCollisionGeneration`'s per-entity loop - added during THIS pass + (not flagged by the reviewer, found while double-checking every + `_operations.TryGetValue` site in the file for completeness). Iterates + an array of KEYS (never Operation references), fresh lookup + full + WakeableLostCell/ExactCellId/CollisionPrefix/CollisionGeneration shape + check every iteration - immune to the snapshotted-reference class of + bug even though it calls the reentrancy-exposed `RetryDeferred` + per-entity. + +OUT OF SCOPE (different class entirely, not an Operation-identity site): +26. `ParkCollisionResidentsForQuiescence`'s `ReferenceEquals(current, + state)` - checks `CollisionPrefixQuiescence` identity (a class that is + never pooled), unrelated to the Operation pool this round's regression + lives in. + +## Round 3 tests + +`ReentrantBeginDuringCancelPublishCannotBeOverwrittenByOuterWithdraw` - +the reviewer's named Cancel-path scenario: Apply (pending Place +projection) -> observer reentrantly Begins on the Discard notification +during Cancel's PublishPlacement -> asserts only the Discard delta +published (a stale-reference bug would add a second Withdraw delta +stamped with the inner operation's state) and the inner token is still +`IsPlacementCurrent`. VERIFIED DISCRIMINATING: reverted Cancel's +`IsCurrentByToken` check back to `IsCurrent(operation)`, reran - failed +exactly as predicted (`Assert.Single` saw 2 deltas, the second a Withdraw +carrying the inner operation's PlacementCommitVersion=2/Sequence=2) - +restored the fix. + +`ReentrantCancelThenBeginDuringCommitFailureLeavesInnerOperationUncancelled` +- the CancelCore-shape scenario: ground-edge HitGround callback does +cancel-then-begin (recycling the instance for `inner`) AND calls +`lifetime.Entities.AdvancePlacementCommit(record)` a second time BEFORE +creating `inner` (so `inner` snapshots the already-advanced value and +stays internally self-consistent, while the OUTER commit's +`canonicalCommitVersion`, captured before the callback, now mismatches) - +this makes `CommitCanonical`'s post-callback `IsCanonicalPlacementCommitCurrent` +check fail for the outer commit without needing a full nested SetPosition +round-trip, driving `SubmitPreparedPlacementCore` into +`PublishCancellation(CancelCore(token.Entity, token))`. Asserts +`outcome.Status == Cancelled` and `inner` is still `IsPlacementCurrent` +afterward. VERIFIED DISCRIMINATING: reverted `CancelCore(key, token)`'s +Token check to accept any match by key alone (simulating the old +ReferenceEquals-without-identity shape), reran - failed exactly as +predicted (`IsPlacementCurrent(inner)` false, the sabotaged check retired +`inner`'s instance out from under it) - restored the fix. + +## Final verification (round 3 + addendum) + +- Release build (`dotnet build AcDream.slnx -c Release`): 0 errors, 21 + pre-existing warnings, all in test files this session did not touch (App/ + Core test projects) - unchanged from the F1-F4 round. +- `WarmedImmediateCommitAllocationIsMeasuredBeforeProductionCutover`: + passes at the existing 1,536L gate - token captures added this round are + all stack-only locals/struct fields, no new heap allocation. +- Complete AcDream.Runtime.Tests: 927/927 (925 F1-F4 baseline + 2 new: the + Cancel-path and CancelCore-shape regressions). +- Complete solution build (`dotnet build AcDream.slnx -c Release`): 0 + errors. +- `git diff --check`: exit 0, clean (same pre-existing LF/CRLF metadata + notices on the same pre-existing dirty files, RuntimeSetPositionState.cs + included - no whitespace-error content). +- `git status`/`git rev-parse HEAD`: still exactly the 2 files this session + owns (RuntimeSetPositionState.cs, RuntimeSetPositionStateTests.cs) plus + the same pre-existing dirty paths (AGENTS.md, + PlayerModeController.cs, PlayerInteractionMovementSink.cs, + LiveAnimationPresentationContext.cs, RuntimeRemotePhysicsUpdater.cs, + CellTransitTests.cs, Issue133DungeonTeleportPrefixTests.cs, + A8CellAudit.csproj) untouched by this session. Nothing staged, nothing + committed, HEAD unchanged at 6460596b56cd72a2c6d96e757b33da879a805b6d. + +## Files touched this round + +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs (IsCurrent split + into IsCurrent + IsOperationStateConsistent, new IsCurrentByToken helper, + CancelCore(Operation) -> CancelCore(key, token) signature change + 5 + caller conversions, Cancel/SubmitPreparedPlacementCore/RetryDeferred/ + RebindQuiescedDeferredOperations converted call sites, + CommitCanonical's A1 bookkeeping-write token gate, ~14 proven-safe-site + comments, RetireOperationToPool doc-comment rewrite (A2), + CommitCollisionGeneration audit comment) +- tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs (2 + new tests: ReentrantBeginDuringCancelPublishCannotBeOverwrittenByOuterWithdraw, + ReentrantCancelThenBeginDuringCommitFailureLeavesInnerOperationUncancelled) + +--- + +# C3 implementer session (2026-08-02) — spawn-frequency host cutover + +Worktree/branch/HEAD verified against the pinned contract before starting: +a32aba35d1d945b9d3194a84e70facf74a7d7608. Read in full: c3-contract.md, +docs/plans/2026-08-02-placement-cutover.md, docs/research/ +2026-08-02-cutover-route-inventory.md (routes 1+8 + all cross-cutting +sections), docs/research/2026-08-02-canonical-body-writer-map.md, docs/ +research/2026-08-02-runtime-continuation-executor-handoff.md. + +## C3-1 — DONE, tested, gated. (Runtime-only prerequisite a) + +Design: rather than widen any internal enum's accessibility (the contract's +explicit preference), added a small PUBLIC projection surface next to the +existing public placement-receipt types (`RuntimePlacementProjectionKind`/ +`Token`/`Snapshot` are already public; the executor's own receipt/trace types +are `internal`): + +- `RuntimeInitialCreateTeleportHookPhase`, `RuntimeInitialCreatePositionDisposition`, + `RuntimeInitialCreatePositionConstrainPhase` — public 1:1 projections of the + internal `RuntimeTeleportHookPhase`/`RuntimeAuthoritativePositionDisposition`/ + `RuntimePositionConstrainPhase` enums (`RuntimeAuthoritativePositionRouteClassifier.cs:38,49,63`). +- `RuntimeInitialCreatePositionRouteFact` — one Position continuation's route + facts (Sequence/Disposition/HookPhase/ConstrainPhase/StopInterpolating/ + ZeroVelocity/PreserveHeading/SendPositionImmediately), projected from + `RuntimeInitialCreateExecutedAction` trace entries where + `Kind == Position` only (every other action kind stays internal-only — + widening the full action-kind vocabulary was explicitly what the contract + said to avoid). +- `RuntimeInitialCreatePlacementCompletion` — the top-level public shape + (Entity, FullCellId, TeleportHookPhase, PositionRouteFacts, + ReplayedDeferredChildCount), built ONCE by a new private + `RuntimeInitialCreateContinuationExecutor.ProjectCompletion` at the exact + completion site (`ExecuteCore`'s `Released` case, where `completedReceipt` + is built) and cached in `_completionReceipts`'s tuple (extended from + `(Sequence, Receipt)` to `(Sequence, Receipt, Public)`) — so a host polling/ + retrying `TryGetInitialCreateCompletion` never re-allocates + (the "allocation-conscious" requirement). +- New internal `RuntimeInitialCreateContinuationExecutor.TryGetCompletion` + reads the cached projection by exact token identity (same Entity/Sequence + correlation rule as the existing `TryGetCompletionReceipt`). +- New PUBLIC `RuntimePlacementProjectionChannel.TryGetInitialCreateCompletion( + RuntimeGenerationToken, in RuntimePlacementProjectionToken, out + RuntimeInitialCreatePlacementCompletion)` — generation-gated like every + other channel method, thin passthrough to the executor. The channel now + takes the executor as a 3rd internal ctor parameter; all 3 + `RuntimeEntityObjectLifetime` constructors updated to pass + `InitialCreateExecution`. + +Files touched: +- src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs + (+219/-0 net insertions: new public types, ProjectCompletion + 3 enum + mappers, TryGetCompletion, _completionReceipts tuple widened, completion + site wired). +- src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs (+32/-1: + new ctor param + field, TryGetInitialCreateCompletion). +- src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs (+9/-6: all 3 + ctors pass InitialCreateExecution into the channel). +- tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs + (+178: 4 new tests — ProjectsHookPhaseCellAndReplayCount [local-player + login correctly reports AfterEnterWorld hook phase, empty route facts], + ProjectsPositionRouteFactsForConstrainInterpolationBinding [teleport- + advanced continuation's route facts round-trip through the public + projection], RejectsWrongGeneration, ReturnsFalseAfterAcknowledgeReapsTheCorrelationEntry). + +Gates run for C3-1: focused Residence|Classifier|Executor|SetPositionState| +PlacementProjectionChannel filter 237/237; complete AcDream.Runtime.Tests +931/931 (927 baseline + 4 new); Release build of the full solution 0 +errors/21 pre-existing warnings; complete solution +`dotnet test AcDream.slnx -c Release -m:1` with +`ACDREAM_PAK_PATH=/c/Users/erikn/Documents/Asheron's Call/acdream.pak` — +every project green (App 4028/3 skips, Bake 15/0, Cli 4/0, Content 124/0, +Core.Net 762/0, Core 4242/1 skip, Headless 77/0, Runtime 931/0, +UI.Abstractions 543/0 — zero failures anywhere). `git diff --check` clean +(only the same pre-existing LF/CRLF notices on pre-existing dirty files). +Nothing staged, nothing committed. `git status` confirms only the 4 files +above are newly dirty beyond the 8 pre-existing protected paths (which I +did not touch — I read PlayerModeController.cs for C3-2 investigation but +made ZERO edits to it). + +## C3-2/C3-3/C3-4 — NOT implemented this session. Stopped with evidence +## after investigation surfaced a materially larger scope than the four +## input docs describe. Full findings below for whoever picks this up. + +I read (in full or targeted-full) beyond the four input docs: +`PlayerModeController.cs` (all 623 lines), `RuntimeLocalPlayerPhysicsPublicationState.cs` +(all 1033 lines), `RuntimeLocalPlayerMovementState.cs` (all 374 lines), +`EntityPhysicsHostComposition.cs` (all 82 lines), `RuntimeInitialCreateResidenceState.Begin`/ +`Own` (full), `RuntimeSetPositionState.TryPrepareAndSubmitAuthoredPlacement`/ +`SubmitPreparedPlacementCore` (targeted), `RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate` +(full), `DatLiveEntityProjectionMaterializer.RegisterAnimation` (full), plus +targeted greps across `RuntimeInitialCreateContinuationExecutorTests.cs` and +`RuntimeLocalPlayerPhysicsPublicationStateTests.cs` for the test harness's +own "intended recipe" (tests are the only place the FULL local-player +publication recipe is exercised end-to-end today). + +### Finding A — confirmed: no accessibility blocker, no accidental +pre-built orchestrator (cross-check of the route-inventory's own claim) + +Re-verified independently: `RuntimeLocalPlayerPhysicsPublicationState`'s +Prepare/Commit/EvaluateActivation/CommitActivation/FinalizeActivation chain +has ZERO production callers (only +`tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs`). +Unlike C1 ("satisfied by existing mechanism" — a pleasant surprise the plan +doc recorded), there is no similar hidden orchestrator tying +`RegisterEntityWithInitialResidence`'s residence lease to the publication +lifecycle automatically. The wiring described by C3-2/C3-3's bullets +genuinely does not exist anywhere, dormant or otherwise. + +### Finding B — the local-player initial-placement circular dependency +(confirmed by direct read, not previously named in any of the 4 docs) + +- `RuntimeEntityObjectLifetime.RegisterEntityWithInitialResidence` → + `InitializeAcceptedCreateResidence` → `InitialCreateResidences.Begin` + (`RuntimeInitialCreateResidenceState.cs:556-607`) → `Own` + (`:731-...`) — `Own` synchronously calls + `_setPosition.TryBeginExclusiveAuthoredPlacement(record, ..., route.OperationKind)` + (`:746-751`) whenever `route.PerformsSetPosition` is true. This runs + **at wire CreateObject time**, inside `LiveEntityRuntime.RegisterLiveEntity` + today's call site (once flipped) — i.e. it opens a `RuntimeEntityPlacementToken` + operation in stage `AwaitingPreparation` immediately, for EVERY entity + including the local player, and the lease (`lease.Placement`) sits open + until something completes it. +- `RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate` + (`RuntimeAuthoritativePositionRouteClassifier.cs:205-273`, full read) + gives EVERY TopLevel Create with a valid wire position — local player, + remote, projectile alike — `Disposition.SetPosition` uniformly + (`route.PerformsSetPosition` is true for all of them; only Parented/ + PickedUp Creates get `AwaitFreshPosition`, which does NOT open a + SetPosition operation). So this circular dependency is not local-player- + specific in its trigger — it applies to the SAME `Own()` call for every + top-level Create. +- For the LOCAL PLAYER specifically, that open placement operation can only + be completed by driving `RuntimeLocalPlayerPhysicsPublicationState`'s full + chain against the EXACT SAME `lease.Placement` token (confirmed by reading + `RuntimeLocalPlayerPhysicsPublicationStateTests.cs:2263-2300`'s `Fixture.RepreparePlacement`/ + `Prepare` helpers: `BeginAuthoredPlacement` → `PrepareMover` → `Owner.Prepare(record, + placement, command, options, activationPreparation, out token)` → + `Commit(token, out activationToken)` → `EvaluateActivation` → + `CommitActivation` — `Commit`'s own body + (`RuntimeLocalPlayerPhysicsPublicationState.cs:391-397`) calls + `_physics.SetPosition.PrepareDormantLocalActivationOwnership(candidate.Record, + candidate.Body, candidate.PreparedActivation.Token.Placement)`, i.e. it + attaches the freshly-built candidate body to THAT EXACT placement token). + The generic `SubmitPreparedPlacement`/`TryPrepareAndSubmitAuthoredPlacement` + path (the one C3-2's bullet 3 names for `AwaitingContinuationPlacement`'s + pending-token flavor) **cannot** be used for the local player's OWN initial + placement: `SubmitPreparedPlacementCore` requires + `operation.Record.PhysicsBody is not { } body` to already be non-null + (`RuntimeSetPositionState.cs:2610`) — there is no local-player body yet at + Create time, so this path structurally rejects it. Only the publication + lifecycle can attach the FIRST body to an already-open placement token. +- TODAY, `PlayerModeController.BuildControllerAndCamera` runs LATER than + Create-time (gated by `PlayerModeAutoEntry.cs:214-230`'s + `IsPlayerEntityPresent && IsWorldReady` per-frame check) and never touches + `lease.Placement`/the publication state at all — it does its own unrelated + `_physics.Resolve`/`ResolvePlacement` (PlayerModeController.cs:409-430). + So after the flip, the residence lease's placement (and therefore the + ENTIRE executor drain — deferred-child replay, the AfterEnterWorld + teleport-hook request, every continuation) stays stuck in `PendingPlacement` + from Create time until whatever replaces `BuildControllerAndCamera` drives + the publication chain AND separately calls `InitialCreateExecution.Execute(...)` + a SECOND time afterward to actually drain the FIFO. This second `Execute` + call is not optional — `Execute`'s own doc comment and the executor + handoff both say a caller re-invokes `Execute` after the placement token + in the trace is acknowledged; nothing does this automatically. +- Practically workable simplification found: `RuntimeLocalPlayerPhysicsPublicationState.Prepare` + calls `DiscardCurrent()` on entry (`:305`) before installing a new + candidate, so a full "retry the whole Prepare→Commit chain from scratch + next frame" is safe UNTIL `Commit()` succeeds (at which point `_activation` + is populated and a subsequent `Prepare` call correctly rejects via + `CanPrepare`'s `_activation is null` guard — the retry loop must switch to + re-driving `EvaluateActivation`/`CommitActivation` on the SAME + `activationToken`, not re-`Prepare`ing). This means `PlayerModeAutoEntry`'s + EXISTING per-frame "keep calling TryEnter until it returns true" loop can + likely be reused rather than inventing a wholly new scheduler — but + `PlayerModeController` needs a small piece of cross-frame state (at least + the pending `activationToken` when Commit succeeded but Evaluate/Commit- + Activation hasn't finished) that does not exist today. This is a genuinely + new resumable mini-state-machine, not a one-line change. +- The animation-sequencer hook attachment + (`AttachCycleVelocityAccessor`/`ObjectScale`/`AttachAnimationRootMotionSource`/ + `Motion.RemoveLinkAnimations`/`InitializeMotionTables`/`CheckForCompletedMotions`/ + `DefaultSink`, `PlayerModeController.cs:378-396`) currently happens on the + controller BEFORE placement resolve, via the existing + `RuntimeLocalPlayerMovementState.BeginMotionPreparation` lease + (`:102-117`). `RuntimeLocalPlayerPhysicsPublicationState.Prepare` builds + its OWN controller internally and does not return a reference to it before + `Commit()` — but since `BeginMotionPreparation` only needs a controller + REFERENCE (no ordering requirement relative to the publication state's own + internal stages), it appears safe to call `BeginMotionPreparation` on + `_movement.Controller` immediately AFTER `Commit()` succeeds (controller + is `RuntimeOwnedDormant` at that point, not yet `RuntimePublished`) and + BEFORE `EvaluateActivation`/`CommitActivation` — this avoids needing any + NEW Runtime API to expose the candidate controller pre-Commit. Flagged + as "appears safe" (not "verified safe") — needs a dedicated conformance + test before trusting it. + +### Finding C — a SECOND, previously-unstated capability gap: no production +path constructs a NON-local entity's FIRST PhysicsBody for a residence-driven +Create (this blocks Route 1/8 for remote/projectile Creates just as much as +the local-player gap blocks it for the local player) + +- `SubmitPreparedPlacementCore` (`RuntimeSetPositionState.cs:2584-2629`) + requires `operation.Record.PhysicsBody is not { } body` (`:2610`) for + EVERY entity, not just the local player — confirmed by reading the full + method; there is no branch that constructs a body when none exists. +- `TryPrepareAndSubmitAuthoredPlacement` (`RuntimeSetPositionState.cs:1562-1625`, + the "C0-3" mover-chain call the contract names for continuation + placements) is a thin wrapper around `PrepareMover` + `SubmitPreparedPlacement` + — it inherits the SAME pre-existing-body requirement. Its own doc comment + says "a residence lease's own Placement/Route.OperationKind/ + Route.SetPositionFlags are exactly the token/kind/flags this takes" — + true for the TOKEN shape, but silent on the body precondition. + `ClassifyCreate` gives Remote/Projectile TopLevel Creates the exact same + `Disposition.SetPosition` as the local player (see Finding B) — so a + fresh remote humanoid/monster Create's residence placement would ALSO + reject via this same guard today. +- The ONLY production body-construction call I found for a NEWLY-CREATED + (non-local) entity is `DatLiveEntityProjectionMaterializer.RegisterAnimation`'s + `_runtime.GetOrCreatePhysicsBody(spawn.Guid, incarnation => new PhysicsBody{...})` + (`DatLiveEntityProjectionMaterializer.cs:1003-1016`) — but this is gated + behind `physicsStatic` (`FinalPhysicsState & PhysicsStateFlags.Static`, + line 961) AND a resolved animation sequencer (`animation.Sequencer is {}`) + — i.e. it is a narrow special case for STATIC decorative animated objects + (banners, torches), never reached for an ordinary moving humanoid/monster + spawn. `RuntimePhysicsState.GetOrCreatePhysicsBody` (public, + `RuntimePhysicsState.cs:1623`) is presumably the right general-purpose + tool to reuse, but nobody calls it for the general case, and none of the 4 + input docs name this as a Route-1 capability gap (the closest hits — + route-inventory's "Gap 2" and body-writer-map's summary — are both scoped + explicitly to the LOCAL PLAYER controller/body atomicity problem, not to + ordinary remote entities). +- Building this out requires retail-fidelity decisions (what a fresh + non-static remote/projectile body's default orientation/scale/friction/ + elasticity/velocity should be at Create time, mirroring whatever retail's + `enter_world`/object-creation path does) that none of the 4 docs specify + and that I should not invent without the grep-named-first workflow this + project mandates for AC-specific behavior. + +### Why I stopped here rather than pushing an implementation + +Both findings B and C are genuine, evidence-backed (file:line cited) gaps +in the CONTRACT's own assumed shape, not just "this is a lot of code." +Landing C3-2+C3-3 correctly needs, at minimum: (1) a new resumable +mini-state-machine in PlayerModeController/PlayerModeAutoEntry driving +Prepare→Commit→EvaluateActivation→CommitActivation→(second) Execute across +frames; (2) the equivalent for headless (which has its own per-tick +`TryCompletePortal`-shaped loop already, per route-inventory's route 8 +section, that a similar chain would need to extend); (3) a NEW general +first-body-construction step for non-local residence-driven Creates, +requiring retail research this session did not do; (4) deletion of the +now-superseded duplicate authorities across ~6 files; (5) new App.Tests/ +Headless.Tests integration tests; (6) the connected lifecycle/reconnect +gate against a live ACE, which is itself explicitly one of this project's +few "stop and get user verification" events. Given the project's own +standing rules — no workarounds, no guessing at retail behavior, dual- +reviewed shape for anything this load-bearing, and "stop and brainstorm +when the observed scope diverges from the plan's assumed shape" — pushing +a rushed implementation of the single most sensitive path in the client +(both hosts' login/placement) within this session's remaining budget was +judged higher-risk than landing C3-1 clean and handing back precise, +citable findings for a properly scoped follow-up session (likely its own +C3-2a "local-player initial-placement orchestration" + C3-2b "first-body +construction for residence-driven Creates" split, each with its own +dual-review pass, mirroring how C0/C1/C2 were each already run). + +No files under C3-2/C3-3/C3-4's scope were edited: PlayerModeController.cs, +LiveEntityRuntime.cs, DatLiveEntityProjectionMaterializer.cs, +RuntimeLiveEntitySessionController.cs, HeadlessSessionWorldProjection.cs, +and RuntimeLocalPlayerMovementState.cs (the C3-4 Controller-setter seal) +are all untouched by this session (confirmed via `git status`). + +## Gate 4 (connected) — not reached, not skipped/faked + +The exact lifecycle/reconnect harness is real and located per the route +inventory: `tools/run-connected-world-lifecycle-gate.ps1` (drives capped + +uncapped-reconnect sessions against local ACE on 127.0.0.1:9000) and +`tools/run-connected-r6-soak.ps1` (canonical nine-stop route). Both require +an already-listening local ACE. I did not attempt to launch/verify ACE +reachability because there is no production code change from C3-2/C3-3/C3-4 +to gate yet — running the connected harness against C3-1's Runtime-only +addition would exercise nothing new (C3-1 has no host caller in this +session) and would misrepresent the gate as having validated the cutover. +Whoever lands C3-2/C3-3/C3-4 must run this gate for real, with a live ACE, +per the contract's gate 4 and the project's own "visual verification is the +one thing that requires stopping for the user" rule. + +## Files touched this session (C3-1 only) + +- src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs +- src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs +- src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +- tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs + +Nothing staged, nothing committed, HEAD unchanged at a32aba35d1d945b9d3194a84e70facf74a7d7608. + +## C3-1 review-fix round (same session, 2026-08-02) + +Coordinator relayed two review passes on C3-1's diff: + +1. Architecture PASS with one MINOR: `MapHookPhase`/`MapDisposition`/ + `MapConstrainPhase`'s catch-all `_ =>` arms silently folded an unmapped + future internal enum value into `None`/`NoPositionOperation` instead of + failing loudly. Fixed: every declared value now has an explicit arm + (added the previously-implicit `None`/`NoPositionOperation` cases) and + the catch-all now `throw new ArgumentOutOfRangeException(...)` with a + message naming both the mapper method and the public enum to update. + Also fixed `ProjectCompletion`'s `action.PositionDisposition ?? + RuntimeAuthoritativePositionDisposition.NoPositionOperation` fallback: + verified (grep-confirmed `BuildPositionTrace` is the sole producer of + `Kind.Position` trace entries, always passing `route.Disposition`, a + non-nullable enum) that null is NOT a legitimate state at that call + site specifically (it legitimately IS null for non-Position action + kinds elsewhere in the trace, per the field's own doc comment - just + not reachable here since the loop already filters to + `Kind == Position`) - replaced the silent fallback with an explicit + `InvalidOperationException` throw + comment explaining why. +2. Retail-conformance PASS with two documentation-only addenda (no code + changes): (a) `RuntimeInitialCreatePositionRouteFact`'s doc comment now + states explicitly that `UnparentBeforeRouting`/ + `ApplyPlacementFrameBeforeRouting` ("unset_parent"/"SetPlacementFrame") + are NOT projected because the executor's own merge + (`ApplyAcceptedPositionSnapshot`'s `clearParent`/`installPlacementFrame` + params, confirmed by direct read at the `ApplyPositionAction`/envelope + call sites) already applies both to the canonical snapshot before the + trace entry is built - a host must not re-apply them; the struct only + carries facts still DEFERRED to the host. (b) + `RuntimeInitialCreatePlacementCompletion`'s doc comment now states + `PositionRouteFacts`'s ARRAY ORDER (not `Sequence`) is authoritative - + confirmed multiple Position trace entries from one same-incarnation + envelope share one continuation `Sequence` (only `Stage`, not + projected, distinguishes them internally), and `ProjectCompletion` + preserves trace/FIFO-drain order by construction. + +New test per the reviewer's ask ("same guard shape as +`OperationResetAllFieldsToDefaultTouchesEveryDeclaredField`"): +`EnumProjectionMapsHaveEqualArityAndEveryInternalValueRoundTripsByName` in +`RuntimeInitialCreateContinuationExecutorTests.cs` - reflection-invokes the +three private static `Map*` methods against every declared value of their +internal source enum, asserting (a) equal arity between the internal and +public enum, and (b) every mapped public value's `.ToString()` name equals +the internal value's name (the mappers are literal 1:1 name mirrors by +design). Sabotage-verified twice, both reverted after confirming failure: +(1) added a member ONLY to internal `RuntimeTeleportHookPhase` - failed on +the arity assertion ("RuntimeTeleportHookPhase has 5 values but +RuntimeInitialCreateTeleportHookPhase has 4"); (2) added the SAME member to +BOTH the internal and public enum (arity equal) without adding a mapping +arm - failed via the reflection-invoked `MapHookPhase` throwing +`ArgumentOutOfRangeException` exactly as designed. Both sabotage edits +fully reverted; confirmed clean via `git diff` on the touched file showing +no residual change. + +Gates re-run after the fix: focused +Residence|Classifier|Executor|SetPositionState|PlacementProjectionChannel +filter 242/242 (241 + 1 new); complete AcDream.Runtime.Tests 932/932 (931 + +1 new); Release build of the full solution 0 errors/21 pre-existing +warnings; `git diff --check` clean (only pre-existing LF/CRLF notices, same +files as before). `git status` shows the same files as the prior C3-1 +checkpoint dirty, PLUS one file I did NOT touch: +`docs/plans/2026-08-02-placement-cutover.md` now shows a diff decomposing +C3 into C3a/C3b/C3c based on my earlier Finding B/C report - this was made +externally (not by this session; I never opened that file for editing this +round) and is left exactly as found, unstaged. Nothing staged by me, +nothing committed, HEAD unchanged at +a32aba35d1d945b9d3194a84e70facf74a7d7608. + +Files touched this round (all within C3-1's original scope, no new files): +- src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs + (Map* explicit arms + throws, ProjectCompletion null-check, doc-comment + addenda on RuntimeInitialCreatePositionRouteFact and + RuntimeInitialCreatePlacementCompletion) +- tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs + (+1 new test, +using System.Reflection) + +================================================================ +C3a -- Runtime first-entry conductor (dormant) -- implementer session +================================================================ + +## Mandatory first step: full reads completed + +Read in full before writing any code: +- src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs + (1,033 lines). +- tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs + (2,698 lines, all ~35 tests). +- docs/research/2026-07-31-remaining-physics-campaign-handoff.md (497 lines, + full) -- route-1's own required order, lines 280-292. +- docs/plans/2026-08-02-placement-cutover.md (166 lines, full). +- docs/research/2026-08-02-canonical-body-writer-map.md (681 lines, full). +- docs/research/2026-08-02-runtime-continuation-executor-handoff.md (211 + lines, full). +- src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs (1,260 + lines, full). +- src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs + (Execute/ExecuteCore, lines 1-250 and 860-1080). +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs targeted sections: + RuntimeEntityPlacementStage enum (30-39), TryBeginExclusiveAuthoredPlacement/ + PrepareDormantLocalActivationOwnership/BeginAcceptedPlacementCore + (1170-1467), PrepareMover/TryPrepareAndSubmitAuthoredPlacement/ + IsExactPreparedPlacementCurrent (1480-1652), SubmitPreparedPlacementCore + (2575-2700), RetryDeferred (3986-4020), AcknowledgeProjection (2913-3006). +- Existing test fixtures for reuse patterns: + tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs + (EngineLifetime/Bind/Spawn/AttachDormantBody/CompleteInitialPlacement, + lines 1-130 and 4963-5150) and + tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs + (FakeCollisionSource, TryPrepareAndSubmitAuthoredPlacement tests, + lines 2660-2760, 3296+). + +## Step-graph (states x transitions x owning method) + +[residence Begin -- ALREADY DONE at registration, outside conductor scope] + RuntimeEntityObjectLifetime.RegisterEntityWithInitialResidence + -> RuntimeInitialCreateResidenceState.Begin + -> RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement + (opens Operation, stage=AwaitingPreparation) + -> RuntimeSetPositionState.WatchPlacementCompletion(placement) + yields: RuntimeInitialCreateResidenceLease { Token, Route, Placement } + +Stage.AwaitingMoverPreparation (conductor entry point) + precondition: RuntimeInitialCreateResidenceState.TryGetCurrent(record, out + lease) && lease.Token == residenceToken + if !lease.Route.PerformsSetPosition (Parented/PickedUp -- never true for a + real login, kept for structural completeness): + -> Stage.Acknowledged (skip straight to Execute) + else: + RuntimeSetPositionState.TryPrepareAuthoredMover <- NEW extracted method + (Setup-read via IPreparedCollisionSource, then PrepareMover; stage + stays AwaitingPreparation; sets authority.Prepared=true) + RetrySetupUnavailable -> yield AwaitingCollisionSource (retry same stage) + Prepared -> Stage.MoverPrepared, holds RuntimeSetPositionCommand + +Stage.MoverPrepared + RuntimeLocalPlayerPhysicsPublicationState.Prepare(record, lease.Placement, + command, options, activationPreparation, out pubToken) + (validates IsExactPreparedPlacementCurrent -- REQUIRES mover already + prepared; off-canonical body+controller build against scratch clock) + RejectedAuthority -> abandon (Discard progress; nothing to undo, Prepare + never mutates on failure) + Prepared -> Stage.PublicationPrepared, holds pubToken + +Stage.PublicationPrepared + RuntimeLocalPlayerPhysicsPublicationState.Commit(pubToken, out + activationToken) + -> internally: RuntimeSetPositionState.PrepareDormantLocalActivationOwnership + (the designed seam -- binds Operation.Body, sets DormantLocalActivation + =true; requires stage STILL AwaitingPreparation + record.PhysicsBody + still null) + -> candidate.Controller.CommitRuntimeOwnership + + candidate.Record.SetPhysicsBody(body) (record now has a body) + RejectedToken/RejectedAuthority -> abandon (Publication.Commit already + self-discards its own candidate on RejectedAuthority; conductor drops + its own progress entry) + Committed -> Stage.PublicationCommitted, holds activationToken + +Stage.PublicationCommitted / Stage.Evaluated (single combined retry point -- + see "CommitActivation resume safety" note below) + RuntimeLocalPlayerPhysicsPublicationState.EvaluateActivation(activationToken, + out receipt) + RejectedToken/RejectedAuthority -> abandon + Evaluated/DeferredCell/RejectedPlacement -> receipt.IsValid in all three; + proceed to CommitActivation in the SAME Advance call (mirrors every + publication test: Evaluate and CommitActivation are always chained, + never yielded between) + RuntimeLocalPlayerPhysicsPublicationState.CommitActivation(receipt, out + projection) + (internally drives: ground phase -> HitGround/LeaveGround -> post-ground + -> collision dispatch -> post-collision -> FinalizeActivation, which is + the SAME retail-staged commit already tested) + Committed -> Stage.ActivationCommitted, holds projection + DeferredCell / RejectedPlacement -> yield AwaitingActivation, stage stays + PublicationCommitted (retry re-runs BOTH EvaluateActivation AND + CommitActivation next Advance call -- safe even for the internal + AwaitingFinalShadowPreparation resume path, see note below) + RejectedAuthority -> abandon + +Stage.ActivationCommitted + RuntimeSetPositionState.AcknowledgeProjection(projection.Token) + ("Place receipt -> acknowledgement" -- the SAME ack any host uses; moves + the watched placement token into _acknowledgedPlacementCompletions, + operation.Stage -> AwaitingCommitAcknowledgement, operation removed from + _operations) + false -> yield AwaitingReceiptAcknowledgement (retry same stage -- only + fails if not the exact FIFO head; single-entity tests never hit this, + documented for completeness) + true -> Stage.Acknowledged + +Stage.Acknowledged (or skipped-straight-here for a non-SetPosition route) + RuntimeInitialCreateContinuationExecutor.Execute(record, residenceToken, + inputs, out executionReceipt) + (internally: RuntimeInitialCreateResidenceState.Complete -- now succeeds + because IsPlacementCurrent(lease.Placement)==false and + TryPeekAcknowledgedPlacement succeeds -- -> AdoptCompletedPlacement -> + AfterEnterWorld hook -> deferred replay -> FIFO drain -> ConsumeExecuted) + Completed -> conductor Stage.Completed (progress removed) -> terminal + PendingPlacement -> yield AwaitingReceiptAcknowledgement (residence still + sees the operation as unacknowledged/current -- should not normally + recur once we've truly acknowledged, kept as a defensive yield) + AwaitingContinuationPlacement -> yield AwaitingContinuationPlacement + (a LATER Position continuation needs its own placement -- entirely the + EXECUTOR's own concern from here; conductor just passes the status + through, mirroring "Execute (FIFO drain) -> ExecutorCompleted receipt" + being the conductor's LAST step, not something it re-implements) + RejectedToken/RejectedAuthority -> abandon + +Typed yields exposed (RuntimeLocalPlayerFirstEntryStatus): Completed, +AwaitingCollisionSource, AwaitingActivation, AwaitingReceiptAcknowledgement, +AwaitingContinuationPlacement, Contention (reentrancy-guard-only -- mirrors +the executor's own `_executing.Add(key)` fail-closed pattern), RejectedToken, +RejectedAuthority. "awaiting-preparation" from the contract's five named +yields is folded into AwaitingCollisionSource / the MoverPrepared -> +PublicationPrepared span (see reconciliation below) rather than kept as an +eighth separate value -- documented at the enum declaration. + +## Reconciliation against route-1 + CONTRADICTION FOUND (resolved, did not +## silently redesign) + +Campaign handoff route-1 order (2026-07-31-remaining-physics-campaign-handoff.md:280-292): + 1. register identity cellless + 2. begin initial/remote-create placement before hydration + 3. load exact Setup mover + 4. prepare the atomic Runtime controller/body relationship + 5. submit canonical SetPosition + 6. publish presentation only from Place + 7. acknowledge, then enable player mode/simulation + +Step 3 (mover) precedes step 4 (controller/body prepare) here. + +The C3a contract's OWN restated PURPOSE-section order instead reads: + "... atomic Commit binding the body to the residence's EXACT placement + token (PrepareDormantLocalActivationOwnership is the designed seam) + -> authored-mover preparation + submission (C0's + TryPrepareAndSubmitAuthoredPlacement ...)" +i.e. it places mover-prep AFTER Publication Prepare/Commit -- the OPPOSITE of +route-1's own order 3-then-4. + +Verified against the actual staged semantics that this restated order is +IMPOSSIBLE and additionally that the named mechanism cannot be reused as +worded: +1. RuntimeLocalPlayerPhysicsPublicationState.CanPrepare + (RuntimeLocalPlayerPhysicsPublicationState.cs:886-889) requires + _physics.SetPosition.IsExactPreparedPlacementCurrent(record, placement, + command) to ALREADY be true. IsExactPreparedPlacementCurrent + (RuntimeSetPositionState.cs:1627-1651) requires + authority.Prepared && authority.PreparedCommand == command -- i.e. + PrepareMover MUST have already succeeded for this exact command BEFORE + Publication.Prepare can even be called. Mover-prep cannot happen after + Commit; it structurally gates entry into Prepare. +2. TryPrepareAndSubmitAuthoredPlacement (RuntimeSetPositionState.cs:1562-1625) + is PrepareMover followed unconditionally by SubmitPreparedPlacement. + SubmitPreparedPlacementCore (RuntimeSetPositionState.cs:2584-2630) + requires operation.Record.PhysicsBody is not {} body -- i.e. a body must + ALREADY exist. Before Commit, the record has no body (Commit is what + attaches one); calling this fused method before Commit would reject. + Calling it AFTER Commit (as the contract's restated order implies) would + NOT reject -- the body now exists -- but RetryDeferred's own comment + (RuntimeSetPositionState.cs:3988-3993) states explicitly: "The local- + player activation lease owns its dormant body/controller and must + re-enter through the same sealed evaluation/commit path ... it must + never bypass that path through the ordinary remote CommitCanonical + tail." SubmitPreparedPlacementCore has no DormantLocalActivation + exclusion check, so calling it post-Commit would silently route the + operation through the wrong (ordinary) commit tail in parallel with the + dormant Evaluate/Commit/FinalizeActivation chain -- corrupting state. +3. Confirmed empirically: the EXISTING executor test suite + (RuntimeInitialCreateContinuationExecutorTests.cs's + AttachDormantBody/CompleteInitialPlacement helpers, lines 5007-5073) + treats even isLocalPlayer: true fixtures via a direct + Entities.SetPhysicsBody + ordinary SubmitPreparedPlacement -- NEVER + through RuntimeLocalPlayerPhysicsPublicationState -- because for a + record that never sets DormantLocalActivation, the ordinary tail is + exactly correct. This is a test-only substitute for what C3a's conductor + now performs for real; it is not evidence that the ordinary tail is ever + valid for a DormantLocalActivation operation. + +Resolution: route-1's own order (mover-prep BEFORE the controller/body +prepare step) is correct and consistent with every tested invariant; the +C3a contract's restated PURPOSE-section prose transposed the two steps. +Per the contract's own instruction ("STOP with file:line evidence" rather +than silently redesigning the PUBLICATION CHAIN), this is flagged here with +full citations; the conductor is implemented using route-1's order because +(a) that is what the contract explicitly told me to reconcile against, and +(b) it is the only order that satisfies the publication chain's own +staged/tested preconditions without changing a single line of already- +tested code. The publication chain itself (Prepare/Commit/EvaluateActivation/ +CommitActivation/FinalizeActivation) is NOT modified or reinterpreted -- only +the CONDUCTOR's call order was corrected relative to the contract's prose. + +Mechanism correction: "C0's TryPrepareAndSubmitAuthoredPlacement" as named in +the contract is the WRONG vehicle for the local-player dormant path for the +reason in point 2 above (it ends in SubmitPreparedPlacement, forbidden +once DormantLocalActivation is set). RuntimeSetPositionState.cs gained one +new internal method, TryPrepareAuthoredMover, extracted verbatim from +TryPrepareAndSubmitAuthoredPlacement's FIRST HALF (Setup-read + PrepareMover +call only, no Submit) -- a pure, behavior-preserving refactor. +TryPrepareAndSubmitAuthoredPlacement itself now calls this shared helper +then submits, unchanged in every observable respect (its own two existing +tests, TryPrepareAndSubmitAuthoredPlacement_ChainsSetupReadThroughPrepareMoverToSubmit +and ..._YieldsRetryOnAMissingSetupReadWithoutMutatingStage, stay green +unmodified). The conductor calls ONLY the new TryPrepareAuthoredMover half. + +## CommitActivation resume safety note (why one retry stage suffices) + +Verified that re-running EvaluateActivation before every CommitActivation +retry -- rather than adding a THIRD stage that resumes CommitActivation alone +for the AwaitingFinalShadowPreparation internal resumption path -- is safe: +CommitActivation's own top-of-method resume check +(activation.PendingFinalCommit.Status is AwaitingFinalShadowPreparation, +RuntimeSetPositionState.cs:498-506) fires only AFTER re-validating +activation.Receipt == receipt; a fresh EvaluateActivation call sets +activation.Receipt to match whatever it just returned, so passing that +same fresh receipt back into CommitActivation satisfies the equality check +and the stored PendingFinalCommit (untouched by the extra Evaluate call) +still drives the correct resume via FinalizeActivation. Confirmed +DeferredCell/RejectedPlacement from CommitActivation clear +activation.Receipt to default (RuntimeSetPositionState.cs:546,628) -- so a +fresh Evaluate is REQUIRED, not just tolerated, on those two outcomes. The +one cost is a redundant extra Engine.SetPosition resolve in the (rare, +contention-only) AwaitingFinalShadowPreparation case -- not a correctness +issue, and simpler than tracking a fourth stage. + +## Files this session will add/touch + +- src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs -- extract + TryPrepareAuthoredMover (new internal method; TryPrepareAndSubmitAuthoredPlacement + now delegates to it, unchanged behavior). +- src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs -- NEW, + the conductor. Dormant; no production caller; constructed only in tests. +- tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs + -- NEW. + +No changes to RuntimeLocalPlayerPhysicsPublicationState.cs, +RuntimeInitialCreateResidenceState.cs, RuntimeInitialCreateContinuationExecutor.cs, +RuntimeEntityObjectLifetime.cs, or GameRuntime.cs (C3c wires production +callers and residence-retirement fan-out; that is explicitly out of C3a's +scope). One consequence documented for C3c: because +RuntimeInitialCreateResidenceState.BindRetirementNotification is a single- +subscriber seam already bound to InitialCreateExecution.DiscardProgress +inside RuntimeEntityObjectLifetime's constructor, the conductor built here +does NOT receive a push notification on external residence retirement; it +relies on lazy re-validation at the top of every Advance call plus an +explicit Forget(key) a future host can call. C3c will need to either fan the +single notification out to both subscribers or route it through the +conductor. + +## Implementation complete — gates passed + +Final design (RuntimeLocalPlayerFirstEntryState.cs, 423 lines) — a five-stage +resumable machine (AwaitingMoverPreparation -> MoverPrepared -> +PublicationCommitted -> ActivationCommitted -> Acknowledged), one +`Advance(record, residenceToken, options, activationPreparation, +collisionSource, gameTime, inputs, out receipt)` entry point, an +`_executing` HashSet reentrancy guard mirroring the executor's own, a +`Progress` class (LeaseId + Stage + the exact token/receipt/projection +structs) keyed by `RuntimeEntityKey`, and a `Discard`/`Forget` pair that +unconditionally calls `Publication.Discard`/`DiscardActivation` (both +harmless no-ops against a default/unreached-stage token). + +Bugs found and fixed during test-driven verification (all via a temporary +diagnostic build with Console.WriteLine probes, removed before the final +commit-ready state): +1. **EvaluateActivation's overloaded DeferredCell status.** Once a PRIOR + CommitActivation call has registered a lease as awaiting a specific cell + (`IsDormantLocalActivationAwaitingCell`), a REPEATED EvaluateActivation + call that is still not ready returns DeferredCell WITHOUT populating its + receipt (stays default/invalid) — confirmed against + `DeferredCommitWaitsThenRearmsSameLeaseAfterExactGenerationWake` in the + publication suite, which asserts exactly `waiting.IsValid == false` on + that repeat and never calls CommitActivation with it. The conductor now + checks `evalReceipt.IsValid` before ever calling CommitActivation, + short-circuiting straight to AwaitingActivation when it is false, instead + of blindly forwarding an invalid receipt (which CommitActivation's own + `!receipt.IsValid` guard would reject as RejectedAuthority). +2. **Re-acknowledging an already-consumed projection token.** An earlier + draft used a single "Acknowledged" stage value to mean both "just + committed, ack not yet attempted" and "ack already succeeded", causing a + retry AFTER a successful acknowledgement (e.g. because Execute yielded + AwaitingContinuationPlacement) to call AcknowledgeProjection a second + time against a token AcknowledgeProjection had already removed from its + FIFO — always failing. Split into two distinct stages + (ActivationCommitted = ack not yet attempted; Acknowledged = ack done, + only Execute remains) so the ack call only ever runs once per projection. +3. **RejectedToken vs RejectedAuthority at the residence-lookup checks.** + Mirrored `RuntimeInitialCreateResidenceState.Complete`'s own convention: + "nothing was ever tracked for this key" (no Progress entry, residence not + found) is RejectedToken; "something WAS in flight and just got + invalidated" (Progress entry existed, now stale) is RejectedAuthority — + matching the executor's identical split for the analogous case. + +Real (not test-only) findings surfaced by writing the tests, documented in +the test file itself: +- `RuntimeEntityRecord.Key` is computed from a nullable `LocalEntityId` and + becomes `null` the instant `ReleaseLocalId` runs (part of delete's + teardown in `RuntimeEntityObjectLifetime.CompleteProjectionRetirement`). + `Advance`'s entry check (`record.Key is not {} key -> RejectedToken`) + EXACTLY mirrors `RuntimeInitialCreateContinuationExecutor.Execute`'s own + entry check — once Key is null, the conductor cannot compute its own + dictionary key to reach stale progress at all. A caller that wants + deterministic cleanup after full teardown must capture the + `RuntimeEntityKey` BEFORE deletion and call `Forget(key)` explicitly; this + is a pre-existing convention in the codebase (the executor has the + identical limitation), not a defect introduced here. +- `RuntimeLocalPlayerPhysicsPublicationState` holds exactly ONE global + `_candidate`/`_activation` (instance fields, not per-key) — correct, since + there is only ever one local player — but it means an orphaned, un-Forgot + first-entry attempt for a stale incarnation will structurally block a + fresh incarnation's own `Publication.Prepare` (CanPrepare requires + `_activation is null`) until `Forget` runs. Proven by + `DeleteAndSameGuidReincarnationRequiresForgetBeforeTheFreshIncarnationCanUseThePublicationSlot`. +- `Movement.ResetSession()` proactively nulls Publication's `_activation` + directly (unlike delete, which only makes it stale via + `_entities.IsCurrent`/epoch checks) — so a retry after ResetSession sees + EvaluateActivation report RejectedToken (activation genuinely gone), not + RejectedAuthority (activation found but stale) — and, because + ResetSession never touches `RuntimeEntityRecord.Key`, ordinary retry alone + (no captured-key Forget) reaches Discard and converges. + +### Final gate results + +- `dotnet build src/AcDream.Runtime/AcDream.Runtime.csproj -c Release`: + 0 errors, 0 warnings. +- `dotnet build AcDream.slnx -c Release`: 0 errors, 21 pre-existing warnings + (same count/files as the C3-1 checkpoint; none new). +- Focused filter + `FullyQualifiedName~Publication|~Residence|~Executor|~SetPositionState|~FirstEntry`: + 308/308 passed. +- Complete `AcDream.Runtime.Tests`: 944/944 passed (932 baseline + 12 new), + 0 skips. +- Complete solution (`dotnet test AcDream.slnx -c Release --no-build -m:1`): + every project reports 0 failed — App 4028/3 skips, Bake 15/0, Cli 4/0, + Content 124/0, Core.Net 762/0, Core 4242/1 skip, Headless 77/0, Runtime + 944/0, UI.Abstractions 543/0. +- `git diff --check`: clean (only pre-existing LF/CRLF notices on the same + eight paths as every prior checkpoint; AGENTS.md is the only one with a + real, pre-existing, untouched content diff; `RuntimeSetPositionState.cs` + shows exactly my own 60/8 insertion/deletion extraction, nothing else). +- No staging, no commits, HEAD unchanged at `277ef5d0`. + +### Files touched (final) + +- `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` — extracted + `TryPrepareAuthoredMover` (new internal method, Setup-read + PrepareMover + only); `TryPrepareAndSubmitAuthoredPlacement` now delegates to it then + submits, byte-identical observable behavior (its own two existing tests + pass unmodified). +- `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs` — NEW, + 423 lines. Dormant; zero production callers (verified by the focused/ + complete/full-solution gates above, which exercise it only from the new + test file). +- `tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs` + — NEW, 12 tests: full-sequence happy path; AwaitingCollisionSource retry + +resume; AwaitingActivation retry+resume (generation wake); AwaitingReceipt + Acknowledgement retry+resume (FIFO-head contention via a second entity's + unacknowledged Place); AwaitingContinuationPlacement propagation+resume; + reentrant Advance during a collision callback (Contention, outer call + still completes); two retry-idempotency tests (mover-preparation and + AwaitingActivation stages never re-create a candidate/duplicate a body); + mid-flight delete-during-collision-callback abandonment (no Place/shadow + published, full convergence); delete-while-AwaitingActivation requiring an + explicit captured-key Forget; ResetSession mid-flight converging through + ordinary retry; delete+same-GUID-reincarnation requiring Forget before the + fresh incarnation can use Publication's one global slot. + +RuntimeLocalPlayerPhysicsPublicationState.cs, RuntimeInitialCreateResidenceState.cs, +RuntimeInitialCreateContinuationExecutor.cs, RuntimeEntityObjectLifetime.cs, and +GameRuntime.cs are all untouched, exactly as scoped. + +================================================================ +Review round 2 -- F1 (MAJOR) + F2 (MINOR) fixes +================================================================ + +## F1 (MAJOR) -- ack-failure authority re-validation + +Root cause confirmed: the ActivationCommitted stage treated EVERY +AcknowledgeProjection failure as the generic "not yet FIFO head" case. +TryAcceptDelete -> CompleteProjectionRetirement -> Physics.SetPosition.Forget +-> CancelCore rewrites the SAME pending slot from Place to Discard with a +bumped Revision; the RuntimePlacementProjectionToken struct this class +already cached in progress.Projection can then never match the FIFO head +again, so AcknowledgeProjection would fail forever -- infinite +AwaitingReceiptAcknowledgement for a dead entity, Progress retained, +IsConverged false forever, exactly as reported. + +Fix: added IsAcknowledgementStillPending(record, residenceToken, expected), +called on every failed acknowledge before deciding retryable vs abandon. +Two checks, either failing means authority moved: +1. _residences.TryGetCurrent(record, out lease) && lease.Token == + residenceToken -- the SAME residence-lookup pattern stage0/stage1 + already use. +2. _physics.SetPosition.TryPeekProjection(out head) -- if the FIFO head + belongs to THIS entity (head.Token.Entity == expected.Entity) but is no + longer the exact Place token expected (kind changed, or revision + bumped), authority for THIS SPECIFIC placement moved even if the + residence lookup alone would not have caught it. A head belonging to a + DIFFERENT entity is the genuine "not our turn yet" case and stays + retryable. +Both failing -> Discard(key) + RejectedAuthority. Read +RuntimeSetPositionState.TryPeekProjection directly (Runtime-internal-to- +internal), not through the public generation-gated +RuntimePlacementProjectionChannel -- this class is part of Runtime, not an +external host crossing that boundary, exactly like its existing direct +AcknowledgeProjection call. + +Real discovery made while testing this: FinalizeActivation nulls +Publication's OWN tracked `_activation` the INSTANT CommitActivation's +final commit succeeds (RuntimeLocalPlayerPhysicsPublicationState.cs +FinalizeActivation, `_activation = null;` right after +TryApplyDormantLocalActivationFinalCommit succeeds) -- the controller is +genuinely live/published from that point on, not a discardable in-progress +candidate. So once Stage.ActivationCommitted is reached, +Publication.DiscardActivation is ALREADY a no-op on the controller/body; +abandoning a stuck acknowledgement never retroactively un-publishes an +already-live entity -- that is ordinary entity teardown's job, not this +class's. Documented on Discard's own doc comment and confirmed empirically +(a diagnostic build showed PendingActivationCount == 0 already at the FIRST +ack attempt, before any authority change). + +New test: +`DeleteWhileAwaitingReceiptAcknowledgementAbandonsInsteadOfRetryingForeverAndConverges` +-- reaches ActivationCommitted (blocked behind another entity's own +unacknowledged Place, same technique as the existing FIFO-head test), then +calls the EXACT narrower mechanism TryAcceptDelete itself uses +(`Physics.SetPosition.Forget(record, releasePreparedMover: true)` + +`PublishCancellation`) directly rather than a full entity delete -- this +was deliberate: a FULL delete now ALSO retires the residence, and F2's +automatic retirement fan-out (below) would converge everything before a +second Advance ever ran, masking whether THIS authority-recheck code path +itself works. The narrower call proves the fix independent of F2's +wiring. Asserts RejectedAuthority, ActiveCount/PendingActivationCount +converge to 0, and the UNRELATED entity's own placement remains +unaffected and acknowledgeable. + +## F2 (MINOR) -- ownership fold + cleanup wiring + +(a) RuntimeInitialCreateResidenceState.BindRetirementNotification converted +from a single nullable Action field (throw-on-second-bind) to a +`List>` (ordered, registration-order invocation via +a new private NotifyRetirement(key) helper). Null-arg throw preserved +(ArgumentNullException.ThrowIfNull); the "already bound" throw is gone by +design since multiple subscribers are now the point. All 5 existing +invocation sites (Forget x2, Clear's two loops, Retire(Entry), +Retire(CompletedEntry)) now call NotifyRetirement instead of +`_retirementNotification?.Invoke`. + +(b) RuntimeLocalPlayerFirstEntryState's constructor no longer takes +RuntimeLocalPlayerPhysicsPublicationState (RuntimeEntityObjectLifetime is +constructed BEFORE Publication exists -- GameRuntime builds +RuntimeLocalPlayerMovementState and attaches its publication only after the +entity-object lifetime). Added the SAME late-bind pattern already used +throughout this class family (BindGeneration, BindRetirementNotification, +BindLiveInputs, RuntimeLocalPlayerMovementState.PhysicsPublication's own +throws-if-unbound accessor): `BindPublication(publication)` (bind-once, +throws on null/double-bind) + a private `Publication` accessor that throws +if unbound. All internal `_publication.X` call sites became `Publication.X`. +Added `DiscardAll()` mirroring the executor's own (discards every tracked +key's candidate/activation, then clears `_progress`). +RuntimeEntityObjectLifetime now constructs `LocalPlayerFirstEntry` in all 3 +constructors (right after InitialCreateExecution, same pattern), binds a +SECOND retirement notification (`key => LocalPlayerFirstEntry.Forget(key)`, +alongside the executor's existing one), and `BeginSessionClear` calls +`LocalPlayerFirstEntry.DiscardAll()` right after +`InitialCreateExecution.DiscardAll()`. `RuntimeEntityObjectOwnershipSnapshot` +gained `LocalPlayerFirstEntryActiveCount = 0` (trailing default, matching +the file's existing convention), folded into `IsConverged` and into +`CaptureOwnership()`'s construction. GameRuntime.cs itself was NOT touched +(BindPublication is never called in production) -- deliberate: since +Advance is never called in production, `_progress` stays permanently empty, +so Forget/DiscardAll never actually dereference Publication regardless of +binding state; wiring the production BindPublication call is left as a +natural part of C3c's Advance-caller work, not manufactured here. + +(c) Updated the two named tests plus my own new delete tests to use +`Lifetime.LocalPlayerFirstEntry` (bound via `.BindPublication(Publication)`) +instead of a separately-constructed conductor instance -- this is what +actually exercises the real wiring; a standalone instance would never see +the fan-out at all. +- `DeleteWhileAwaitingActivationRequiresForgetOfTheCapturedKeyToDiscardTheDormantActivation` + renamed to `DeleteWhileAwaitingActivationConvergesAutomaticallyThroughTheRetirementFanOut`: + delete alone now converges ActiveCount/PendingActivationCount to 0 with + NO explicit host Forget call; a follow-up Advance is a safe RejectedToken + no-op (Key already null). +- `DeleteAndSameGuidReincarnationRequiresForgetBeforeTheFreshIncarnationCanUseThePublicationSlot` + renamed to `DeleteAndSameGuidReincarnationAutomaticallyFreesThePublicationSlotForTheFreshIncarnation`: + the fresh incarnation's own Prepare now succeeds immediately after delete, + no Forget call in between. +No standalone-conductor variant was kept -- delete-triggered convergence +IS the production path once RuntimeEntityObjectLifetime owns construction, +so an explicit-Forget test would only be meaningful for a conductor built +outside the lifetime, which is not a real usage shape this slice needs to +cover (the F1 test's narrower Physics.SetPosition.Forget-only scenario +already demonstrates the authority-recheck's own logic independent of the +fan-out, satisfying that documentation need instead). + +Fixture.Dispose ordering bug found and fixed along the way: disposing +Movement (which tears down Publication) BEFORE Lifetime (whose Dispose runs +BeginSessionClear, which now reaches LocalPlayerFirstEntry.DiscardAll -> +Publication.Discard for any still-tracked entity) threw +ObjectDisposedException whenever a test left real progress untracked at +teardown (e.g. the AwaitingActivation retry-idempotency test, which never +completes or deletes within the test body). Fixed by disposing Lifetime +FIRST. Documented as a real ordering constraint for whoever eventually +disposes GameRuntime in production, since the identical dependency exists +there (RuntimeEntityObjectLifetime's conductor holds a bound reference to +Publication via BindPublication). + +## Final gate results (round 2) + +- `dotnet build src/AcDream.Runtime/AcDream.Runtime.csproj -c Release`: + 0 errors, 0 warnings. +- `dotnet build AcDream.slnx -c Release`: 0 errors, 21 pre-existing warnings + (unchanged). +- Focused filter + `FullyQualifiedName~Publication|~Residence|~Executor|~SetPositionState|~FirstEntry`: + 309/309 passed (308 + 1 new F1 test). +- Complete `AcDream.Runtime.Tests`: 945/945 passed (932 baseline + 13 new), + 0 skips. +- Complete solution (`dotnet test AcDream.slnx -c Release --no-build -m:1`): + every project reports 0 failed -- App 4028/3 skips, Bake 15/0, Cli 4/0, + Content 124/0, Core.Net 762/0, Core 4242/1 skip, Headless 77/0, Runtime + 945/0, UI.Abstractions 543/0. +- `git diff --check`: clean. Modified files now include + `RuntimeEntityObjectLifetime.cs` and `RuntimeInitialCreateResidenceState.cs` + in addition to the round-1 `RuntimeSetPositionState.cs` -- all three + diffs are additive/expected (64, 39, 68 changed lines respectively via + `git diff --stat`); the pre-existing eight dirty paths are otherwise + unchanged (line-ending noise only). +- No staging, no commits, HEAD unchanged at `277ef5d0`. + +## Files touched (round 2 additions) + +- `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs` -- + now 663 lines (was 540): F1's IsAcknowledgementStillPending; F2's + BindPublication/Publication accessor replacing the constructor + parameter; DiscardAll(). +- `src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs` -- + BindRetirementNotification multicast conversion. +- `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` -- + LocalPlayerFirstEntry property + construction in all 3 ctors + second + retirement-notification bind + BeginSessionClear wiring + + RuntimeEntityObjectOwnershipSnapshot field/IsConverged/CaptureOwnership + fold. +- `tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs` + -- now 827 lines (was 758): 1 new F1 test; Fixture now binds/uses + `Lifetime.LocalPlayerFirstEntry` instead of a standalone instance; fixed + Dispose ordering; 2 tests renamed and rewritten for automatic + convergence per F2(c). + +GameRuntime.cs remains untouched (see F2(b) note above for why). No +production caller of Advance anywhere. + +================================================================ +Review round 3 -- H1 + H2 hardening (final verdicts: retail PASS, +architecture PASS) +================================================================ + +## H1 -- NotifyRetirement snapshot before iterating + +`RuntimeInitialCreateResidenceState.NotifyRetirement` iterated the live +`_retirementNotifications` List<> directly. A subscriber binding a NEW +notification from inside a retirement callback it is itself receiving +(unreachable today -- only 2 subscribers exist, neither rebinds -- but +becomes reachable the instant C3c adds a runtime-bound third subscriber) +would throw "Collection was modified" on the very next iteration step. +Fixed per the reviewer's exact instruction, matching +`RuntimeEntityObjectEventStream`'s own copy-on-write dispatch precedent: +`foreach (... in _retirementNotifications.ToArray())`. `ToArray()` (not a +`Volatile`-guarded array swap like the event stream) was the right +granularity here since binding only ever happens a handful of times at +construction, never on a hot per-frame path -- documented on the method's +own doc comment with that reasoning spelled out. + +New test (`RuntimeInitialCreateResidenceStateTests.cs`): +`RetirementNotificationBoundReentrantlyDuringDispatchDoesNotCorruptTheCurrentIteration` +-- binds a notification that, on its first invocation, reentrantly binds a +THIRD one; triggers a real retirement via `Forget`; asserts no exception, +that the newly-bound subscriber does NOT see the in-flight retirement (not +required to), and that it DOES see the next one. + +## H2 -- unbound-Publication transactional failure + +Root cause confirmed: `AdvanceCore` had no check for an unbound +`_publication` before mutating anything. An Advance call in the window +before a host calls `BindPublication` would run the FULL authored-mover +Setup-read/PrepareMover call (mutating `RuntimeSetPositionState`'s own +`_preparedMovers`) and create/store this class's own `Progress` entry +BEFORE the FIRST `Publication` dereference (inside the `MoverPrepared` +stage) throws -- leaving a poisoned `Progress` entry in `_progress` that a +LATER, unrelated `Discard`/`DiscardAll` call (from a retirement +notification or session-clear fan-out) would ALSO throw on, corrupting +someone else's teardown. + +Fix: `AdvanceCore`'s very first statement (before `_progress.TryGetValue`, +before the ABA check, before anything) is now `_ = Publication;` -- the +existing throws-if-unbound accessor, referenced purely for its side +effect, so the whole call fails transactionally with nothing yet mutated. +Also hardened `Discard`/`DiscardAll` to tolerate an unbound `_publication` +defensively (`if (_publication is null) return;` before touching +`Publication.Discard`/`DiscardActivation`) -- both documented as +structurally unreachable post-H2 (a `Progress` entry can only exist if +`Advance` already ran, which now requires a bound `Publication` first) and +guarded anyway as belt-and-suspenders so no future caller shape can turn +an already-surfaced `Advance` failure into a SECOND throw from inside an +unrelated fan-out. + +New tests (`RuntimeLocalPlayerFirstEntryStateTests.cs`): +- `AdvanceWithUnboundPublicationThrowsTransactionallyBeforeAnyStateMutation` + -- constructs a bare `RuntimeEntityObjectLifetime` (its own + `LocalPlayerFirstEntry` is naturally unbound, since only this test file's + own `Fixture` calls `BindPublication`), registers a residence, calls + `Advance` with no bound Publication, asserts the throw AND that the + residence lease/`ActiveCount` are completely untouched, THEN + binds a real Publication and confirms the SAME token still drives + correctly to `AwaitingActivation` -- proving nothing was corrupted by the + failed attempt. +- `BindPublicationTwiceThrows` -- the standard bind-once guard test, + matching every other `BindX` method in this class family. + +## Final gate results (round 3, last of the slice) + +- `dotnet build src/AcDream.Runtime/AcDream.Runtime.csproj -c Release`: + 0 errors, 0 warnings. +- `dotnet build AcDream.slnx -c Release`: 0 errors (warning count reported + as 0 on this incremental rebuild since no other project's files changed + and MSBuild skipped re-analyzing them as up-to-date; the prior two + rounds already confirmed 21 pre-existing warnings, all in untouched test + files, with a from-scratch build). +- Focused filter + `FullyQualifiedName~Publication|~Residence|~Executor|~SetPositionState|~FirstEntry`: + 312/312 passed (309 + 3 new: 1 H1 + 2 H2). +- Complete `AcDream.Runtime.Tests`: 948/948 passed (932 baseline + 16 new + across the whole C3a slice), 0 skips. +- Complete solution (`dotnet test AcDream.slnx -c Release --no-build -m:1`): + every project reports 0 failed -- App 4028/3 skips, Bake 15/0, Cli 4/0, + Content 124/0, Core.Net 762/0, Core 4242/1 skip, Headless 77/0, Runtime + 948/0, UI.Abstractions 543/0. +- `git diff --check`: clean. Modified files now additionally include + `tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs` + (+47/-0, the new H1 test) alongside + `RuntimeInitialCreateResidenceState.cs` (54 changed lines, up from 39 in + round 2 -- the ToArray snapshot + doc comment) and the unchanged round-1/2 + files. The pre-existing eight dirty paths remain line-ending noise only. +- No staging, no commits, HEAD unchanged at `277ef5d0`. + +## Files touched (round 3 additions) + +- `src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs` -- + `NotifyRetirement` now snapshots via `ToArray()`. +- `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs` -- + now 690 lines (was 663): upfront unbound-Publication check in + `AdvanceCore`; `Discard`/`DiscardAll` unbound-Publication tolerance. +- `tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs` + -- +1 new H1 test. +- `tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs` + -- now 922 lines (was 827): +2 new H2 tests. + +This is the last round of code changes for C3a per the coordinator's +message. GameRuntime.cs remains untouched throughout the whole slice; no +production caller of Advance anywhere. + +## C3b implementer progress (remote body construction at Create) + +- Verified HEAD d62b9950 on codex/port-claude-agents; 8 protected dirty paths untouched. +- Read: plan (C3b scope), float-gates doc (3 byte-certain gates), retail-notes.md + (CreateObject 0x00558870 order; set_description 0x00514F40 order; PhysicsDesc::UnPack), + writer map (6 canonical SetPhysicsBody writers), PhysicsBody.cs, + RuntimeInitialCreateResidenceState.cs, RuntimeLocalPlayerFirstEntryState.cs (C3a shape), + RuntimeSetPositionState mover/submit/ack/park/retry paths, RuntimePhysicsState bind sites, + route classifier, C3a test harness. +- Resolved SetMotionTableID(0) semantics from pseudo-C: CPhysicsObj::SetMotionTableID + 0x00512780 (pc:280528) fails ONLY when part_array==0 (005127da) or + MotionTableManager::Create fails for a NONZERO id (CPartArray::SetMotionTableID + 0x005186E0, pc:286732, 0051872f); id==0 skips manager creation (0051871f) and + returns 1 -> gate PASSES for zero id. CPhysicsObj skips MakeMovementManager for + INVALID_DID (005127ca). +- Recovered PhysicsDesc ctor defaults 0x0051D4D0 (pc:292056): friction "33s?" = + 0x3F733333 = 0.95f; elasticity 0.05f; translucency 0 (memset); scale 1; state 0x400c08. +- Recovered set_elasticity 0x0050FD40 (pc:277817): <0 -> 0; <=0.1 -> value; >0.1 -> 0.1. + Cross-checked ACE PhysicsGlobals.MaxElasticity = 0.1f (PhysicsObj.cs:3586-3599). +- Design: RuntimeRemoteFirstEntryState (Entities/, no publication chain) with stages + MoverPreparation -> BodyConstruction (via canonical RuntimePhysicsState.GetOrCreatePhysicsBody + factory, retail set_description order) -> Submit (lease.Placement) -> Withdraw/Place ack + (TryPeekProjection loop for deferred parks) -> Execute. Pure builder + RuntimeRemoteBodyDescription + construction receipt for gate proofs. +- IMPLEMENTED: RuntimeRemoteBodyDescription.cs (288 L, pure set_description-ordered + construction + gated receipt), RuntimeRemoteFirstEntryState.cs (620 L, six-stage + resumable conductor, no publication chain), lifetime wiring (+51/-2: construction in + all 3 ctors, third multicast retirement binding, BeginSessionClear DiscardAll, + RemoteFirstEntryActiveCount snapshot field + IsConverged clause), tests (879 L, 29 + tests, all pass first run). +- GATES: Runtime build 0W/0E; solution build 0 errors, 21 pre-existing warnings none in + changed files; focused filter (Residence|Executor|SetPositionState|FirstEntry| + RemoteEntry) 247/247; complete Runtime 977/977; git diff --check clean; 8 protected + dirty paths + AGENTS.md untouched; nothing staged/committed. +- No conflicts stopped on: motion-table zero-id semantics resolved from pseudo-C + (gate passes for id 0); elasticity clamp recovered (0..0.1) + ACE cross-check; + PhysicsDesc ctor defaults recovered for absent-wire fields. +- REVIEW ROUND (arch M1/M2 + retail R1/R2) closed: construction receipt now rides the + terminal Advance out-param (M1); shared RuntimeFirstEntryAcknowledgement.IsStillPending + used by BOTH conductors + new delete-after-commit-before-ack abandonment test (M2); + movement branch re-gated on buffer non-emptiness per UnPack's buff_length!=0 assignment + (R1, both-ways tests; parser's empty-buffer wrapper confirmed at CreateObject.cs:597); + elasticity NaN -> 0f per retail's first-arm unordered (R2a, ACE divergence noted); + friction NaN skip kept + commented per gates-doc quirk (R2b; doc addendum text in + final report); translucency NaN matches retail apply-bucket already (noted). +- FINAL GATES: focused 252/252; complete Runtime 982/982; build 0 errors, no warnings in + changed files; git diff --check exit 0; nothing staged; protected paths untouched. + +# ============================================================ +# C3c — THE HOST FLIP (new session) +# ============================================================ + +## Reading phase (complete) +- Verified worktree HEAD 78f1eb18 on codex/port-claude-agents; 8 dirty files = protected list. +- Read: c3c-contract.md, plan doc C0-C3b notes, route inventory routes 1+8 + cross-cutting, + canonical-body-writer-map, both conductors, executor surface, channel + subscription + + retry slot, both sinks, both session routes, GameRuntime, RuntimeLocalPlayerMovementState, + PlayerModeController (protected-noise), LiveEntityRuntime placement family, materializer, + HeadlessSessionWorldProjection, RuntimeLiveEntitySessionController, publication Prepare, + RuntimeFirstEntryAcknowledgement, synchronous event-stream dispatch, AcknowledgeProjection + (strict FIFO-head, non-idempotent), PublishExecutorCompletion (snapshot carries body + position/orientation + token.ExactCellId; residence released before publish). + +## Design conclusions +1. One acknowledger per receipt: conductors consume their own initial Place/Withdraw + (proven by AwaitingReceiptAcknowledgement tests). Both host sinks must return false + (leave-at-head) for Place/Withdraw of an entity with an ACTIVE initial-create + residence (TryGetInitialCreateResidence discriminator). +2. ExecutorCompleted = the presentation-binding receipt (C3-1's purpose). Graphical sink + applies Place-shaped presentation from its snapshot (celless => ack-and-ignore); + returns false until sidecar+backend ready (existing FIFO-head retry semantics). +3. Drive points: graphical = post-hydration in the Create flow + wrap the per-frame retry + slot callback (drive conductors then RetryPending). Continuation placements completed + via C0 fused TryPrepareAndSubmitAuthoredPlacement + AcknowledgeProjection. +4. GameRuntime first act: LocalPlayerFirstEntry.BindPublication(Movement.PhysicsPublication) + after AttachPhysicsPublication (GameRuntime.cs:260-265). + +## C3c implementation state at session end +- ALL FIVE SCOPE ITEMS IMPLEMENTED in production code; complete solution builds 0 errors. +- Runtime tests 982/982 GREEN (3 direct-sink tests rewritten to started-generation + driven-conductor form). +- Headless tests 74/77 (3 failures: WorldProjectionHydratesCanonicalMovementAndTeleportState + 2 others — old + SynchronizeLocalPlayer expectations; fixtures need conductor-driven rework). +- App tests 3,865/4,031 passing, 163 failing after central fixture repair (LiveEntityRuntimeFixture now binds + generation 1). Remaining classes: (a) ~90 fixtures with private lifetimes and no generation bound + ("cannot acquire a structurally valid initial residence lease"); (b) ~40 hand-built spawns failing + HasConsistentCreateIdentityAndParent ("inconsistent instance or parent projections"); + (c) ~30 behavior-expectation updates (suppressed-until-receipt visibility, FullCellId staying 0 for + undriven residences). +- New contract-required integration tests NOT yet written; connected gates NOT reached (automated gates + not green — per gate order, stopped and reported). +- Nothing staged or committed. Protected dirty paths untouched except the sanctioned surgical + PlayerModeController.cs touch (flagged). + +## Continuation session (fixture repair) +- App: 443 -> 163 -> 93 -> 50 failing (3,978/4,031 passing). Repairs, all mechanical, NO assertion changes: + (1) generation binds: LiveEntityRuntimeFixture (all 5 overloads), LiveEntityHydrationControllerTests fixture, + EquippedChildProjectionWithdrawalTests fixture (own lifetime + production drive controller + landblock + collision generation); + (2) consistent-spawn repair: new shared tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs + (WithConsistentPhysics extension deriving the nested PhysicsDesc from flattened fields), applied to + CreateSupersessionRecovery, Vfx light, DeferredLifecycle, LocalPlayerTeleport, LiveAppearanceAnimation, + StreamingFrame builders; per-file physics blocks for LiveEntityPhysicsHostOwnershipTests (19->0) and + EquippedChildProjectionWithdrawalTests child/embedded-parent spawns (23->0); + (3) conductor-completion where legacy direct application now requires a released residence + (NoPositionCreateParent test: CompleteFirstEntry before TryApplyCreateParent). +- Headless: 77/77 GREEN. The 3 SynchronizeLocalPlayer-era tests rewritten CONDUCTOR-DRIVEN with the real + host wiring (host.Start -> RegisterEntityWithInitialResidence -> drive controller constructed BEFORE + registration -> HeadlessSessionWorldProjection pump). All original assertions preserved verbatim + (controller identity, LocalEntityId, positions, PortalSpace/InWorld, CenterCount 3/2, receipt-validation + no-authority invariants). Coverage not shrunk; WorldProjectionHydratesCanonicalMovementAndTeleportState now + doubles as a headless first-entry integration flow. +- Runtime: 982/982 GREEN (incl. probe-restoration build). +- Autowalk probes RESTORED at the Runtime site (diagnostic-owner pattern, PhysicsDiagnostics.ProbeAutoWalkEnabled): + [autowalk-target] + [autowalk-end reason=interrupt] in RuntimeLocalPlayerPhysicsPublicationState.Prepare's + host/motion closures; [autowalk-end reason=complete] retained App-side on the approach-lifetime MoveToComplete. + +## Expectation changes +(Continuation 4 — the logged pass. Clause key: (1) suppressed-until-receipt +visibility; (2) undriven-residence semantics; (3) residence-gated +Place/Withdraw with ExecutorCompleted as the presentation-binding receipt; +(4) sealed-setter lifecycle routing. Where the true mechanism is committed +C3a/C3b/C3-1 residence design the clause labels do not literally name — +SameIncarnationCreate FIFO staging (AD-59), conductor-built bodies at Create +(C3b), no create-authority advance outside the residence transaction — the +line cites the closest clause PLUS the design mechanism; all such lines are +flagged [interp] for coordinator veto.) + +1. CurrentGameRuntimeAdapterTests.DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace + → old: both hosts register + hand-driven apply chain produce identical + entity-object traces → new: at the deliberate zero generation BOTH hosts + refuse the initial Create transactionally with the identical structural + error ("cannot acquire a structurally valid initial residence lease"), + identical EMPTY traces, zero entity/object counts → clause (2). Full + driven-flow parity moved to the new C3c integration tests. +2. UpdateFrameOrchestratorTests.GameplayInputOwnersUseTypedSeamsWithoutGameWindowBackReferences + → old pinned PlayerModeController source order: PreparePositionForCommit + → EnterChaseMode → SelectStableHostWithoutRebind → SyncPose → + InstallOrRebind → SetPosition(initial) → CommitPreparedPosition → + `_controllerSlot.Controller = controller` → IsPlayerMode=true → new pinned + order: controller.IsRuntimePublished gate → EnterChaseMode → SyncPose → + `_hostSlot.Host = playerHost` → IsPlayerMode=true → clause (4) (the + deleted markers were exactly the App-side controller construction+commit). +3. LiveEntityHydrationControllerTests.CompletedSpatialRecovery_DetectsCreateVersionDriftWithoutNestedHydrationRequest + → old: PositionSequences [1,1,2], last purpose CreateSupersessionRecovery, + InstalledCreateIntegrationVersion 2UL → new: [1,1], SpatialRecovery, 1UL + → clause (3) [interp]: a post-residence same-generation Create is + description-only at registration; its position churn flows through the + freshness-gated events tail; create authority advances only inside the + residence transaction, so no drift-replay fires. +4. LiveEntityHydrationControllerTests.TimestampCallbackFresherSameGeneration_StopsOuterCreateVersion + → old: single materialize from the nested newer spawn (PositionSequences[0] + == 2) → new: single materialize from the admission-frozen seq-1 create + (== 1); the seq-2 facts commit at the executor drain in array order → + clause (3) [interp, AD-59 FIFO staging]. +5. LiveEntityHydrationControllerTests.ObjectRemovalCallbackFresherSameGeneration_WinsOuterReplacement + → old: last materialize seq 3 + DoesNotContain(2, Skip(1)) → new: last + materialize is the admission-frozen seq-2 replacement + DoesNotContain(3) + (the seq-3 facts commit at the drain and never re-materialize) → + clause (3) [interp, AD-59]. +6. LiveEntityHydrationControllerTests.FailedCompletedSupersession_RemainsPendingUntilExactRetry (both rows) + → old: InstalledCreateIntegrationVersion == (retryFromLandblock ? 2 : 3) + → new: == 2 in both rows (the retry retransmit no longer advances create + authority) → clause (3) [interp]. Drift probe swapped from a nested + OnCreate to record.Canonical.AdvanceCreateAuthority() (the drain-stage + advance) so the retained-obligation/exact-retry machinery under test + still fires; every other assertion verbatim. +7. LiveEntityInboundAuthorityGateTests.Position_CanonicalInventoryObjectIsAcceptedBeforeProjectionExists + → old: accepted.PositionAuthorityVersion == 2 (post-apply) → new: == 1 + (admission-time; the merge is queued behind the pending residence and its + authority advance commits at the drain) → clause (2). +8. Probe swaps with ALL assertions verbatim (logged for transparency, + mechanism = same-generation Creates no longer advance create authority at + registration; modeled by record.Canonical.AdvanceCreateAuthority(), the + exact drain-stage advance) [interp, clause (3)]: + - LiveEntityHydrationControllerTests.CompletedSupersessionReadyFailure_RetriesWholeCommitBoundary (both rows) + - LiveEntityHydrationControllerTests.ReadyPublisher_RevalidatesExactRecordBetweenEveryStage (3 rows) + - LiveEntityHydrationControllerTests.ReadyPublisher_RevalidatesAfterReentrantRenderProjectionCallback + - LiveEntityHydrationControllerTests.EquippedChildReadyCandidate_RejectsVersionAdvancedByPoseCallback + - LiveEntityCreateSupersessionRecoveryTests.Recovery_CreateVersionAdvanceDuringAppearance_StopsLaterOwners +9. LiveEntityHydrationControllerTests.ParentSupersession_CompletesAtExactAttachedReadyBoundary (both rows) + → scenario repairs: (a) parent registered up front (retail queues a child + Create under an unaddressable parent — R5-1 QueueBlobForObject — instead + of applying it); (b) drift probe as in item 8; (c) the OnSpawnAction hook + now mirrors the production relationship owner's sticky-residence + conversion (AwaitRuntimePlacement → LegacyImmediate) at the + world→attached kind transition. Assertions verbatim → [interp, clause (3)]. +10. LiveEntityHydrationControllerTests.PositionAfterPickup_ReentersWithSameEntityBodyAndResources + → body scaffolding changed from seeding a fresh PhysicsBody to capturing + the conductor-built canonical body (C3b bodies-at-Create; factory now + throws if missing — a STRONGER assertion). Identity-preservation + assertions verbatim → [interp, C3b]. +11. LiveEntityRuntimeTests.PhysicsStateAndRemoteBodyStaySynchronizedAcrossEitherArrivalOrder + → old: seeded RemoteMotionRuntime bodies observe state sync across + bind/state arrival orders → new: the conductor-built canonical bodies + are observed (C3b never-clobber forbids seeding a replacement); "arrival + order" is now SetState-FIFO'd-before-the-drain vs + SetState-after-completion; the two expected state-flag transforms are + UNCHANGED → [interp, C3b + clause (3)]. + +## Remaining at session end (App 50) +- LiveEntityHydrationControllerTests 21 (supersession/recovery semantics + 5 "publication owner not bound" + = the fixture drive needs a bound publication chain for isLocalPlayer leases: add + RuntimeLocalPlayerMovementState+Identity+PublicationState+BindPublication to that fixture); +- RuntimePlacementPresentationSinkTests 4 (sink behavior changed by design - residence gate + ExecutorCompleted + presentation; these need logged expectation updates per contract clauses); +- LiveEntityRuntimeTests 4, LiveEntityPresentationControllerTests 4, remaining consistent-spawn stragglers in + builders that already carry physics blocks (field-level mismatches), CurrentGameRuntimeAdapterTests 2 + (deliberate zero-generation binds), misc singles. +- NOT STARTED: the five contract integration tests; Release build; complete solution (-m:1, ACDREAM_PAK_PATH); + connected gates (blocked behind green automated gates per the pinned gate order). +- No production regression found: the one suspicious trace (FullCellId=wire-cell in a pickup-supersession test) + was ruled out against ApplyAcceptedSpawn (object-table only, no cell write) - it is a fixture-flow artifact. +- Post-log continuation: hydration fixture publication chain bound (Movement/Identity/PublicationState + + BindPublication + ServerGuid) -> hydration 21 -> 18; App total 50 -> 47 (3,981/4,031). Runtime 982/982 and + Headless 77/77 re-verified green after all repairs. Nothing staged; protected paths still untouched + (PlayerModeController touch remains the one sanctioned exception). + +## Continuation 2 (guard-zone stop) +- Hydration fixture now also carries the REAL RuntimePlacementProjectionSubscription (ack-only sink mirroring + production rules: Discard/ExecutorCompleted ack, Place/Withdraw left for conductors) - fixture is now the + full production wiring shape (generation + collision generation + publication chain + drive pump + + subscription). Hydration failures unchanged at 18 => the FIFO-wedge hypothesis is ruled OUT; the 18 + supersession/recovery failures are NOT a simple fixture gap. +- STOP under instruction-1 guard: the observed symptom (nested same-generation Create during + RecoverProjection no longer triggers CreateSupersessionRecovery; Assert.Throws sees no throw at line 1247) + means the initial residence is STILL ACTIVE when the nested create arrives, i.e. the drive after the first + OnCreate did not complete the conductor in this fixture. Before editing ANY of these 18 tests' assertions I + need to establish WHY the conductor yields here (candidate: something in the fixture's stub materializer / + spawn shape leaves the placement deferred or the executor pending) - because if the same yield can happen in + PRODUCTION's composed path, this is a real flip defect (login supersession burst leaving residences pending), + not an expectation update. That determination requires a focused diagnostic run I could not complete in the + remaining session budget. +- Expectation changes so far: STILL ZERO. +- Totals at stop: Runtime 982/982, Headless 77/77, App 3,981/4,031 (47 failed, 3 skipped). +- Items 2-4 (zero-generation adapter tests, physics-block mismatch stragglers, five integration tests, + Release/solution/connected gates) not reached. + +## Continuation 3 — DIAGNOSTIC + CLASSIFICATION (coordinator-directed) +- Instrumented RuntimeFirstEntryDriveController.DriveOne (yield status + FIFO head + residence-active + + FullCellId per step, file-logged); ran FailedCompletedSupersession_RemainsPendingUntilExactRetry. +- YIELD CHAIN (before fix): first and only drive step yielded RejectedToken with residenceActive=False and + fullCell=0x01010001 ALREADY COMMITTED — the residence was retired out-of-band before the pump ever ran. +- ROOT CAUSE: the hydration fixture's RecordingMaterializer called the internal + LiveEntityRuntime.MaterializeLiveEntity overload WITHOUT the residence parameter -> LegacyImmediate -> + legacy RebucketLiveEntity fall-through (LiveEntityRuntime.cs:777) -> CommitRebucket committed the wire cell + and advanced placement/spatial authority -> residence IsCurrent detected the out-of-band commit and retired + the lease -> conductor correctly RejectedToken. +- CLASSIFICATION: FIXTURE ARTIFACT. Production's route-1 materializer + (src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs, MaterializeProjection: residence = + retainedRecord?.MaterializationResidence ?? AwaitRuntimePlacement) never takes LegacyImmediate for an + initial world create, so production cannot reach this retirement path. Post-fix diagnostic re-run: + status=Completed, fullCell committed by the CONDUCTOR, FIFO drained — the drive converges. +- Fixture fixed at the choke point (stub materializer now passes AwaitRuntimePlacement; subscription upgraded + to a production-mirroring FixturePlacementSink: Discard ack, ExecutorCompleted -> + TryApplyInitialCreateCompletionPresentation, Place/Withdraw residence-gated -> + TryApplyRuntimePlacementProjection). Diagnostics stripped; Runtime rebuilt 0 errors. +- Result: hydration failures 18 -> 19 (group did NOT shrink; composition changed — the flows now complete + their conductors and fail on POST-flip semantic assertions: supersession/recovery expectations against + suppressed-until-receipt visibility + conductor-committed cells). These 19 + the rest of the inventory are + now genuinely the logged-expectation-update pass (guard rules unchanged, zero changes logged so far). +- Totals at stop: Runtime 982/982, Headless 77/77, App 3,980/4,031 (48 failed, 3 skipped). No staging/commits. + +## Continuation 4 — expectation-update pass + integration tests + gate ladder +- Session start state re-verified: HEAD 78f1eb18, protected dirty paths intact, nothing staged. +- Full App run (Release): 47 failed / 3,981 passed / 3 skipped of 4,031 (one fewer + than the Continuation-3 count of 48; the inventory below is the current truth). +- Classified inventory: Hydration 19; Withdrawal 6; Sink 4; LiveEntityRuntime 4; + Presentation 4; RemotePhysicsUpdater 2; CurrentGameRuntimeAdapter 2; singles 6 + (LifecycleStress, UpdateFrameOrchestrator, LiveSessionResetPlan, + InboundAuthorityGate, CreateSupersessionRecovery, LiveAppearanceAnimation). +- CLASSIFICATION POLICY DECLARED (auditable): where a required change's true + mechanism is committed, dual-reviewed C3a/C3b/C3-1 residence design that the + four clause labels do not literally name (SameIncarnationCreate FIFO staging + per AD-59; conductor-constructed bodies at Create per C3b; no create-authority + advance outside the residence transaction), the change is logged under the + closest clause WITH an explicit mechanism note and flagged in the final + report for coordinator veto. Verbatim-assertion fixture repairs get no + clause line per the guard's own exemption, but are listed below. + +### Fixture repairs (assertions verbatim, no expectation-change lines) +1. RuntimePlacementPresentationSinkTests.Fixture.Materialize — normalize the + stale residence (legacy-immediate materialization commits the wire cell + out-of-band; the query performs lazy retirement) BEFORE tests capture + ownership snapshots; previously the retirement happened inside the sink's + own first residence query and shifted SetPositionOperationCount/ + AwaitingSetPositionPreparationCount 1->0 mid-TryApply. 14/14 green. +2. LiveEntityProjectionWithdrawalControllerTests.Fixture.Spawn — spawn had no + Physics block but non-null Position/Setup/Scale + InstanceSequence!=0; + wrapped with the shared WithConsistentPhysics (sanctioned for block-less + builders). 6/6 green. +3. LiveEntityPresentationControllerTests.Fixture.Spawn — builder ALREADY + carries a physics block; field-level fix only (Timestamps.Instance was + hardcoded 1 vs the instanceSequence parameter used by guid-reuse tests). + NO blind wrap; RawState untouched. 12/12 green. +4. RemotePhysicsUpdaterTests boundary Spawn(ushort instanceSequence, ...) — + same field-level Timestamps.Instance fix; RawState untouched. Both green. +5. LiveAppearanceAnimationTests.Capture_... — block-less cellless spawn with + InstanceSequence 1; WithConsistentPhysics wrap (file's other builder + already used it). Green. + +### Continuation 4 — production changes beyond the inherited diff (both +### flagged for coordinator review; each is one revertible hunk) +1. src/AcDream.App/Rendering/EquippedChildRenderController.cs (TryAttach, + before MaterializeLiveEntity): converts a retained residence-managed + child's sticky residence AwaitRuntimePlacement → LegacyImmediate at the + world→attached kind transition. WITHOUT this, equipping a world-created + (cut-over) item throws at LiveEntityRuntime.cs:655's residence-change + guard ("cannot change its materialization residence from + AwaitRuntimePlacement to LegacyImmediate") because the attach path passes + the default LegacyImmediate — while the flip's own materializer comment + (DatLiveEntityProjectionMaterializer.cs:712-722) states equipped children + must carry LegacyImmediate so a later drop-to-world stays legacy "by + construction". Found via ParentSupersession_CompletesAtExactAttachedReadyBoundary; + production-reachable (pickup → CreateParent → TryAttach with a retained + sidecar). This completes the flip's documented design, not new invention. +2. src/AcDream.App/World/LiveEntityRuntime.cs (RebucketLiveEntity, + residence-managed branch): while the initial-create residence is still + ACTIVE, the presentation-only move now refuses (returns false) — the + completion receipt is the entity's first world-visible moment (clauses + 1/3). The inherited flip had made the presentation-only move + unconditional, re-opening the mid-registration reentrancy hazard pinned + by RuntimePlacementPendingMaterialization_OwnsResourcesWithoutPublishingResidence + (a resource-registration observer could install a bucket for a suppressed + record before its placement committed). A STALE residence lazily retires + inside the same query, so post-residence legacy moves are unaffected; the + completion-receipt path calls RebucketLiveEntityPresentationOnly directly + and never crosses this gate. + +### Continuation 4 — additional fixture repairs (assertions verbatim) +6. LiveEntityLifecycleStressTests fixture — generation bind on its private + lifetime. 7. LiveSessionResetPlanTests.GraphicalResetHost — entity seeded + through the legacy direct RegisterEntity (session-less GameRuntime has + generation 0; the subject is reset/teardown retry, not the create flow). +8. CurrentGameRuntimeAdapterTests.GraphicalObserverFailure — session started + first (its siblings' existing pattern). 9. Hydration RecordingMaterializer + — mirrors the production materializer's self-projection branch (committed + cell + no active residence → presentation-only rebucket); fixed + PositionAfterPickup/PositionAfterInventoryOnlyCreate spatial-projection + truth. 10. PartialProjection_IsRetriedInsteadOfMistakenForCompletedHydration + — models the production per-frame pump (FirstEntry.DriveAll) before the + streaming recovery (the failed Create unwound before OnCreateCore's own + pump; an undriven residence leaves FullCellId 0 and streaming candidates + key off the committed cell). 11. LiveEntityRuntimeFixture.CreateDriven — + NEW driven variant (collision generation + engine landblock + production + RuntimeFirstEntryDriveController + ack-only subscription mirroring host + rules); applied to InitialChildCreate_PreservesParentEventQueued..., + PositionAfterPickup_RequiresTeleportHookEvenWithEqualTeleportStamp, and + PhysicsStateAndRemoteBodyStaySynchronizedAcrossEitherArrivalOrder. + +### Checkpoint: FULL App suite GREEN — 4,028 passed / 0 failed / 3 skipped +### of 4,031 (Release). Next: Runtime + Headless re-verification, then the +### five contract integration tests. + +### Continuation 4 — five contract integration tests (all green, real host +### wiring: registration -> subscription -> RuntimeFirstEntryDriveController; +### no hand-called conductor sequences) +NEW tests/AcDream.App.Tests/World/RuntimeFirstEntryHostIntegrationTests.cs +(fixture: real RuntimeEntityObjectLifetime + generation + committed collision +generation + LiveEntityHydrationController.OnCreate route + production +RuntimePlacementPresentationSink behind the real +RuntimePlacementProjectionSubscription + real drive controller + local-player +publication chain; materializer is the production-mirroring double): +1. InitialCreate_ResidenceConductorReceipt_BindsWorldVisibilityExactlyOnce — + residence begins once, sidecar provably suppressed at materialize time, + conductor completes inside the Create transaction, exactly one visibility + edge, world snapshot at the committed pose, all ledgers drained. +2. DeferredParentCreate_StaysInvisibleUntilParentReplay — unaddressable-parent + child: no canonical/sidecar/presentation, queued under the parent GUID; + parent Create replays it (real registration delegate), next-frame pump + completes the parented conductor; child celless + presentation-suppressed, + only the parent world-visible. +3. LocalLogin_PresentationAttachFailure_RetriesWithoutRuntimeRollback — the + camera/shadow-analog App attach failure (first visibility binding throws) + AFTER the Runtime commit: published PlayerMovementController + (IsRuntimePublished) + canonical body + committed cell all survive, the + completion receipt stays pending, RetryPending() binds presentation with + the SAME controller instance. NOTE: the literal PlayerModeController + camera object is not constructed here (its ~20-dependency graph has no + focused harness); its presentation-only rollback is pinned by the updated + UpdateFrameOrchestrator source assertions + this receipt-level analog. +5. GraphicalAndDirectHosts_CommitIdenticalFirstEntryRuntimeFacts — the same + spawn through the full graphical wiring vs the no-window direct host + shape (RegisterEntityWithInitialResidence + pump + ack-only subscription) + commits byte-identical canonical first-entry facts (cell, versions, body + pose/state/InWorld, snapshot sequence, local id). +NEW in tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs: +4. MissingPreparedCollisionYieldsTypedRetryAndCompletesWhenAvailable — flaky + IPreparedCollisionSource (Missing -> Loaded) through the REAL + HeadlessSessionWorldProjection.ProjectSpawn pump: no exception escapes, + entity stays tracked/pending with no controller and cell 0, the session + tick's retry pump completes placement + publishes the controller. +Totals after the tests: Headless 78/78 (77+1); App integration class 4/4. + +### Continuation 4 — gate ladder +1. Complete test projects (Release): App 4,032 passed / 0 failed / 3 skipped + of 4,035 (4,031 + 4 new integration tests); Runtime 982/982; Headless + 78/78 (77 + 1 new). GREEN. +2. dotnet build AcDream.slnx -c Release --nologo: 0 errors, 18 warnings + (pre-existing test-project warnings; none in files this session touched). + GREEN. +3. Complete solution (-m:1, ACDREAM_PAK_PATH=C:\Users\erikn\Documents\ + Asheron's Call\acdream.pak): IN FLIGHT (background). +4. Connected gates: ACE confirmed listening on UDP 9000 (PID 22100), + C:\ACE\Server\ACE_Log.txt present, no AcDream.App/acclient processes + running. Will run after gate 3. + +### Continuation 4 — gate ladder RESULT (stopped at gate 4 per pinned order) +3. Complete solution (Release, -m:1, ACDREAM_PAK_PATH): GREEN — App 4,032/3 + skips (4,035), Bake 15, Cli 4, Content 124, Core.Net 762, Core 4,242/1 + skip (4,243), Headless 78, Runtime 982, UI.Abstractions 543 = 10,782 + passed / 0 failed / 4 skipped of 10,786. +4. CONNECTED tools/run-connected-world-lifecycle-gate.ps1: **FAIL — STOPPED + HERE.** Artifacts: logs/connected-world-gate-20260802-122749/ + (report.json Passed=false; capped/ + uncapped-reconnect/ each with + stdout.log, stderr.log, artifacts/). Both sessions connected, entered + world as 0x5000000A, ran the 54-command UI probe, requested AND received + graceful logout confirmation, then CRASHED identically with an unhandled + System.InvalidOperationException: "A sealed, retired, or discarded + Runtime movement controller cannot be mutated." + EXACT CHAIN (identical in both sessions): + PlayerMovementController.EnsureConfigurationMutable + (src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:859) + <- SetCharacterSkills(:1248) + <- RuntimeMovementSkillProjection.ApplyTo( + src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs:21) + <- LiveSessionRuntimeFactory.ApplyMovementStats( + src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:327; recompute + callback registered at :300 in CreateCharacterBindings) + <- LiveSessionEventRouter.RecomputePlayerQualities( + src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:406) + <- ClientObjectTable.Ingest(WeenieData) + <- ObjectTableWiring.ApplyEntitySpawn + <- RuntimeEntityObjectLifetime.ApplyAcceptedSpawn(:926) + <- LiveEntityHydrationController.OnCreateCore(:298) — an ordinary + inbound Create datagram, processed AFTER the graceful-logout + confirmation (stdout timeline: probe -> logout confirmed -> + post-logout stat-chain recomputes -> crash). + DIAGNOSIS (report-only, no fix attempted): the flip made the local + movement controller Runtime-published with a SEALED configuration + lifecycle (mutable only pre-publication; retired at teardown). The + character-bindings quality-recompute subscription + (LiveSessionRuntimeFactory.CreateCharacterBindings -> + ApplyMovementStats -> controller.SetCharacterSkills) still mutates the + controller directly on EVERY player-quality recompute; once the + controller is sealed (published) or retired (logout teardown), that + mutation throws. This is exactly the review-focus item "Sealed + Controller setter: audit every compile break's fix — each must route + through the publication lifecycle" — this site was missed because it is + a RUNTIME mutation (EnsureConfigurationMutable), not a compile break. + A fix must decide where server skill updates route post-flip (e.g. the + Runtime movement-state seam / construction options at first entry + + a Runtime-owned live-skill channel), which is a production design + decision outside this session's mechanical remit — per the pinned gate + order the session STOPS at this failure and reports. + Secondary observation from the same logs (likely the same defect class, + recorded for the fixer): after the unhandled exception the shutdown path + reported "status=AbandonedIncomplete, blocked=native window" with + Silk.NET "You cannot call `Reset` inside of the render loop!" — a + crash-path artifact, not an independent bug. + The nine-stop soak (tools/run-connected-r6-soak.ps1) was NOT run (gated + behind the lifecycle gate's pass). +- Nothing staged, no commits at any point; HEAD remains 78f1eb18; the 7 + protected dirty paths + AGENTS.md carry only their inherited state + (AGENTS.md the sole real pre-existing content diff; PlayerModeController + touched only by the inherited sanctioned flip surgery — this session + changed only the marker TEST for it, not the file). + +## C3c-F1 — movement-stat application through the Runtime seam +### Candidate-window investigation (resolved with evidence) +- The crash state was RuntimeOwnedDormant, not retired: the failing gate run's + stdout shows the login world-reveal never completed (collision=False at every + readiness line; event=cancel at logout with completed=False) — first-entry + activation stayed DeferredCell for the whole session, so + RuntimeLocalPlayerMovementState held the dormant controller when the + post-logout ingest recompute fired. EnsureConfigurationMutable throws for + dormant (PlayerMovementController.cs:848-861: only Standalone/ + CandidatePreparing/RuntimePublished/dormant+groundPhase return). +- CandidateSealed is UNREACHABLE through the seam: the sealed candidate is + never installed into the movement owner (CanPrepare requires + `_movement.Controller is null`, RuntimeLocalPlayerPhysicsPublicationState.cs:908; + IsCurrent requires CanCommitRuntimeOwnedController(epoch, null), :937-939), + and Prepare→Commit runs back-to-back inside one synchronous Advance step + (RuntimeLocalPlayerFirstEntryState.cs:395-431) with no callback dispatch + between (Prepare's construction is callback-free by design, publication + state :319-334). +- RuntimeOwnedDormant IS reachable across pump iterations: Commit installs + the dormant controller (publication state :436) and Evaluate/CommitActivation + DeferredCell yields AwaitingActivation with stage kept + (RuntimeLocalPlayerFirstEntryState.cs:493-497) — inbound quality events pump + between Advance calls. DECISION: apply-immediately-to-dormant (not + defer-at-commit) — the dormant instance IS the controller that goes live + (ActivateRuntimePublication at RuntimeSetPositionState.cs:2464 flips the + same object), the writes touch only PlayerWeenie fields + the mover-flag + latch (nothing the activation envelope validates), and the precedent is the + existing dormant channels RefreshDormantRuntimePhysicsState/Vector + (PlayerMovementController.cs:735-759) that land accepted server facts on + the dormant owner mid-window. Deferring would need an activation hook and + would leave the weenie stale for the activation ground-phase dispatch. +### Implementation +- PlayerMovementController: internal ApplyCharacterMovementStats(in snapshot) + — lifecycle switch (live/dormant apply, terminal typed drop) + private + ApplyCharacterMovementStatsCore (verbatim body of the deleted + RuntimeMovementSkillProjection.ApplyTo, field writes not gated setters) + + internal ReportExhaustionAtMovementBoundary (live-only exhaustion dispatch). +- RuntimeLocalPlayerMovementState: public ApplyCharacterMovementStats( + RuntimeMovementSkillState) + public ReportExhaustion() + public enum + RuntimeMovementStatsApplication {AppliedLive, AppliedDormant, + DroppedNoController, DroppedIncompleteSnapshot, DroppedDisplacedController}. + Disposed-owner tolerant (typed drop, not ObjectDisposedException) per J3.6. +- DELETED src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs + (zero remaining consumers). +- NEW src/AcDream.App/Net/LiveMovementStatsApplier.cs — owns the + StaminaExhaustionEdgeTracker + logging; observes the exhaustion edge for + both applied outcomes, dispatches only AppliedLive (dormant: no in-flight + movement; activation reads the current stamina gate). +- LiveSessionRuntimeFactory: constructs the applier; OnSkillsUpdated/ + OnMovementStatsUpdated route through it; ApplyMovementStats + + _staminaExhaustion deleted; ResetPlayerPresentation resets the applier. + App keeps zero direct configuration mutations on the stats path. +### Tests (all green at write time) +- Runtime (7 new in RuntimeLocalPlayerMovementStateTests): live byte-identity + vs the old direct path (InqRunRate/InqJumpVelocity/CanJump/JumpStaminaCost/ + OwnPvpFlags), dormant-window write lands on the instance that goes live, + terminal typed drop with unchanged observables + setter-still-throws, + sealed-candidate defensive row, absent/incomplete drops, disposed-owner + tolerance, ReportExhaustion lifecycle gating. 19/19 file total. +- App (3 new in LiveMovementStatsApplierTests): the REAL crash chain + (real WorldSession + LiveSessionEventRouter + ClientObjectTable.Ingest of + the player row + real applier callback) against a retired-installed + controller — no throw, typed displaced drop logged; dormant-window ingest + applies + values current at activation; absent/incomplete silent skips. +### Connected gate run 1 (post stats-fix): FAIL — second site of the same class +- logs/connected-world-gate-20260802-125907: BOTH sessions confirmed graceful + logout, then crashed on the SAME EnsureConfigurationMutable throw via the + OTHER App-side mutation my sweep had already classified residual-unsafe: + LiveEntityNetworkUpdateController.OnState:1006 → + PlayerMovementController.ApplyPhysicsState(:366) at the dormant controller + (post-logout inbound SetState; the login reveal never completes in this + gate profile so the first-entry controller stays dormant all session — + same as the original 122749 failure). The stats seam itself WORKED: the + stdout shows repeated "player: applied server movement stats run=10205..." + dormant applications with no ingest crash. +- Disposition per the sweep clause ("route each through the seam or report + why it is already lifecycle-safe" — this site cannot be reported safe): + routed through the same owner seam pattern. NOT a guard at the throw site: + the lifecycle decision moved into the owner. +### Second routing (inbound local-player SetState) +- PlayerMovementController.ApplyServerPhysicsState (internal, typed): + live → exact ApplyPhysicsState body; dormant → + DroppedDormantActivationOwned (the activation transaction re-reads the + canonical FinalPhysicsState itself via RefreshDormantRuntimePhysicsState + at both activation phases, and while the accepted SetState is queued + behind the initial residence the App push carries that same UNCHANGED + record value — RuntimeEntityObjectLifetime.TryApplyState:1351-1378 queues + without advancing the record — so the drop is value-preserving by + construction); terminal → DroppedDisplacedController. New enum + RuntimeServerPhysicsStateApplication beside the stats enum. +- LiveEntityNetworkUpdateController.cs:1005-1018 routes through the typed + entry (result discarded; comment records both gate crash chains). +- Tests: Runtime ApplyServerPhysicsState_DormantDropsForActivationAndLiveAppliesExactly + (990/990 Runtime); App source pin C3cF1ProductionWiringTests. + LocalPlayerInboundSetState_RoutesThroughTheTypedOwnerEntry (no direct + ApplyPhysicsState caller remains in the file). App 4,036/3 skips of 4,039; + Headless 78/78. +### Connected gate run 2 (130455) — FAIL, root cause classified INHERITED; STOPPED per directive +- Both sessions ran CRASH-FREE (0 unhandled exceptions; capped alive 7+ min + until my graceful WM_CLOSE, uncapped alive the full 420 s timeout) — the + F1 crash-class fixes hold live. 418 login entities + 10,384 total ingested + cleanly with 158 dormant stat applications. +- Harness failures: capped "client exited waiting for probe complete" (my + directed close), uncapped "timed out after 420 s waiting for probe + complete". +- Wedge mechanism (link-by-link): + 1. First-entry conductor Prepares+Commits the DORMANT controller within + the first frames (stats lines from stdout line 62). + 2. Activation stays DeferredCell; even after streaming collision readiness + completes (~40 s, reveal line 223 collision=True ready=True) the + activation/publication never commits — PlayerModeController logs ONE + "Runtime first-entry controller ... not committed yet" (line 224, + PlayerModeController.cs:247-250) and player mode never enters. + 3. Reveal (kind=Login) completes on readiness alone with + materialized=False (RuntimeWorldTransitState.Complete:685-720 requires + Materialized only for Portal kind), then per-frame readiness + re-acknowledgements spam event=rejected reason=readiness-after-terminal + (9,000+ lines). + 4. AcknowledgeWorldViewportVisible never fires (visible=False forever) → + probe line 5 "timed out waiting for normal world viewport" + (RetailUiAutomationScriptRunner.cs:308-313; route line 4-5 + tools/connected-world-lifecycle.route.txt) → probe never prints + complete → harness fails. +- NOT an F1 regression — three proofs: + 1. 122749 (zero F1 changes) fails with the IDENTICAL harness failure + string ("client exited waiting for probe complete", report.json), has + NO probe-complete/checkpoint/player-mode lines, and its own crash + (SetCharacterSkills throwing) proves the controller was never published + there either. + 2. 122749's login was WORSE pre-F1: the per-Create ingest-recompute throw + killed the process 26 s in (StartedUtc→FinishedUtc) with only 1 entity + seen vs 418/10,384 under F1 — the coordinator's "122749 entered world + normally" premise is contradicted by its own artifacts. + 3. The F1 write set cannot affect activation currency: the stats core + touches only PlayerWeenie fields + the OwnPvpFlags latch; the + activation envelope checks lifecycle/epochs/body identity only + (RuntimeLocalPlayerPhysicsPublicationState.cs:961-992, :747-775); the + DeferredCell decision is RuntimeSetPositionState cell-residency + machinery untouched by this slice. +- Residual root cause home: the C3c first-entry activation never resolves + its deferred destination cell in the graphical host (or its pending drive + entry stops being re-armed) — RuntimeLocalPlayerFirstEntryState.cs:433-501 + (PublicationCommitted → EvaluateActivation/CommitActivation DeferredCell + loop) against RuntimeSetPositionState's dormant-activation cell gate. + That is conductor/publication semantics OUTSIDE the F1 contract → + STOPPED, no fix attempted, per coordinator directive 3. +- Client terminated via graceful-close discipline (WM_CLOSE → logout + confirmed by ACE, clean [session] lines, no crash); harness concluded and + recorded FAIL; artifacts preserved at + logs/connected-world-gate-20260802-130455/. + +## C3c-F2 — the login DeferredCell activation wedge + +### Step 1 — artifact re-verification (the pinned contract's evidence chain is +### WRONG on run 122749; both prior classifications were partly unsound) +Read directly from the primary artifacts, not from either prior report: +- 122749 (flip WITHOUT F1), capped/stdout.log is 67 lines TOTAL. It shows + `[UI-PROBE] running 54 UI probe command(s)` (L52) — that line is the probe + ANNOUNCING its command count, not 54 commands completing. The reveal shows + ONLY `event=begin` (collision=False) and ONE `event=readiness` + (collision=False, ready=False, materialized=False, visible=False), then + `event=cancel ... visible=False`. The smoke plugin saw 1 entity total. + report.json StartedUtc -> FinishedUtc = 26 SECONDS for BOTH sessions. + => the contract's "world became visible / probe's `wait world-visible` + passed / 54-command probe ran" is falsified. The world was NEVER visible in + 122749; the process died ~9 s after entering world from the F1 crash, i.e. + BEFORE the ~40 s collision-readiness edge where the wedge manifests. +- Consequence: 122749 proves NOTHING either way about post-readiness + activation (it never got there). The F1 agent's "identical harness failure + string" proof is equally weak (identical string, different causes: crash vs + timeout). Both prior attributions are unsound; attribution has to come from + CODE, not from these two logs. +- 130455 (flip WITH F1) is the only run that reaches the readiness edge: + L223 readiness collision=True ready=True; L224 `not committed yet` (ONE + line); L225 `event=complete ... materialized=False`; then 5,577+ + `readiness-after-terminal`; probe L1193 `wait world-visible 30000` timeout. + +### Step 2 — the contract's primary suspect is STRUCTURALLY disproven +F1's sweep change (LiveEntityNetworkUpdateController.cs:1005-1018) passes +`record.FinalPhysicsState` into the typed owner entry. The activation cell +gate reads that SAME canonical value directly off the record — +RuntimeSetPositionState.cs:1747-1752 builds `canonicalRequest` with +`MoverPhysicsState = record.FinalPhysicsState`. The activation path never +reads the controller's copy. A drop on the CONTROLLER therefore cannot +deprive the activation of anything, "value-preserving" or not. The primary +suspect cannot produce a DeferredCell wedge. Contract constraint 3's +"retained-to-activation admission" is not the fix; the real defect is below +and is entirely inside C3c-flip code that F1 never touched. + +### Step 3 — VERIFIED MECHANISM, link by link +L1. Login: the first-entry conductor prepares the authored mover and + Prepare+Commits the publication in one synchronous step + (RuntimeLocalPlayerFirstEntryState.cs:395-431). The controller is now + RuntimeOwnedDormant (RuntimeLocalPlayerPhysicsPublicationState.cs:433-436) + — confirmed live by the 158 `player: applied server movement stats` + dormant lines in 130455. +L2. Stage PublicationCommitted -> EvaluateActivation -> engine SetPosition + (RuntimeSetPositionState.cs:1770). The destination landblock's collision + is still being published (the reveal reports collision=False for ~40 s), + so the result is DEFERRED. +L3. CommitActivation -> TryApplyDormantLocalActivationCommit PARKS the + operation (RuntimeSetPositionState.cs:2066-2107): Stage=AwaitingCell, + WakeableLostCell=true, CollisionGenerationReady=false, and + `CollisionGeneration = prepared.DeferredCollisionGeneration`, computed at + :1939-1940 as `_physics.ExpectedCollisionGeneration(cellId)` = the + IN-FLIGHT admission's generation G (RuntimePhysicsState.cs:2934-2939). + Bucket key = (cell, prefix, G). +L4. ~40 s later the landblock's collision generation commits. + RuntimePhysicsState.cs:2503 calls + `SetPosition.CommitCollisionGeneration(lb, G, ready:true)`, which finds + bucket (cell, prefix, G), verifies `IsSpawnCellReady`, and sets + `operation.CollisionGenerationReady = true` + (RuntimeSetPositionState.cs:3886). The push-side `RetryDeferred` is + deliberately a no-op for this operation — it returns immediately when + `operation.DormantLocalActivation` is set (:4044-4045) by design: the + local-player lease must re-enter through the sealed evaluation/commit + path. So the ONLY door left is the PULL-side rearm on the conductor's + next Advance. +L5. Immediately after :2503, `AdvanceCommittedActivation` REMOVES the + admission from `_collisionAdmissions` (RuntimePhysicsState.cs:2552-2558) + while leaving `_collisionGenerations[lb] == G` (set at + BeginCollisionAdmission, :2066). From this instant on, + `ExpectedCollisionGeneration(cellId)` no longer returns G — with no + admission it returns `_collisionGenerations[lb] + 1` = G+1 + (RuntimePhysicsState.cs:2940-2944). +L6. THE DEFECT. The next Advance reaches + `TryRearmDeferredDormantLocalActivation` + (RuntimeSetPositionState.cs:1851-1882), whose gate includes + `operation.CollisionGeneration != _physics.ExpectedCollisionGeneration( + operation.ExactCellId)` (:1870-1871) -> `G != G+1` -> rearm refused, + permanently. `ExpectedCollisionGeneration` means "the generation that + will make me ready" at PARK time and "the next, not-yet-begun generation" + at WAKE time; the rearm compares against the wrong one. Nothing else ever + clears WakeableLostCell, so the operation is wedged for the session. +L7. EvaluateActivation's fallback then reports DeferredCell forever + (publication state :469-476 -> IsDormantLocalActivationAwaitingCell true), + the conductor yields AwaitingActivation and keeps its pending entry + (first-entry state :493-497), the controller never activates, and + `IsRuntimePublished` stays false -> PlayerModeController.cs:243-251 logs + "not committed yet". +L8. SECOND LINK (flip-introduced, independent). `PlayerModeAutoEntry.TryEnter` + sets `_armed = false` BEFORE invoking EnterPlayerMode + (PlayerModeAutoEntry.cs:227-228) — a one-shot. Its + `IsPlayerControllerReady` precondition in the PRODUCTION context is the + constant `true` (PlayerModeAutoEntry.cs:86). That was harmless pre-flip + because TryEnter CONSTRUCTED the controller and could not fail on it + (deleted block, `git diff src/AcDream.App/Input/PlayerModeController.cs`); + post-flip TryEnter returns false when the conductor has not committed. So + the single shot is burned on the exact frame readiness flips, and + `LivePlayerModeAutoEntryContext.EnterPlayerMode` calls + `_worldReveal.Complete()` UNCONDITIONALLY (PlayerModeAutoEntry.cs:97-101) + — sealing the reveal (materialized=False) and producing the 5,577 + readiness-after-terminal rejections. The flip's own comment ("auto-entry + retries on a later frame", PlayerModeController.cs:242) is false as + written. Ordering note: UpdateFrameOrchestrator.cs:201-207 runs streaming + -> live frame (DriveAll) -> auto-entry, so with L6 fixed the same frame + would usually succeed — but "usually" is a race, and the log proves a + failed attempt burns the shot and seals the reveal. + +### Why the existing rearm test never caught L6 +RuntimeLocalPlayerPhysicsPublicationStateTests +.DeferredCommitWaitsThenRearmsSameLeaseAfterExactGenerationWake (:337-383) +wakes the operation by calling `SetPosition.BeginCollisionGeneration` / +`CommitCollisionGeneration` DIRECTLY, bypassing RuntimePhysicsState's +admission ledger. `_collisionAdmissions` and `_collisionGenerations` therefore +stay EMPTY, so `ExpectedCollisionGeneration` returns 1 at both park and wake +and the identity check accidentally holds. Production always goes through +BeginCollisionAdmission -> CommitCollisionGeneration, which is exactly the +path that breaks it. + +### The fix (2 hunks) +F2-1 (root cause, Runtime): the rearm's generation identity check compares +against the LIVE collision generation authority +(`_physics.CollisionGenerationAuthority`, RuntimePhysicsState.cs:2953-2962 = +`_collisionGenerations[lb]`), not `ExpectedCollisionGeneration`. Post-commit +that is exactly G, so the parked lease rearms. Every stale case still +refuses: a superseding admission or a cancel bumps `_collisionGenerations` +away from G. Park-side semantics (:1939) untouched — "park against the +generation that will make me ready" is the established convention shared with +the remote ParkDeferred path (:3974). No latch is loosened: WakeableLostCell, +CollisionGenerationReady and IsSpawnCellReady remain mandatory. +F2-2 (second link, App): `LivePlayerModeAutoEntryContext +.IsPlayerControllerReady` stops lying — it reports the exact precondition +PlayerModeController.TryEnter enforces (Runtime-published controller + +committed EntityPhysicsHost). The one-shot latch, the reveal latch, and the +readiness-after-terminal rejection are all UNCHANGED; the trigger simply +cannot burn its shot before the conductor has committed, so +`_worldReveal.Complete()` runs only on a real entry. PlayerModeController.cs +is NOT touched by this fix. + +### Step 4 — LIVE PROBE CORRECTION (temporary attributed probe, since stripped) +The Step-3 analysis was right about the parts but wrong about which link fires +first. A temporary change-only probe on the rearm gate terms and the drive +controller's local status (env-gated, stripped before the gate) was run against +ACE. Evidence, run `logs/c3c-f2-probe.out.log` (F2-1 only, no admissibility +term): +``` +L61 [c3cf2-rearm] ... ready=False ... gen=1 auth=0 expected=1 spawnReady=True +L220 [c3cf2-rearm] ... ready=False ... gen=1 auth=1 expected=1 spawnReady=True +L221 [c3cf2-rearm] ... ready=True ... gen=1 auth=1 expected=1 spawnReady=True +L222 [c3cf2-drive] local status=RejectedAuthority step=0 pending=59 +``` +- L61: the lease parks at generation 1 with NO admission and NO committed + generation yet (auth=0), i.e. `ExpectedCollisionGeneration`'s 1UL default. +- L221: the collision-generation commit marks it ready — and `expected` is + STILL 1, proving the admission is still registered at that instant: the + commit reenters the host's first-entry pump between + RuntimePhysicsState.cs:2503 and the retirement at :2552-2558. +- L222: the rearm succeeds inside that window, the immediately following + evaluation fails `TrySealCollisionEvaluationAuthority` on the still- + registered admission, and because the lease is no longer AwaitingCell, + EvaluateActivation answers RejectedAuthority — TERMINAL. The conductor + discards and the drive entry is dropped for the session. +=> LINK 3 (the reentrant-window rearm) is the DOMINANT live blocker, and it +fires BEFORE the L6 generation mismatch can. L6 is still real and still +load-bearing — see the next run. + +Second live run with both terms (`logs/c3c-f2-probe2.out.log`): +``` +L223 [c3cf2-rearm] ... ready=True gen=1 auth=1 expected=1 -> refused (window) +L224 [c3cf2-rearm] ... ready=True gen=1 auth=1 expected=2 -> REARMED +L225 [c3cf2-drive] local status=Completed step=0 pending=2 +L230 live: auto-entered player mode for 0x5000000A +L231 [world-reveal] event=complete ... +L234 [world-reveal] event=world-visible ... visible=True +``` +L224 is the direct proof that F2-1 is load-bearing: at the frame the rearm +actually happens the admission is gone, so `expected` is 2 and only the +committed authority still equals the parked generation 1. Zero +readiness-after-terminal lines in the whole run. + +### Final fix (3 hunks, all required) +F2-1 RuntimeSetPositionState.TryRearmDeferredDormantLocalActivation — compare +the parked generation against `CollisionGenerationAuthority` (the generation +the collision world HOLDS) instead of `ExpectedCollisionGeneration` (which +means "the next, not-yet-begun generation" once the admission retires). +F2-3 same method — refuse the rearm while the destination prefix is not +evaluable, using the seal's own predicate, now factored as +`RuntimePhysicsState.IsCollisionEvaluationPrefixAdmissible` and consumed by +BOTH the seal and the rearm so they cannot drift. This is the same shape the +remote wake path already had via `TryGetBlockingQuiescence` (:4069-4095). +F2-2 LivePlayerModeAutoEntryContext.IsPlayerControllerReady — report the +Runtime first-entry commit instead of the constant `true`, so the one-shot +guard cannot burn its single attempt (and unconditionally complete the world +reveal) before the conductor has published the controller. + +### Tests +- NEW RuntimeLocalPlayerPhysicsPublicationStateTests + .DeferredCommitRearmsAfterProductionAdmissionCommitsItsGeneration — pins + F2-1; fails pre-fix. +- NEW RuntimeLocalPlayerPhysicsPublicationStateTests + .DeferredCommitStaysParkedWhileTheCommittingAdmissionIsStillRegistered — + pins F2-3; pre-fix it fails with the exact live symptom + (`Expected: DeferredCell / Actual: RejectedAuthority`). +- NEW tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests + .ProductionAutoEntryRequiresTheRuntimePublishedController — pins F2-2 + (source pin; the production context's ~15-dependency graph has no focused + harness, matching the C3c-F1 precedent). +- CONVERTED to the production wake path (assertions verbatim; only the wake + DRIVER changed, from the raw SetPosition seam to the collision admission + ledger, because the raw seam leaves the ledger empty — a state production + can never reach, and precisely why these tests missed the wedge): + RuntimeLocalPlayerPhysicsPublicationStateTests + .DeferredCommitWaitsThenRearmsSameLeaseAfterExactGenerationWake, + .DeferredAuthoredActivationSuspendsRowsAndExactWakeRestoresThem, + RuntimeLocalPlayerFirstEntryStateTests + .AwaitingActivationRetriesWhileCellUnresolvedThenResumesAfterGenerationWake, + .DeleteAndSameGuidReincarnationAutomaticallyFreesThePublicationSlotForTheFreshIncarnation. + All four fail pre-fix once converted. + +### Gates +Runtime 992/992; App 4,037/0/3 skips; Headless 78/78; Release solution build +0 errors / 18 pre-existing test-project warnings; complete solution (-m:1, +ACDREAM_PAK_PATH) 10,797 passed / 0 failed / 4 skipped of 10,801; +`git diff --check` clean. + +CONNECTED GATE: logs/connected-world-gate-20260802-135444 — RESULT=FAIL, but +NOT on the wedge, which is gone in both sessions: +- capped: login reveal reached world-visible, the probe captured checkpoint + `capped_login` AND screenshot `capped_login.png`, then teleported + (`old lb=(169,180) new lb=(9,4)`), and generation 2 (kind=Portal) reached + materialized=True, visible=True, completed=True. 17,043 entities ingested. +- uncapped-reconnect: login at cell 0x09040008 reached visible=True, + completed=True. +- Zero `readiness-after-terminal` lines, zero "not committed yet", zero + F1-class controller-mutation crashes in either session. + +### BLOCKER (pre-existing, out of C3c-F2 scope) — map-corner landblock +Both sessions then died identically: +`System.ArgumentOutOfRangeException (Parameter 'landblockId')` at +RuntimeSetPositionState.BeginCollisionPrefixQuiescence +(src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs:783) +<- RuntimePhysicsState.BeginCollisionPrefixQuiescence(:1993) +<- RuntimePhysicsState.CommitCollisionGeneration(:2432) +<- LandblockPhysicsPublisher.AdvanceCompleteOne + (src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:626) +<- LandblockPresentationPipeline.Advance <- StreamingController.Tick. +Mechanism: the teleport destination is landblock (9,4). The far streaming +radius is 12 and StreamingRegion only SKIPS out-of-range indices +(`nx < 0 || nx > 0xFF`, StreamingRegion.cs:80/117/155/225) — it does not skip +(0,0) — so the window includes landblock id `(0<<24)|(0<<16)|0xFFFF` = +0x0000FFFF. `CanonicalLandblock` keeps that as 0x0000FFFF, and +BeginCollisionPrefixQuiescence computes `prefix = landblockId & 0xFFFF0000` += 0x00000000 and throws its `prefix == 0u` guard (:781-783). Dereth's +south-west corner landblock therefore cannot be collision-published, and any +position within the far radius of it crashes the client. +This code is untouched by the C3c flip, by C3c-F1, and by C3c-F2 (`git diff +src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` contains no change to +BeginCollisionPrefixQuiescence); it was simply unreachable while login itself +was wedged — 130455 never left Holtburg and never teleported. +NOT fixed here, and deliberately not a one-line guard removal: prefix 0 is +also overloaded as an "absent prefix" sentinel in the same class — e.g. +ParkDeferred derives `operation.CollisionQuiescenceHeld = collisionPrefixOverride +!= 0u` (:3975-3978), so a genuine landblock-(0,0) quiescence override would be +read as "no override". A correct fix needs an explicit has-prefix flag (or a +nullable prefix), which is a physics-ownership design change outside this +contract. Recommend a dedicated slice. + +## C3c-F3 — corner-landblock prefix-0 sentinel conversion + +### Chain audit (complete, every prefix-sentinel site classified) +CONVERTED (the has-prefix representation is nullable uint for the override +pair + OperationId-based token presence + explicit landblockId==0 absent-id +input guards): +1. RuntimeSetPositionState.cs RuntimeCollisionPrefixQuiescenceToken.IsValid + (:112) — dropped `LandblockPrefix != 0u`; presence now discriminated by + OperationId != 0 (monotonic from 1) + CollisionGeneration != 0 (from 1). + This was load-bearing beyond the crash site: a real prefix-0 token read + as invalid wedged TryGetCurrentQuiescence (:839/:887/:905 callers), + permission currency (:886), and TryGetBlockingQuiescence's `excluded` + term (:3617 — RetryDeferred's restoringQuiescence would have blocked + itself). +2. RuntimeSetPositionState.BeginCollisionPrefixQuiescence (:782) — the + crash-site `prefix == 0u` throw replaced by `landblockId == 0u` (absent + id), prefix computed unconditionally. +3. RuntimeSetPositionState.ParkDeferred (:3977-78, :4013-19) — override + params converted `ulong collisionGenerationOverride = 0UL, uint + collisionPrefixOverride = 0u` -> `ulong?/uint? = null`; + CollisionQuiescenceHeld = collisionPrefixOverride.HasValue. Both + quiescence-token call sites (:2842, :2893) pass values unchanged + (implicit lift); byte-identical for nonzero prefixes. +4. RuntimeSetPositionState.ParkCollisionResidents (:3414-17) — `?? 0UL` / + `?? 0u` collapse removed; `quiescence?.Token.X` now flows null/value. +5. RuntimePhysicsState.BeginCollisionPrefixQuiescence (:1991) — dead + `canonical == 0u` (CanonicalLandblock ORs 0xFFFF, never 0) replaced by a + LIVE `landblockId == 0u` guard. +6. RuntimePhysicsState.AdvanceCollisionRetirementMutation (:2626) — same + dead-guard replacement. +7. RuntimePhysicsState.BeginCollisionAdmission (:2037) — NEW landblockId==0 + guard at the admission entrance: an absent id canonicalizes to + 0x0000FFFF (the REAL corner landblock), and the old accidental + commit-time protection (the prefix throw) is gone. +8. ShadowObjectRegistry.DeriveOutdoorSeed (:651, Core) — `lbPrefix == 0u -> + no seed` replaced by `landblockId == 0u`; corner-block baked statics now + derive real seeds 0x0000000N (previously silently dropped from the + shadow world). Same sentinel class, exercised by the corner collision + publication chain. + +KEPT with justification (no collision with prefix 0): +- Generation-0 "unbound" sentinel (RuntimeSetPositionState :3727/:3741-45/ + :3811/:3842/:5135/:5180/:5255): generations allocate from 1 + (checked(current+1), Begin throws on 0) — 0 is unreachable as a real + generation. +- ExactCellId==0 "absent cell" (IndexDeferred/IndexUnboundDeferred/ + UnindexDeferred): cell-part 0x0000 is not a valid cell; corner cells are + 0x00000001+. +- RuntimePhysicsState dead post-canonicalization zero checks + (ExpectedCollisionGeneration :2932, IsCollisionEvaluationPrefixAdmissible + :2963, CollisionGenerationAuthority :2977, TrySealCollisionEvaluationAuthority + Add :3030): CanonicalLandblock never returns 0; harmless dead defensive + terms, prefix-agnostic. (Noted follow-up: CanonicalLandblock(0) aliases + cellId 0 -> 0x0000FFFF in these helpers; unreachable with real + operation cells today, flagged rather than redesigned.) +- Core PhysicsEngine bare-id compat (:1355/:1362 SampleTerrainWalkableInCell, + :2093 HasCellSurface): `requestedPrefix == 0` there means "caller passed a + bare pre-#106 test-fixture id", a different semantic; corner cells resolve + correctly through the world-position filter. Follow-up cleanup candidate, + out of this contract's chain. +- Convention pinned by test: raw input 0x00000000 remains "absent" + everywhere; landblock (0,0) is addressed by canonical 0x0000FFFF (or any + cell inside it) — matching what production streaming always passes. + +### Tests (7 new, all fail pre-fix / pass post-fix; pre-fix run of the +### production-chain test reproduced the EXACT 135444 crash signature: +### ArgumentOutOfRangeException 'landblockId' at RuntimeSetPositionState.cs:783 +### <- RuntimePhysicsState.cs:1993 <- :2432) +New partial tests/AcDream.Runtime.Tests/Physics/ +RuntimeCollisionPrefixQuiescenceTests.CornerLandblock.cs: +- CornerLandblockCollisionGenerationCommitsThroughTheProductionAdmissionChain + (corner 0x0000FFFF + neighbor 0x0001FFFF, empty engine, full + admission->prepare->stage->seal->commit, ownership converged) +- CornerResidentParksAndRestoresAcrossAnActivationReplacement (SetPosition + commit into corner cell; activation replacement parks the resident under + the prefix-0 quiescence override, wakes, restores) +- CornerPrefixQuiescenceHoldsAndReleasesExactlyLikeANonzeroPrefix (contract + test 2: identical held-placement script vs PrefixP, step-for-step log + parity incl. QuiescenceHeld hold/acquire/cancel/restore) +- CornerLandblockDemotesAndWithdrawsThroughRetirementMutations +- AbsentLandblockIdStillCannotBeginQuiescence (id-0 keeps throwing at + quiescence AND at the new admission-entrance guard; corner token IsValid) +tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs: +- Register_CornerLandblock_DerivesRealOutdoorSeed +- Register_AbsentLandblockId_StillKeepsWhenEmpty (passed pre-fix too — + pins the preserved keep-when-empty guard) +Two in-flight test corrections during TDD: seeded corner Place asserts +CommittedHostAcknowledgementPending (the ordinary bound-events commit +status — over-strict first draft); parity run addresses the corner by +canonical 0x0000FFFF, not raw 0 (which is the absent sentinel by design). + +### Gate ladder +1. Focused corner tests: 5/5 Runtime + 39/39 ShadowObjectRegistry suite. + Complete projects: Runtime 997/997 (992+5), App 4,037/3 skips of 4,040, + Headless 78/78. +2. Release solution build: 0 errors. Complete solution (-m:1, PAK): running. +2 (cont). Complete solution (-m:1, ACDREAM_PAK_PATH): 10,804 passed / 0 + failed / 4 skipped (App 4,037/3, Bake 15, Cli 4, Content 124, Core.Net + 762, Core 4,244/1, Headless 78, Runtime 997, UI 543). +3. Connected gate: run logs/connected-world-gate-20260802-142539 launched + against live ACE (UDP 9000 confirmed listening, no stale client); result + pending. +4. git diff --check: clean (line-ending metadata warnings only, matching the + known worktree pattern). Nothing staged. +3 (result). Connected gate PASS: logs/connected-world-gate-20260802-142539/ + report.json Passed=true, Failures=[], both sessions exit 0, one warning + ("capped: 25 expected world-edge landblock miss(es)" — the expected + class). The 135444 corner crash is gone: the capped session completed the + full route (capped_login, facility_hub, aerlinthe_first, rynthid, + holtburg_after_dungeon, aerlinthe_revisit checkpoints + screenshots + under capped/artifacts/), uncapped-reconnect completed + uncapped_reconnect. Coordinator confirmed and directed the soak. +5. R6 soak launched (run-connected-r6-soak.ps1, same ACE, detached PID + 14036); result pending. +5 (result). R6 soak logs/connected-r6-soak-20260802-143157: RESULT=FAIL per + the PRIMARY artifact (report.json Passed=false, 37 failures) — the + coordinator's "concluded successfully" summary was based on the markers + log (route-complete + graceful close are true) but the pass criterion is + report.json. Failure class: streamingWork convergence + (deferredCompletions/deferredAdoptedCpuBytes/pendingPublications=1/ + farBacklog nonzero) at 8 of 9 canonical checkpoints (aerlinthe clean), + plus Caul plateau loadedLandblocks/totalLandblocks 283->189 and mesh + cache growth 547->588 / +40.5 MB. No crash, no exception, graceful + logout confirmed; 9/9 checkpoints + screenshots captured. + ATTRIBUTION ANALYSIS (verified, not guessed): + - Last passing soak (logs/connected-r6-soak-20260727-004942.report.json, + Passed=true, 0 failures) enforced the SAME streamingWork expect-zero + criterion and met it — but predates the ENTIRE uncommitted C3c + flip+F1+F2+F3 stack, so it separates {branch} from {baseline}, not F3 + from the flip. + - F3's blast radius was provably NOT exercised in the failing run: + grep 0x0000FFFF over the soak out.log = 0 hits (the corner landblock + never entered any route window), zero exceptions (the new absent-id + guards never fired), and every F3 conversion is byte-identical for + nonzero prefixes (pinned by the parity test). The failing criterion is + streaming/publication convergence — a C3c-flip/F1/F2-era surface. + - The lifecycle gate 142539 (WITH F3) passed cleanly the same day. + STOPPED per contract — no retry, no further code work, nothing staged. + +## C3c-F4 — soak streaming-convergence regression: DIAGNOSED, STOPPED (no fix landed) + +**Verdict: NOT a wedge and NOT in the C3c flip/F1/F2/F3 diff.** It is a +throughput regression in the committed collision-generation atomic-replacement +mechanism. + +### Named mechanism (link by link) +1. `StreamingController.DrainAndApply` (src/AcDream.App/Streaming/StreamingController.cs:1662-1690) + advances the completion-queue head and `break`s when it does not complete — + at most ONE landblock publication per frame. +2. `LandblockPresentationPipeline.Advance` stage `publication-index-physics` + (src/AcDream.App/Streaming/LandblockPresentationPipeline.cs:612-627) charges + `EntityOperations: PreparationCursor < Entities.Count ? 1 : 0`. A FAR build is + `Array.Empty()` (PublishAsFar, :419-447), so every step is FREE + and only the 2 ms elapsed-time ceiling bounds it. +3. `LandblockPhysicsPublisher.AdvancePreparationOne` + (src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:297-309) gates on + `RuntimePhysicsState.AdvanceCollisionGenerationPreparation`. +4. That calls `PreparedLandblockCollisionGeneration.AdvanceStagingClone` + (src/AcDream.Runtime/Physics/RuntimePhysicsState.cs:531-560) → + `PhysicsEngine.CollisionStagingBuilder.Advance` + (src/AcDream.Core/Physics/PhysicsEngine.cs:842-925): ONE leaf per step of an + off-side draft of the COMPLETE collision world minus the target prefix + (landblock slots, CellStruct, FlatCellStruct, FlatEnvCell, Buildings, + EnvCells, Terrain, OutdoorCells, shadow-owner slots). +5. `PhysicsEngine.CommitLandblockReplacement` (:304-321) does + `stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld)` — the + atomic unit is the WHOLE world, which is why the whole world must be cloned. + +### Measured (attributed probes, lifecycle-gate route, since stripped) +- median 19,736 / p90 32,135 / max 38,021 clone leaves per landblock + publication; median 3.64 ms CPU each; 1,584 preparations = 8.53 s CPU in one + 4-minute capped session. +- Far-queue drain measured at ~10 landblocks/s; `[queue-stale]` showed the head + seq advancing 234→488→548→603→…→1360 and far count 411→351→296→242→190 — + the queue drains, it never catches up. `pendingPublications=1` is the one + head in flight, not a stuck item. +- `[fifo-wedge]` never fired: `pendingProjections=0`. The placement projection + FIFO, the sink's C3c residence gate, and the conductors are NOT involved. + +### Attribution +`git log -S CollisionStagingBuilder` → introduced by `6b28ff99` +"fix(physics): make collision activation starvation-free" (2026-07-31), on top +of `be94bc9b` (atomic activation, 2026-07-31) / `9b0f59bd` (2026-08-01). The +last PASSING soak is `a9a822f2` (2026-07-27) — before all three. `git diff` +touches no file on this path. + +### Why no fix landed (contract STOP rule) +Tried the one semantics-preserving lever: batch 256 leaves per metered +preparation step. Measured 1.8x (gate far backlog 334→187 / 264→130, loaded +landblocks 291→438 / 261→395) — real but NOT convergence. It also fails +`RuntimePhysicsStateTests.DenseResidentWorldAdmissionIsConstantAndMaterializesOneLeafPerStep` +(tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:983, +`Assert.InRange(step.WorkUnits, 0, 1)`) — one-leaf-per-step is an ASSERTED +invariant of the slice that introduced the clone. Reverted; tree is byte-identical +to flip+F1+F2+F3. + +Convergence requires the clone to become O(changed) instead of O(resident world) +— structural sharing in `CollisionWorldState`, or a per-landblock (not +whole-world) atomic replacement unit. That is a semantics change to the C2-era +mechanism and belongs to its own slice. + +### Secondary symptoms — same mechanism, not separate defects +`loadedLandblocks` 283→189 and `visibleLandblocks` 30 vs the baseline's 180 are +the far ring never converging (baseline held 625 loaded at every checkpoint). +Mesh cache 547→588 / +40.5 MB is different far-ring subsets resident at the two +visits; retest for a true leak only after convergence is restored. + +## C3c-F5 — local-player first-entry contact seeding + +### Research (grep-named-first, complete before design) +- Retail local-player seeding point: `SmartBox::HandleCreateObject` 0x00454C80 + runs `SmartBox::init_player` 0x00455010 then `CPhysicsObj::enter_world` + (call site 0x00455095; body 0x00516170) for the LOCAL player — the same + enter_world the non-player branch uses at 0x004550EC. enter_world builds + SetPositionStruct flags 0x11, calls CPhysicsObj::SetPosition, sets + `transient_state |= 0x80` (ACTIVE) and HandleEnterWorld — NO contact + seeding anywhere in it (pseudo-C 284198-284249). Contact arrives from the + first gravity frame (digest #270 section; find_placement_pos validates the + spot but records no touch). Local player and remote spawn CONFIRMED to + share the retail mechanism. +- Legacy local path (deleted by the flip): BuildControllerAndCamera ran + Resolve(100f drop) + ResolvePlacement then PreparePositionForCommit -> + SetPositionCore, which FORCE-seeded `Contact | OnWalkable | Active` + ("Treat as grounded after a server-side position snap", + PlayerMovementController.cs:1830-1834) — an unconditional non-retail seed + (Contact-without-plane, the state the landing family calls + unrepresentable). The flip deleted the call chain without an equivalent. +- New path today: conductor -> publication -> dormant activation -> + PhysicsEngine.SetPosition (faithful port, result.InContact=false for a + clean placement) -> TryApplyDormantLocalActivationCommit commits + contact=false -> FinalizeActivation activates. Body starts airborne; + outbound CanSendPositionEvent (= InContact && OnWalkable, + PlayerMovementController.cs:1517) stays false -> MTS contact byte 0 + (LocalPlayerOutboundController.cs:203) -> ACE says 'while in the air'. +- DO-NOT-RETRY compliance: settle passes isOnGround:false + real body (no + caller-bool seeding); no ContactPlaneValid gating; no forced transients — + contact only from the sweep's real touch; airborne spawn stays airborne. + +### Design (pinned-contract shape) +- Move RemoteSpawnPlacementSettler (App) -> Core.Physics + `SpawnPlacementSettler` (public, like PhysicsObjUpdate; Core internals are + NOT visible to App). Remote caller + tests updated; semantics byte-identical. +- Seed at RuntimeLocalPlayerPhysicsPublicationState.FinalizeActivation, + after TryApplyDormantLocalActivationFinalCommit + shadow dispatch + + IsCommittedActivationSuffixCurrent, before placement dispatch (no + reentrant-sink hazard; stale-authority path skips the settle). Inputs: + activation Body/Record key/ActivationPreparation radius+height, + IsPlayer|EdgeSlide|OwnPvpFlags, Movement.HitGround/Motion.LeaveGround — + the same callback pair the per-tick landing path uses. +- Propagation: body transients (a) are THE controller grounded state (b) + (controller reads _body directly) and THE outbound bit (c) + (CanSendPositionEvent -> contactByte). No second copy exists. + +### Implementation (seams touched) +1. src/AcDream.Core/Physics/SpawnPlacementSettler.cs — NEW (moved from + src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs, deleted; public + like PhysicsObjUpdate because Core internals are not visible to App). + TrySettle body byte-identical to the #270 shipped version; only the + class name/namespace/doc changed. +2. src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:193 — the + one legacy remote caller now calls + AcDream.Core.Physics.SpawnPlacementSettler.TrySettle (unchanged args). +3. src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs + — FinalizeActivation now calls new private + SettleFirstEntryGroundContact(activation) AFTER + TryApplyDormantLocalActivationFinalCommit + shadow dispatch + + IsCommittedActivationSuffixCurrent, BEFORE placement dispatch (no + reentrant-sink window; stale-authority early return skips the settle). + Inputs: activation.Body position/cell, ActivationPreparation + radius/height, IsPlayer|EdgeSlide|OwnPvpFlags, + Controller.LocalEntityId, Movement.HitGround/Motion.LeaveGround (the + per-tick landing pair). try/catch matches the existing post-commit + ground-edge dispatch containment (_activationDispatchFailureCount). +4. Tests: tests/AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs + (moved from App.Tests, bodies unchanged, layer rule 6); + Issue270ProductionWiringTests source pin updated to the new name; + RuntimeLocalPlayerPhysicsPublicationStateTests +2 + (CommitActivationOnFlatGroundSeedsRetailFirstGravityFrameContact, + CommitActivationOverVoidLeavesFirstEntryGenuinelyAirborne) + fixture + moverSphereOriginZ param (default 0 = pre-existing shape); + RuntimeFirstEntryHostIntegrationTests +2 + (LocalLogin_FlatGround_ReportsGroundedOutboundContactBit, + LocalLogin_AirborneSpawn_StaysGenuinelyAirborne) + HostFixture + terrainHeight/moverSphereOriginZ params. + +### Gate ladder +1. Focused: publication-state 92/92 (90+2), first-entry conductor 15/15, + settler 3/3 (Core), App first-entry integration + issue-270 wiring 8/8. + Complete projects (Release): Runtime 999/0, App 4,036/3 skips, + Headless 78/0, Core 4,247/1 skip. + NOTE (pre-existing, not this slice): + LandblockBuildOriginTests.FarLoad_StripsEnvCellsAndPhysics... fails in + DEBUG only — the test feeds an intentional near payload to a FarLoad and + LandblockStreamer.cs:505 Debug.Assert fires; Release (the gate config) + compiles it out and it passes. Reproduced 3x isolated in Debug, passes + in Release; untouched by this slice's diff. +2. Release solution build 0 warn/0 err; complete solution (-m:1, + ACDREAM_PAK_PATH): 10,808 passed / 0 failed / 4 skips + (App 4,036/3, Bake 15, Cli 4, Content 124, Core.Net 762, Core 4,247/1, + Headless 78, Runtime 999, UI 543). +3. Connected lifecycle gate: launched against live ACE (process 22100, + UDP 9000); result pending. +3 (result). Connected lifecycle gate PASS: + logs/connected-world-gate-20260802-164432/report.json Passed=true, + Failures=[], both sessions ExitCode=0, one warning ("capped: 25 expected + world-edge landblock miss(es)" — the exact 142539/161138 class). All 6 + capped checkpoints + uncapped_reconnect captured with screenshots. + "while in the air" grep: 0 in capped/stdout.log, 0 in + uncapped-reconnect/stdout.log (0x042C also 0/0). Note: the two prior + PASS runs (142539/161138) also contained 0 occurrences — the scripted + route never surfaced the rejection string; the behavioral proof of the + fix is the outbound-bit integration tests + the settle assertions, and + the gate proves no regression. +4. git diff --check exit 0 (CRLF metadata warnings only, the known worktree + pattern); nothing staged; no probes added by this slice. + +### Register note +No divergence-register row existed for the #270 compressed settle (it is +classified as timing compression — it produces exactly the state retail's +first gravity frame produces); extending it symmetrically to the local +player follows the same classification. The commit that lands C3c should +also delete/refresh AD-42 (its cited legacy GameWindow/PlayerModeController +resolve path no longer exists in the flipped tree) — flagged for the +closeout, not acted on here (report-only bookkeeping, no doc edits in this +slice's scope). + +### Purple-haze note (observation only) +The haze script is the Hidden/UnHide materialization path +(EntityEffectController.PlayTypedFromHiddenTransition — retail set_hidden +0x00514C60); nothing in it keys off contact/airborne state, so the F5 +contact gap does NOT plausibly drive the re-fire. A re-fired UnHide implies +the local player's presentation saw a hidden/visibility edge while standing +— consistent with F4's streaming/publication convergence regression +re-bucketing the player's surroundings, which remains the plausible driver. + +## C3c-R1 +Fix round for review round 1 (contract c3c-r1-fixes.md). Every finding +verified at source before editing; dispositions below. + +### Verification-at-source results (pre-edit) +- R1 CONFIRMED: CommitPreparedPosition (PlayerMovementController.cs:1784) + had ZERO production callers post-flip (grep); PreparePositionForCommit + (called at RuntimeLocalPlayerPhysicsPublicationState.cs:219) uses + publishSharedState:false and the PositionManager binds at :318 (after the + position seed), so no login path armed the leash. The final commit + (RuntimeSetPositionState.cs:2494-2516) is where the accepted position is + final, the shared cell is published (:2508), and the controller activates + (:2516). +- R2 CONFIRMED: LiveEntityRuntime.cs:806-828 keyed the presentation-only + branch off the sticky enum; post-residence entities skipped CommitRebucket + (:882-892) + the prepare_to_enter_world clock edges (:897-924). All six + cited unflipped-route callers verified (grep RebucketLiveEntity). +- R3 CONFIRMED: RuntimeLiveEntitySessionController.OnSpawned opened the + residence unconditionally; HeadlessSessionHost builds drive+projection only + under `_contentLease is { } content` (:539-565); content-less is + validated-legal (HeadlessConfigurationLoader.ValidateContent :88-98 + returns on null). worldProjection==null is exactly "no drive exists" + (single production constructor call site). +- F2 CONFIRMED: SpawnPlacementSettler.cs:61 commits settle.Position; + settle.CellId never read. +- F4 CONFIRMED: F3's landblockId==0 guards (RuntimePhysicsState + .BeginCollisionAdmission :2037-area) throw through + HeadlessCollisionGenerationTransaction.Begin (:62) reachable from + CenterOn; the two cited sites passed the raw wire LandblockId unguarded. +- F5/F6/F7/F8/F9 confirmed as cited (drive `_pending` outside every ledger; + both route Disposes cleared a SHARED drive unconditionally; far-remote + DeferredCell park has no wake outside the 3x3 neighborhood; the three + stale comments; the per-pump TryGetWorldEntity+GetSetupCylinder). + +### F3 — STOPPED, pinned design conflicts with source (file:line evidence) +The pinned probe ("nested production OnCreate, not hand-called +AdvanceCreateAuthority") cannot produce create-authority drift in the +items-6/8 tests' post-residence window: +1. RuntimeEntityObjectLifetime.cs:660-665 — the ExistingGeneration branch + calls Entities.AdvanceCreateAuthority ONLY when !beginInitialResidence; + the graphical route always registers with residence + (LiveEntityRuntime.cs:544-548), so a post-residence same-generation + Create is description-only. +2. The FIFO-adoption alternative is closed: ConsumeExecuted removes the + completed residence entry at Released + (RuntimeInitialCreateResidenceState.cs:1246-1251), and the fixtures + complete+release during the initial OnCreate, so TryGetTransaction + (:729-748) misses and no continuation can be staged post-release. +3. The executor drain (RuntimeInitialCreateContinuationExecutor.cs:2424-2429) + is the ONLY production site that advances create authority for an + existing incarnation - exactly what the hand-call models. +4. EMPIRICAL: temporarily restoring the nested-OnCreate probe in + FailedCompletedSupersession_RemainsPendingUntilExactRetry made BOTH rows + fail "Assert.Throws() Failure: No exception was thrown" (no drift, no + CreateSupersessionRecovery). Experiment reverted; tree byte-identical + for that file except nothing. +Restoring the probe requires either accepting dead drift machinery or a +test-scenario redesign (drift staged during the ACTIVE-residence window and +drained mid-recovery by a reentrant pump) - a design call for the +coordinator, not a probe swap. NOT implemented; reported. + +### R4(c) progress-log correction (retail minor M1) +The C3c-F5 section above says the legacy local force-seed path was +"deleted by the flip". CORRECTION: the force-seed at +PlayerMovementController.cs SetPositionCore ("Treat as grounded after a +server-side position snap", Contact|OnWalkable|Active) still RUNS during +publication-candidate preparation (PreparePositionForCommit -> +SetPositionCore) and is then OVERWRITTEN by the faithful activation commit +(which commits the SetPosition result's contact=false) and the settle. What +the flip deleted was the CALLER CHAIN (BuildControllerAndCamera's +Resolve/ResolvePlacement + CommitPreparedPosition), not the seed statement. +Register row AD-61 records the "overwritten, not deleted" truth. + +### Implemented (all others) +- R1: PlayerMovementController.ArmConstraintLeashAtCommittedPlacement + (internal, published-guarded) + RuntimeLocalPlayerPhysicsPublicationState + .ArmFirstEntryConstraintLeash called in FinalizeActivation after the + final commit + shadow dispatch, inside the IsCommittedActivationSuffixCurrent + gate, BEFORE the settle; exactly-once via `_activation = null` preceding + the suffix. Ordering justified in the new doc comment with file:line. +- R2: the public RebucketLiveEntity now suppresses ONLY while + HasActiveInitialCreateResidence (exact-token activity view); post-residence + falls through to the FULL legacy branch (CommitRebucket + clock edges). + RebucketLiveEntityPresentationOnly is now called only from + TryApplyInitialCreateCompletionPresentation. +- R3: content-less direct host (worldProjection null) registers via + RegisterEntity + direct accepted-frame commit (exact pre-flip shape), + with the C4/C5 revisit note. +- R4: register AD-61 filed (local-player settle compression, overwritten + force-seed, settle-CellId caveat); AD-42 refreshed (repointed at + RemoteTeleportController.ResolvePlacement + headless portal resync; + login split retired); section count 46->47. +- F1: LiveEntityRuntime.ConvertMaterializationResidenceToLegacyImmediate + (throws while residence active); EquippedChildRenderController uses it. +- F4: `{ LandblockId: not 0u }` guards at both cited CenterOn sites. +- F5: RuntimeEntityObjectOwnershipSnapshot.FirstEntryDrivePendingCount + (IsConverged-gated) + RegisterFirstEntryDriveOwnership; the drive + registers its pending-count provider at construction. +- F6: RuntimeFirstEntryDriveController.AttachRoute/DetachRoute one-route + latch (Clear deleted); GraphicalSessionEventRoute + HeadlessSessionEventRoute + attach/detach with `this`. +- F7: RuntimeAuthoritativePositionRouteClassifier.ToCellessCreateRoute + (exact Parented/PickedUp branch shape, preserving authority/operation/ + collision-batch); RuntimeInitialCreateResidenceState.TryConvertToCellessRoute + (active+unplaced only: ForgetExactPlacement + lease rewrite + + PublishCancellation + retirement fan-out for conductor progress reset, + entry retained); RuntimeEntityObjectLifetime + .TryConvertInitialResidenceToCellessRoute facade; + IHeadlessCollisionNeighborhood.IsWithinServiceWindow (3x3 around the + requested center; no-center = within) consumed by + HeadlessSessionWorldProjection.ProjectSpawn for far remotes before the + pump. +- F8: both conductor "nothing calls Advance in production" headers + corrected; PlayerModeController's two false "auto-entry retries" claims + replaced with the actual disarm-before-invoke semantics (verified against + PlayerModeAutoEntry.TryEnter :232-248 - _armed=false precedes the + invoke, and a throw propagates before the context's reveal Complete()). +- F9: the graphical activation-preparation provider caches the resolved + setup cylinder per incarnation (keyed by LocalEntityId; unresolved + results not cached; shadow disposition stays live - it is validated + against the exact registry at activation commit and may change between + pumps). + +### New tests +- Runtime: CommitActivationArmsTheLoginConstraintLeashAtTheCommittedPlacement, + CommitActivationNeverRearmsTheLeashOnAStaleRetry (publication suite); + ContentLessDirectSink_KeepsPreFlipLegacyRegistration, + FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner + (session-controller suite; DirectSinkOwnsCanonicalCreateUpdateDelete... + updated to pass a world projection so it keeps exercising the residence + route per R3's semantics). +- App: PostResidenceRebucket_TakesTheFullLegacyPathIncludingTheClockEdge + (active-suppression + F5 ledger visibility + retired->legacy CommitRebucket + + the pending-reentry clock rebase), + ResidenceConversionToLegacyImmediate_RefusesWhileTheResidenceIsActive. +- Headless: FarRemoteCreateCompletesCelllessWithoutPinningItsResidence + (+ Spawn(guid, cellId) helper param; FixtureCollisionNeighborhood + implements IsWithinServiceWindow). +- One in-flight test correction during TDD: the leash test's anchor + assertions initially assumed the committed placement kept the raw spawn + Z=3; the faithful placement transaction already floor-snaps (2.705), so + the anchor asserts the committed floor-snapped band + committed cell. + +### Gate ladder (this round) +1. Runtime 1,003/1,003; App 4,038/3 skips of 4,041; Headless 79/79. +2. Release solution build: 0 errors. Complete solution (-m:1, + ACDREAM_PAK_PATH): 10,815 passed / 0 failed / 4 skipped of 10,819 + (App 4,038/3, Bake 15, Cli 4, Content 124, Core.Net 762, Core 4,247/1, + Headless 79, Runtime 1,003, UI 543). +3. Connected gate run 1: logs/connected-world-gate-20260802-174811 — + Passed=false, ATTRIBUTED TO USER INTERFERENCE per the coordinator + (the user manually drove the client, including a teleport, during the + run). Fingerprint: every failure at the capped_login checkpoint only + (transitOwnership.activeTeleportCount=1 at the stable checkpoint, + activeRevealCount=1, pendingDestinationReadinessCount=1, + hostProjectionCount=1, reveal/viewport/composites/collision not ready, + 216 staged mesh uploads + 44 composite warmups mid-stream to the manual + teleport destination). No crash, no exception; only the expected + world-edge warning class. Coordinator sanctioned exactly ONE clean + re-run (external interference, not a blind retry); graceful-close + discipline held (no stale client process, ACE endpoint intact). +4. git diff --check: exit 0 (CRLF metadata warnings only, the known + worktree pattern); nothing staged. +3 (result). Sanctioned clean re-run PASS: + logs/connected-world-gate-20260802-175401/report.json Passed=true, + Failures=[], only the expected world-edge warning — the established + passing signature (142539/161138/164432 class). 174811 interference + attribution confirmed. Round complete; nothing staged. + +### F3 addendum (coordinator resolution accepted, implemented) +Hand-calls KEPT as honest documented models: enriched comments at all six +item-6/8 sites (LiveEntityHydrationControllerTests x5 sites incl. the +shared item-6/CompletedSupersessionReadyFailure text, +LiveEntityCreateSupersessionRecoveryTests x1) citing +ApplyWeenieDescriptionAction as the sole production site, +RuntimeEntityObjectLifetime :660-665 !beginInitialResidence gate, and +ConsumeExecuted. NEW source pin +tests/AcDream.App.Tests/World/C3cR1F3DriftModelSourcePinTests +.HandCalledDriftProbe_StillModelsTheExecutorDrainAdvance (single +_entities.AdvanceCreateAuthority in the executor, inside +ApplyWeenieDescriptionAction; registration advance still residence-gated). +Focused files 82/82; Runtime 1,003/1,003; App 4,039/3 skips of 4,042; +git diff --check exit 0. No staging/commits. diff --git a/docs/research/2026-08-02-collision-throughput-handoff/user-observations-feel-test.md b/docs/research/2026-08-02-collision-throughput-handoff/user-observations-feel-test.md new file mode 100644 index 00000000..642c6f49 --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/user-observations-feel-test.md @@ -0,0 +1,30 @@ +# User feel-test observations — O-slice tree (2026-08-02 ~20:10, uncommitted) + +Axioms; they override the gate numbers. Log: launch-feeltest-oclone.log. + +1. MONSTERS STILL POP IN while running past — the O-slice did NOT fix the + user-visible symptom despite the soak's publication convergence. +2. MONSTERS SPAWNED MID-AIR far ahead at a newly-entered area. +3. STATICS ("stabs") PLACED INCORRECTLY — visibly wrong static placement. +4. User: "This is not how retail worked. I could see monsters way in + front of me." +5. DOOR APPROACH REGRESSED: using a door no longer walks the character + to it first. NOTE: likely a COMMITTED C3c regression, not O-slice — + prime suspect is PlayerModeController's conditional MoveTo bind + (`if (controller.MoveTo is { } moveTo)` — the flip only binds + approach callbacks IF the Runtime-owned MoveToManager already exists; + the legacy path CREATED it via factory at attach). If Runtime's + MakeMoveToManager runs after player-mode attach (or never for this + flow), MoveToComplete/approach never wires. Triage first in the C3c + fix slice; check whether the 175401 gate probe ever exercised a + door/use-approach (suspect: no coverage). + +SMOKING GUN (log): 243x "streaming: origin-recenter preparation will +resume: System.InvalidOperationException: Landblock already has a +full retirement receipt." — continuous catch-retry loop during origin +recenter. Both reviewers redirected with this; the implementer's +"exposed pre-existing" retirement classification is under re-judgment. +The catch-and-resume wrapper is itself suspect as a pre-existing +symptom-swallower (no-silent-catch rule). + +STATUS: O-slice commit ON HOLD until every observation is explained. diff --git a/docs/research/2026-08-02-collision-throughput-handoff/user-observations-smoke-test.md b/docs/research/2026-08-02-collision-throughput-handoff/user-observations-smoke-test.md new file mode 100644 index 00000000..dc6f6148 --- /dev/null +++ b/docs/research/2026-08-02-collision-throughput-handoff/user-observations-smoke-test.md @@ -0,0 +1,29 @@ +# User in-game observations — 2026-08-02 (~15:40, during the F4 diagnostic run) + +Axioms per the retail-oracle rule. User will do a full test session once +the current work passes; these are the pre-session signals. + +1. AIRBORNE-WHILE-STANDING (severe, flip-suspect): repeated + "[System] You can't do that while in the air!" + + "You can't do that. (error 0x042C)" x4 + one "WeenieError 0x001D" when + trying to cast while standing still. Suspect: the conductor placement + path lacks the legacy spawn path's #270 settle sweep (contact from the + compressed first gravity frame) -> outbound contact state says + airborne. Routed to F4 as a lead (unified-hypothesis check); if F4's + stuck item is not the player, this becomes its own slice (F5) BEFORE + the C3c commit — casting is core gameplay and blocks the smoke test. +2. MATERIALIZATION HAZE RE-FIRING while standing still (flip-suspect): + purple haze re-triggers around the character. Plausibly the visible + face of the soak's pendingPublications=1 stuck item if that item is + the local player. Routed to F4. +3. NO SLIDE ALONG IMPASSABLE SLOPES: walking into too-steep terrain does + not glide laterally. Likely pre-existing open issue #269 (Campaign P + slope-slide residual). Verify pre-existence during the review/closeout; + do not fold into C3c unless evidence says the flip changed it. +4. /ls DOES NOT WORK: unclear which command surface (chat slash command?). + Triage at the session; low priority. + +Review-focus implication: retail reviewer must verify the flip preserves +the legacy spawn path's contact seeding (#270) semantics; adversarial +reviewer must verify the placement publication for the local player +actually completes and is reaped. diff --git a/src/AcDream.Core/Physics/CollisionWorldState.cs b/src/AcDream.Core/Physics/CollisionWorldState.cs index bc92d882..3e46a97f 100644 --- a/src/AcDream.Core/Physics/CollisionWorldState.cs +++ b/src/AcDream.Core/Physics/CollisionWorldState.cs @@ -3,6 +3,81 @@ using AcDream.Core.World.Cells; namespace AcDream.Core.Physics; +/// +/// Per-landblock installed-key ledger for one collision-world map. Slot lists +/// mirror the shadow registry's prefix-owner-slot idiom: removal tombstones a +/// slot (key 0) so an in-flight metered cursor retains its captured list +/// reference and observes only tombstones, and an emptied container is +/// reclaimed so a future install gets a fresh compact list. Maintained by the +/// typed install/remove helpers; consumed by +/// the landblock-replacement seal so capturing one prefix's keys never scans +/// the whole resident world (O1 of the 2026-08-02 collision +/// publication-throughput fix). +/// +internal sealed class PrefixKeyIndex +{ + private readonly Dictionary> _slots = new(); + private readonly Dictionary> _indices = new(); + private readonly Dictionary> _freeSlots = new(); + + internal void Add(uint key) + { + uint prefix = key & 0xFFFF0000u; + if (!_slots.TryGetValue(prefix, out List? slots)) + { + slots = new List(); + _slots[prefix] = slots; + _indices[prefix] = new Dictionary(); + _freeSlots[prefix] = new Stack(); + } + Dictionary indices = _indices[prefix]; + if (indices.ContainsKey(key)) + return; + if (_freeSlots[prefix].TryPop(out int freeIndex)) + { + slots[freeIndex] = key; + indices[key] = freeIndex; + return; + } + indices[key] = slots.Count; + slots.Add(key); + } + + internal void Remove(uint key) + { + uint prefix = key & 0xFFFF0000u; + if (!_indices.TryGetValue(prefix, out Dictionary? indices) + || !indices.Remove(key, out int slotIndex)) + { + return; + } + _slots[prefix][slotIndex] = 0u; + _freeSlots[prefix].Push(slotIndex); + if (indices.Count != 0) + return; + // An in-flight seal cursor retains its captured List reference and + // observes only tombstones. A future install gets a fresh container. + _slots.Remove(prefix); + _indices.Remove(prefix); + _freeSlots.Remove(prefix); + } + + /// + /// The live slot list for one landblock prefix, or null when no key is + /// installed. Callers capture the reference plus Count once and + /// iterate by index, skipping tombstone slots (key 0). + /// + internal List? SlotsForPrefix(uint prefix) => + _slots.TryGetValue(prefix & 0xFFFF0000u, out List? slots) + ? slots + : null; + + internal int InstalledKeyCountForPrefix(uint prefix) => + _indices.TryGetValue(prefix & 0xFFFF0000u, out var indices) + ? indices.Count + : 0; +} + /// /// One exclusive-by-ownership collision-world root. A preparation mutates only /// its private root; activation transfers the complete root through one volatile @@ -38,6 +113,128 @@ internal sealed class CollisionWorldState internal List ShadowOwnerSlots { get; } = new(); internal Dictionary ShadowOwnerIndices { get; } = new(); internal Stack ShadowOwnerFreeSlots { get; } = new(); + + // ── O1 per-prefix installed-key ledgers ──────────────────────────────── + // Every mutation of the five landblock-scoped world maps goes through the + // typed helpers below so these ledgers stay exact. The seal's landblock- + // replacement builders enumerate one prefix's keys instead of scanning the + // whole resident map, and the retirement/removal paths retire one prefix + // in O(prefix keys). + internal PrefixKeyIndex CellStructKeys { get; } = new(); + internal PrefixKeyIndex FlatCellStructKeys { get; } = new(); + internal PrefixKeyIndex FlatEnvCellKeys { get; } = new(); + internal PrefixKeyIndex BuildingKeys { get; } = new(); + internal PrefixKeyIndex EnvCellKeys { get; } = new(); + + internal void SetCellStruct(uint id, CellPhysics value) + { + CellStruct[id] = value; + CellStructKeys.Add(id); + } + + internal bool TryAddCellStruct(uint id, CellPhysics value) + { + if (!CellStruct.TryAdd(id, value)) + return false; + CellStructKeys.Add(id); + return true; + } + + internal bool RemoveCellStruct(uint id) + { + if (!CellStruct.TryRemove(id, out _)) + return false; + CellStructKeys.Remove(id); + return true; + } + + internal void SetFlatCellStruct(uint id, FlatCellStructureCollisionAsset value) + { + FlatCellStruct[id] = value; + FlatCellStructKeys.Add(id); + } + + internal bool TryAddFlatCellStruct(uint id, FlatCellStructureCollisionAsset value) + { + if (!FlatCellStruct.TryAdd(id, value)) + return false; + FlatCellStructKeys.Add(id); + return true; + } + + internal bool RemoveFlatCellStruct(uint id) + { + if (!FlatCellStruct.TryRemove(id, out _)) + return false; + FlatCellStructKeys.Remove(id); + return true; + } + + internal void SetFlatEnvCell(uint id, FlatEnvCellTopology value) + { + FlatEnvCell[id] = value; + FlatEnvCellKeys.Add(id); + } + + internal bool TryAddFlatEnvCell(uint id, FlatEnvCellTopology value) + { + if (!FlatEnvCell.TryAdd(id, value)) + return false; + FlatEnvCellKeys.Add(id); + return true; + } + + internal bool RemoveFlatEnvCell(uint id) + { + if (!FlatEnvCell.TryRemove(id, out _)) + return false; + FlatEnvCellKeys.Remove(id); + return true; + } + + internal void SetBuilding(uint id, BuildingPhysics value) + { + Buildings[id] = value; + BuildingKeys.Add(id); + } + + internal bool TryAddBuilding(uint id, BuildingPhysics value) + { + if (!Buildings.TryAdd(id, value)) + return false; + BuildingKeys.Add(id); + return true; + } + + internal bool RemoveBuilding(uint id) + { + if (!Buildings.TryRemove(id, out _)) + return false; + BuildingKeys.Remove(id); + return true; + } + + internal void SetEnvCell(uint id, EnvCell value) + { + EnvCells[id] = value; + EnvCellKeys.Add(id); + } + + internal bool TryAddEnvCell(uint id, EnvCell value) + { + if (!EnvCells.TryAdd(id, value)) + return false; + EnvCellKeys.Add(id); + return true; + } + + internal bool RemoveEnvCell(uint id) + { + if (!EnvCells.TryRemove(id, out _)) + return false; + EnvCellKeys.Remove(id); + return true; + } } /// @@ -83,5 +280,18 @@ internal sealed class CollisionWorldStateSlot return transferred; } + /// + /// O2 (2026-08-02): terminally revokes a consumed staging root. The + /// per-landblock delta commit installs the staged content into the active + /// root instead of swapping roots, so the staging root no longer becomes + /// the active root — but a committed preparation must still lose access to + /// its private world exactly as the old transfer revoked it. + /// + internal void Revoke() + { + _revoked = true; + _current = null; + } + internal CollisionWorldState Capture() => Current; } diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index ad2a2b9d..9e58e7e1 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -483,9 +483,9 @@ public sealed class PhysicsDataCache preparedTopology = null; } if (preparedStructure is not null) - _flatCellStruct.TryAdd(envCellId, preparedStructure); + _collisionWorld.Current.TryAddFlatCellStruct(envCellId, preparedStructure); if (preparedTopology is not null) - _flatEnvCell.TryAdd(envCellId, preparedTopology); + _collisionWorld.Current.TryAddFlatEnvCell(envCellId, preparedTopology); // UCG Stage 1: register only a loadable authored cell. if (!CellGraph.Contains(envCellId)) @@ -558,7 +558,7 @@ public sealed class PhysicsDataCache // for every ordinary (non-house-barrier) cell. RestrictionObj = envCell.RestrictionObj, }; - _cellStruct[envCellId] = cellPhysics; + _collisionWorld.Current.SetCellStruct(envCellId, cellPhysics); if (PhysicsDiagnostics.ProbeDumpCellsEnabled && PhysicsDiagnostics.ProbeDumpCellIds.Contains(envCellId)) @@ -696,8 +696,8 @@ public sealed class PhysicsDataCache if (preparedStructure.ContainmentBsp.RootIndex < 0) return; - _flatCellStruct.TryAdd(envCellId, preparedStructure); - _flatEnvCell.TryAdd(envCellId, preparedTopology); + _collisionWorld.Current.TryAddFlatCellStruct(envCellId, preparedStructure); + _collisionWorld.Current.TryAddFlatEnvCell(envCellId, preparedTopology); if (!CellGraph.Contains(envCellId)) { @@ -724,7 +724,7 @@ public sealed class PhysicsDataCache portal.Flags)); } - _cellStruct.TryAdd(envCellId, new CellPhysics + _collisionWorld.Current.TryAddCellStruct(envCellId, new CellPhysics { SourceId = envCellId, WorldTransform = worldTransform, @@ -914,7 +914,7 @@ public sealed class PhysicsDataCache /// dat-driven . /// public void RegisterCellStructForTest(uint envCellId, CellPhysics physics) - => _cellStruct[envCellId] = physics; + => _collisionWorld.Current.SetCellStruct(envCellId, physics); /// /// Indoor walking Phase 2 (2026-05-19). Cache the building portal list @@ -926,7 +926,7 @@ public sealed class PhysicsDataCache { if (_buildings.ContainsKey(landcellId)) return; Matrix4x4.Invert(worldTransform, out var inverse); - _buildings[landcellId] = new BuildingPhysics + _collisionWorld.Current.SetBuilding(landcellId, new BuildingPhysics { WorldTransform = worldTransform, InverseWorldTransform = inverse, @@ -935,7 +935,7 @@ public sealed class PhysicsDataCache // (0x00534030) — and one building per origin landcell mirrors // CLandBlock::init_buildings (0x0052fd80). ModelId = modelId, - }; + }); } /// @@ -954,10 +954,33 @@ public sealed class PhysicsDataCache /// public void RemoveBuildingsForLandblock(uint landblockId) { - uint prefix = landblockId & 0xFFFF0000u; - foreach (var key in _buildings.Keys) - if ((key & 0xFFFF0000u) == prefix) - _buildings.TryRemove(key, out _); + CollisionWorldState world = _collisionWorld.Current; + RemovePrefixKeys( + world.BuildingKeys, + landblockId & 0xFFFF0000u, + world.RemoveBuilding); + } + + /// + /// Retires one landblock prefix's installed keys through the O1 ledger: + /// O(prefix keys), never a whole-map scan. Removal tombstones the captured + /// slot list, so index iteration over the captured reference stays exact. + /// + private static void RemovePrefixKeys( + PrefixKeyIndex ledger, + uint prefix, + Func remove) + { + List? slots = ledger.SlotsForPrefix(prefix); + if (slots is null) + return; + int limit = slots.Count; + for (int index = 0; index < limit; index++) + { + uint key = slots[index]; + if (key != 0u) + remove(key); + } } /// @@ -973,15 +996,13 @@ public sealed class PhysicsDataCache public void RemoveCellsForLandblock(uint landblockId) { uint prefix = landblockId & 0xFFFF0000u; - foreach (var key in _cellStruct.Keys) - if ((key & 0xFFFF0000u) == prefix) - _cellStruct.TryRemove(key, out _); - foreach (var key in _flatCellStruct.Keys) - if ((key & 0xFFFF0000u) == prefix) - _flatCellStruct.TryRemove(key, out _); - foreach (var key in _flatEnvCell.Keys) - if ((key & 0xFFFF0000u) == prefix) - _flatEnvCell.TryRemove(key, out _); + CollisionWorldState world = _collisionWorld.Current; + RemovePrefixKeys(world.CellStructKeys, prefix, world.RemoveCellStruct); + RemovePrefixKeys( + world.FlatCellStructKeys, + prefix, + world.RemoveFlatCellStruct); + RemovePrefixKeys(world.FlatEnvCellKeys, prefix, world.RemoveFlatEnvCell); } public BuildingPhysics? GetBuilding(uint landcellId) @@ -990,7 +1011,8 @@ public sealed class PhysicsDataCache public IReadOnlyCollection BuildingIds => (IReadOnlyCollection)_buildings.Keys; /// Test helper, mirrors . - public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b; + public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => + _collisionWorld.Current.SetBuilding(landcellId, b); internal sealed class LandblockReplacementBuilder : IDisposable { @@ -1017,10 +1039,9 @@ public sealed class PhysicsDataCache private readonly List _removeFlatEnvCells = new(); private readonly List _removeBuildings = new(); private readonly UcgCellGraph.LandblockReplacementBuilder _cellGraph; - private IEnumerator>? _cellEnumerator; - private IEnumerator>? _flatCellEnumerator; - private IEnumerator>? _flatEnvEnumerator; - private IEnumerator>? _buildingEnumerator; + private List? _keySlots; + private int _keySlotLimit; + private bool _keySlotsCaptured; private int _phase; private int _cursor; @@ -1078,93 +1099,139 @@ public sealed class PhysicsDataCache _phase++; return false; case 2: - _cellEnumerator ??= _staging._cellStruct.GetEnumerator(); - if (CapturePrefixOne(_cellEnumerator, _prefix, _cells, _cellIds)) + { + // O1: enumerate the staging root's installed target-prefix + // keys instead of scanning the whole staging map, one key + // per advance. + if (TryTakeNextPrefixKey( + StagingWorld.CellStructKeys, + out uint id)) { + CaptureInstall(_staging._cellStruct, id, _cells, _cellIds); WorkUnits++; return false; } - _cellEnumerator.Dispose(); - _cellEnumerator = null; _phase++; return false; + } case 3: - _cellEnumerator ??= _active._cellStruct.GetEnumerator(); - if (CaptureRemovalOne(_cellEnumerator, _prefix, _cellIds, _removeCells)) + { + // O1: enumerate the active root's installed target-prefix + // keys for removal capture. This also removes the previous + // cross-frame live enumerator over the active map. + if (TryTakeNextPrefixKey( + ActiveWorld.CellStructKeys, + out uint id)) { + CaptureRemoval(_active._cellStruct, id, _cellIds, _removeCells); WorkUnits++; return false; } - _cellEnumerator.Dispose(); - _cellEnumerator = null; _phase++; return false; + } case 4: - _flatCellEnumerator ??= _staging._flatCellStruct.GetEnumerator(); - if (CapturePrefixOne(_flatCellEnumerator, _prefix, _flatCells, _flatCellIds)) + { + if (TryTakeNextPrefixKey( + StagingWorld.FlatCellStructKeys, + out uint id)) { + CaptureInstall( + _staging._flatCellStruct, + id, + _flatCells, + _flatCellIds); WorkUnits++; return false; } - _flatCellEnumerator.Dispose(); - _flatCellEnumerator = null; _phase++; return false; + } case 5: - _flatCellEnumerator ??= _active._flatCellStruct.GetEnumerator(); - if (CaptureRemovalOne(_flatCellEnumerator, _prefix, _flatCellIds, _removeFlatCells)) + { + if (TryTakeNextPrefixKey( + ActiveWorld.FlatCellStructKeys, + out uint id)) { + CaptureRemoval( + _active._flatCellStruct, + id, + _flatCellIds, + _removeFlatCells); WorkUnits++; return false; } - _flatCellEnumerator.Dispose(); - _flatCellEnumerator = null; _phase++; return false; + } case 6: - _flatEnvEnumerator ??= _staging._flatEnvCell.GetEnumerator(); - if (CapturePrefixOne(_flatEnvEnumerator, _prefix, _flatEnvCells, _flatEnvCellIds)) + { + if (TryTakeNextPrefixKey( + StagingWorld.FlatEnvCellKeys, + out uint id)) { + CaptureInstall( + _staging._flatEnvCell, + id, + _flatEnvCells, + _flatEnvCellIds); WorkUnits++; return false; } - _flatEnvEnumerator.Dispose(); - _flatEnvEnumerator = null; _phase++; return false; + } case 7: - _flatEnvEnumerator ??= _active._flatEnvCell.GetEnumerator(); - if (CaptureRemovalOne(_flatEnvEnumerator, _prefix, _flatEnvCellIds, _removeFlatEnvCells)) + { + if (TryTakeNextPrefixKey( + ActiveWorld.FlatEnvCellKeys, + out uint id)) { + CaptureRemoval( + _active._flatEnvCell, + id, + _flatEnvCellIds, + _removeFlatEnvCells); WorkUnits++; return false; } - _flatEnvEnumerator.Dispose(); - _flatEnvEnumerator = null; _phase++; return false; + } case 8: - _buildingEnumerator ??= _staging._buildings.GetEnumerator(); - if (CapturePrefixOne(_buildingEnumerator, _prefix, _buildings, _buildingIds)) + { + if (TryTakeNextPrefixKey( + StagingWorld.BuildingKeys, + out uint id)) { + CaptureInstall( + _staging._buildings, + id, + _buildings, + _buildingIds); WorkUnits++; return false; } - _buildingEnumerator.Dispose(); - _buildingEnumerator = null; _phase++; return false; + } case 9: - _buildingEnumerator ??= _active._buildings.GetEnumerator(); - if (CaptureRemovalOne(_buildingEnumerator, _prefix, _buildingIds, _removeBuildings)) + { + if (TryTakeNextPrefixKey( + ActiveWorld.BuildingKeys, + out uint id)) { + CaptureRemoval( + _active._buildings, + id, + _buildingIds, + _removeBuildings); WorkUnits++; return false; } - _buildingEnumerator.Dispose(); - _buildingEnumerator = null; _phase++; return false; + } case 10: WorkUnits++; if (!_cellGraph.Advance()) @@ -1210,43 +1277,70 @@ public sealed class PhysicsDataCache destination.TryAdd(id, value); } - private static bool CapturePrefixOne( - IEnumerator> enumerator, - uint prefix, + private CollisionWorldState StagingWorld => + _staging._collisionWorld.Current; + + private CollisionWorldState ActiveWorld => + _active._collisionWorld.Current; + + /// + /// O1 metered prefix-key cursor. The first call of a phase captures + /// the ledger's live slot-list reference and count; later calls + /// iterate by index, skipping tombstones (key 0). Removal only ever + /// tombstones a slot, so a captured reference stays exact across + /// frames without holding a map enumerator. + /// + private bool TryTakeNextPrefixKey(PrefixKeyIndex ledger, out uint key) + { + if (!_keySlotsCaptured) + { + _keySlots = ledger.SlotsForPrefix(_prefix); + _keySlotLimit = _keySlots?.Count ?? 0; + _keySlotsCaptured = true; + _cursor = 0; + } + while (_cursor < _keySlotLimit) + { + uint candidate = _keySlots![_cursor++]; + if (candidate != 0u) + { + key = candidate; + return true; + } + } + key = 0u; + _keySlots = null; + _keySlotsCaptured = false; + return false; + } + + private static void CaptureInstall( + ConcurrentDictionary source, + uint id, List> destination, HashSet ids) { - if (!enumerator.MoveNext()) - return false; - KeyValuePair pair = enumerator.Current; - if ((pair.Key & 0xFFFF0000u) == prefix) + if (source.TryGetValue(id, out T? value)) { - destination.Add(pair); - ids.Add(pair.Key); + destination.Add(new KeyValuePair(id, value)); + ids.Add(id); } - return true; } - private static bool CaptureRemovalOne( - IEnumerator> enumerator, - uint prefix, + private static void CaptureRemoval( + ConcurrentDictionary source, + uint id, HashSet retained, List destination) { - if (!enumerator.MoveNext()) - return false; - uint id = enumerator.Current.Key; - if ((id & 0xFFFF0000u) == prefix && !retained.Contains(id)) + if (!retained.Contains(id) && source.ContainsKey(id)) destination.Add(id); - return true; } public void Dispose() { - _cellEnumerator?.Dispose(); - _flatCellEnumerator?.Dispose(); - _flatEnvEnumerator?.Dispose(); - _buildingEnumerator?.Dispose(); + _keySlots = null; + _keySlotsCaptured = false; _cellGraph.Dispose(); } } diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 69194998..9fc0c335 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -249,20 +249,22 @@ public sealed class PhysicsEngine float WorldOffsetY); /// - /// Creates an off-side collision world from the last complete generation. - /// Streaming modifies this copy only; the active engine and its borrowed - /// cache/registry identities remain stable until Runtime commits. + /// Creates the empty off-side staging root for one landblock collision + /// generation. O3 (2026-08-02): admission no longer materializes a clone + /// of the resident world — the staging root holds ONLY the target + /// landblock's authored content and the commit installs it into the + /// active root as a per-landblock delta whose owner refloods run against + /// the live world (retail CObjCell::init_objects 0x0052B420 → + /// CPhysicsObj::recalc_cross_cells 0x00515A30). /// internal CollisionStagingBuilder CreateCollisionStagingBuilder( uint targetLandblockId) { + _ = targetLandblockId; PhysicsDataCache activeCache = DataCache ?? throw new InvalidOperationException( "Active collision engine has no data cache."); - return new CollisionStagingBuilder( - this, - activeCache, - targetLandblockId & 0xFFFF0000u); + return new CollisionStagingBuilder(this, activeCache); } internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( @@ -301,6 +303,20 @@ public sealed class PhysicsEngine expectedRetainedOwners)); } + /// + /// Publishes one sealed landblock replacement into the ACTIVE collision + /// root as a per-landblock delta drained in this one synchronous + /// update-thread call — the O2 (2026-08-02) restoration of be94bc9b's + /// O(changed) commit, replacing the whole-root + /// CollisionWorldStateSlot.TransferTo swap. Retail hydrates one + /// cell synchronously and refloods the objects associated with it + /// (CObjCell::init_objects 0x0052B420 → + /// CPhysicsObj::recalc_cross_cells 0x00515A30); the streaming + /// analogue is one landblock delta applied atomically with respect to + /// every reader (the runtime is single-threaded and the caller holds the + /// prefix quiescence permission). Owner rows install from the sealed + /// staging registry, which the seal keeps exactly current. + /// internal void CommitLandblockReplacement( PreparedPhysicsEngineLandblock replacement) { @@ -310,14 +326,59 @@ public sealed class PhysicsEngine PhysicsDataCache stagingCache = replacement.Staging.DataCache ?? throw new InvalidOperationException( "Staging collision engine has no data cache."); - uint activeCurrentCellId = activeCache.CellGraph.CurrCell?.Id ?? 0u; - stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld); - if ((activeCurrentCellId & 0xFFFF0000u) - == (replacement.LandblockId & 0xFFFF0000u)) + ShadowObjectRegistry stagingShadows = replacement.Staging.ShadowObjects; + CollisionWorldState active = activeCache.CollisionWorld.Current; + + // Rows in retired target cells belong exclusively to owners in the + // sealed owner list (every owner with target-prefix rows is captured + // there), so dropping the retired cells' row lists first can never + // discard a row the owner installs below. + PreparedPhysicsDataCacheLandblock data = replacement.DataCache; + for (int index = 0; index < data.CellIdsToRemove.Count; index++) + active.ShadowCells.Remove(data.CellIdsToRemove[index]); + IReadOnlyList envCellRemovals = + data.CellGraph.EnvCellIdsToRemove; + for (int index = 0; index < envCellRemovals.Count; index++) + active.ShadowCells.Remove(envCellRemovals[index]); + + using (LandblockReplacementApplyCursor cursor = + CreateLandblockReplacementApplyCursor(replacement)) { - activeCache.CellGraph.CurrCell = - activeCache.CellGraph.GetVisible(activeCurrentCellId); + while (true) + { + LandblockReplacementApplyStep step = cursor.Advance(); + if (step.HasOwner) + { + // O3 (2026-08-02): retail's per-cell hydration suffix — + // adopt each staged owner and recalculate its cross-cells + // against the live post-delta world, retire the outgoing + // generation's authored statics, and re-run the flood for + // retained live owners touching the replaced landblock + // (CObjCell::init_objects 0x0052B420 → + // CPhysicsObj::recalc_cross_cells 0x00515A30). + ShadowObjects.ApplyCommittedOwnerReplacement( + stagingShadows, + step.OwnerId, + replacement.LandblockId); + } + if (step.Completed) + break; + } } + + // Retail init_objects refloods every object associated with the + // hydrated cell at hydration time. Owners that became associated with + // the target after the sealed capture (a mover entering the prefix + // mid-publication) are in the live prefix-owner slots but not the + // sealed list; recalculate their cross-cells here too. + ShadowObjects.RefloodPrefixOwnersAfterReplacement( + replacement.LandblockId, + replacement.Shadows.OwnerIds); + + // The staging root no longer becomes the active root, but a committed + // preparation must still lose its private world exactly as TransferTo + // revoked it. + stagingCache.CollisionWorld.Revoke(); } internal LandblockReplacementApplyCursor @@ -331,239 +392,12 @@ public sealed class PhysicsEngine bool HasOwner, uint OwnerId); - internal LandblockRetirementCursor CreateLandblockRetirementCursor( - PhysicsEngine authoritative, - uint landblockId, - bool withdraw) => new( - this, - authoritative, - landblockId, - withdraw); - /// - /// Applies one demotion/withdrawal to an off-side root without a whole- - /// world synchronous scan. Every advance inspects or mutates at most one - /// stable owner slot, dictionary leaf, or authored outdoor cell. - /// - internal sealed class LandblockRetirementCursor : IDisposable - { - private readonly PhysicsEngine _destinationEngine; - private readonly PhysicsDataCache _destinationCache; - private readonly CollisionWorldState _destination; - private readonly CollisionWorldState _authoritative; - private readonly uint _canonical; - private readonly uint _prefix; - private readonly bool _withdraw; - private readonly List _ownerSlots; - private readonly int _ownerSlotLimit; - private readonly LandblockPhysics? _demotedLandblock; - private readonly CellGraphTerrain? _demotedTerrain; - private IEnumerator>? _cells; - private IEnumerator>? - _flatCells; - private IEnumerator>? _flatEnvCells; - private IEnumerator>? _buildings; - private IEnumerator>? _envCells; - private int _ownerIndex; - private int _outdoorIndex; - private int _phase; - - internal LandblockRetirementCursor( - PhysicsEngine destination, - PhysicsEngine authoritative, - uint landblockId, - bool withdraw) - { - _destinationEngine = destination; - _destinationCache = destination.DataCache - ?? throw new InvalidOperationException( - "Collision engine has no data cache."); - _destination = destination._collisionWorld.Capture(); - _authoritative = authoritative._collisionWorld.Capture(); - _canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; - _prefix = landblockId & 0xFFFF0000u; - _withdraw = withdraw; - _ownerSlots = _destination.ShadowOwnerSlots; - _ownerSlotLimit = _ownerSlots.Count; - if (!withdraw) - { - _authoritative.Landblocks.TryGetValue( - _canonical, - out _demotedLandblock); - _authoritative.Terrain.TryGetValue( - _prefix, - out _demotedTerrain); - } - } - - internal uint LandblockId => _canonical; - - internal LandblockRetirementStep Advance() - { - while (true) - { - switch (_phase) - { - case 0: - if (_ownerIndex < _ownerSlotLimit) - { - uint ownerId = _ownerSlots[_ownerIndex++]; - if (ownerId != 0u) - { - _destinationEngine.ShadowObjects - .RetireOwnerFromLandblock( - ownerId, - _canonical); - } - return Worked(); - } - _phase++; - continue; - case 1: - _cells ??= _destination.CellStruct.GetEnumerator(); - if (RemoveOneInPrefix(_cells, _destination.CellStruct)) - return Worked(); - DisposeEnumerator(ref _cells); - _phase++; - continue; - case 2: - _flatCells ??= _destination.FlatCellStruct.GetEnumerator(); - if (RemoveOneInPrefix( - _flatCells, - _destination.FlatCellStruct)) - return Worked(); - DisposeEnumerator(ref _flatCells); - _phase++; - continue; - case 3: - _flatEnvCells ??= _destination.FlatEnvCell.GetEnumerator(); - if (RemoveOneInPrefix( - _flatEnvCells, - _destination.FlatEnvCell)) - return Worked(); - DisposeEnumerator(ref _flatEnvCells); - _phase++; - continue; - case 4: - _buildings ??= _destination.Buildings.GetEnumerator(); - if (RemoveOneInPrefix( - _buildings, - _destination.Buildings)) - return Worked(); - DisposeEnumerator(ref _buildings); - _phase++; - continue; - case 5: - _envCells ??= _destination.EnvCells.GetEnumerator(); - if (RemoveOneInPrefix(_envCells, _destination.EnvCells)) - return Worked(); - DisposeEnumerator(ref _envCells); - _phase++; - continue; - case 6: - if (_outdoorIndex < 0x40) - { - uint id = _prefix | (uint)++_outdoorIndex; - _destination.ShadowCells.Remove(id); - if (_withdraw) - { - _destination.OutdoorCells.TryRemove(id, out _); - } - else if (_authoritative.OutdoorCells.TryGetValue( - id, - out ObjCell? outdoor)) - { - _destination.OutdoorCells[id] = outdoor; - } - return Worked(); - } - _phase++; - continue; - case 7: - if (_withdraw) - { - _destinationEngine._landblocks.Remove(_canonical); - _destinationEngine.RemoveLandblockSlot(_canonical); - _destination.Terrain.TryRemove(_prefix, out _); - } - else - { - if (_demotedLandblock is not null) - { - _destinationEngine._landblocks[_canonical] = - _demotedLandblock; - _destinationEngine.EnsureLandblockSlot(_canonical); - } - if (_demotedTerrain is not null) - _destination.Terrain[_prefix] = _demotedTerrain; - } - _phase++; - return Worked(); - case 8: - uint currentCellId = - _destinationCache.CellGraph.CurrCell?.Id ?? 0u; - if ((currentCellId & 0xFFFF0000u) == _prefix - && (_withdraw - || (currentCellId & 0xFFFFu) >= 0x0100u)) - { - _destinationCache.CellGraph.CurrCell = null; - } - _phase++; - return new LandblockRetirementStep( - Completed: true, - Worked: false); - default: - return new LandblockRetirementStep( - Completed: true, - Worked: false); - } - } - } - - private LandblockRetirementStep Worked() => new( - Completed: false, - Worked: true); - - private bool RemoveOneInPrefix( - IEnumerator> source, - IDictionary destination) - { - if (!source.MoveNext()) - return false; - uint id = source.Current.Key; - if ((id & 0xFFFF0000u) == _prefix) - { - destination.Remove(id); - _destination.ShadowCells.Remove(id); - } - return true; - } - - private static void DisposeEnumerator( - ref IEnumerator>? enumerator) - { - enumerator?.Dispose(); - enumerator = null; - } - - public void Dispose() - { - DisposeEnumerator(ref _cells); - DisposeEnumerator(ref _flatCells); - DisposeEnumerator(ref _flatEnvCells); - DisposeEnumerator(ref _buildings); - DisposeEnumerator(ref _envCells); - } - } - - internal readonly record struct LandblockRetirementStep( - bool Completed, - bool Worked); - - /// - /// Applies one already-committed landblock delta to a later off-side root. - /// Each advance mutates at most one dictionary leaf, one synthesized - /// outdoor cell, or one logical shadow owner. + /// Applies one sealed landblock delta to the active collision root. Each + /// advance mutates at most one dictionary leaf, one synthesized outdoor + /// cell, or yields one logical shadow owner to the committing caller. + /// drains it in one synchronous + /// update-thread call. /// internal sealed class LandblockReplacementApplyCursor : IDisposable { @@ -597,48 +431,88 @@ public sealed class PhysicsEngine switch (_phase) { case 0: - if (RemoveOne(_destination.CellStruct, data.CellIdsToRemove)) + if (_index < data.CellIdsToRemove.Count) + { + _destination.RemoveCellStruct( + data.CellIdsToRemove[_index++]); return Worked(); + } NextPhase(); continue; case 1: - if (InstallOne(_destination.CellStruct, data.Cells)) + if (_index < data.Cells.Count) + { + KeyValuePair pair = + data.Cells[_index++]; + _destination.SetCellStruct(pair.Key, pair.Value); return Worked(); + } NextPhase(); continue; case 2: - if (RemoveOne(_destination.FlatCellStruct, data.FlatCellIdsToRemove)) + if (_index < data.FlatCellIdsToRemove.Count) + { + _destination.RemoveFlatCellStruct( + data.FlatCellIdsToRemove[_index++]); return Worked(); + } NextPhase(); continue; case 3: - if (InstallOne(_destination.FlatCellStruct, data.FlatCells)) + if (_index < data.FlatCells.Count) + { + KeyValuePair + pair = data.FlatCells[_index++]; + _destination.SetFlatCellStruct(pair.Key, pair.Value); return Worked(); + } NextPhase(); continue; case 4: - if (RemoveOne(_destination.FlatEnvCell, data.FlatEnvCellIdsToRemove)) + if (_index < data.FlatEnvCellIdsToRemove.Count) + { + _destination.RemoveFlatEnvCell( + data.FlatEnvCellIdsToRemove[_index++]); return Worked(); + } NextPhase(); continue; case 5: - if (InstallOne(_destination.FlatEnvCell, data.FlatEnvCells)) + if (_index < data.FlatEnvCells.Count) + { + KeyValuePair pair = + data.FlatEnvCells[_index++]; + _destination.SetFlatEnvCell(pair.Key, pair.Value); return Worked(); + } NextPhase(); continue; case 6: - if (RemoveOne(_destination.Buildings, data.BuildingIdsToRemove)) + if (_index < data.BuildingIdsToRemove.Count) + { + _destination.RemoveBuilding( + data.BuildingIdsToRemove[_index++]); return Worked(); + } NextPhase(); continue; case 7: - if (InstallOne(_destination.Buildings, data.Buildings)) + if (_index < data.Buildings.Count) + { + KeyValuePair pair = + data.Buildings[_index++]; + _destination.SetBuilding(pair.Key, pair.Value); return Worked(); + } NextPhase(); continue; case 8: - if (RemoveOne(_destination.EnvCells, graph.EnvCellIdsToRemove)) + if (_index < graph.EnvCellIdsToRemove.Count) + { + _destination.RemoveEnvCell( + graph.EnvCellIdsToRemove[_index++]); return Worked(); + } NextPhase(); continue; case 9: @@ -689,8 +563,13 @@ public sealed class PhysicsEngine NextPhase(); continue; case 10: - if (InstallOne(_destination.EnvCells, graph.EnvCells)) + if (_index < graph.EnvCells.Count) + { + KeyValuePair pair = + graph.EnvCells[_index++]; + _destination.SetEnvCell(pair.Key, pair.Value); return Worked(); + } NextPhase(); continue; case 11: @@ -750,67 +629,30 @@ public sealed class PhysicsEngine _index = 0; } - private bool RemoveOne( - IDictionary destination, - IReadOnlyList ids) - { - if (_index >= ids.Count) - return false; - destination.Remove(ids[_index++]); - return true; - } - - private bool InstallOne( - IDictionary destination, - IReadOnlyList> entries) - { - if (_index >= entries.Count) - return false; - KeyValuePair pair = entries[_index++]; - destination[pair.Key] = pair.Value; - return true; - } - public void Dispose() { } } /// - /// Retained one-leaf-at-a-time materializer for an off-side collision - /// generation. Construction captures the current root reference only; no - /// resident dictionary is copied until . Runtime's - /// owner journal reconciles mutations that occur while this cursor walks. + /// O3 (2026-08-02): the empty off-side staging root for one landblock + /// collision generation. The pre-O3 builder materialized a whole-world + /// clone one leaf per host step; that clone existed only so the old + /// whole-root activation swap and the staged retained-owner refloods had + /// a complete world to stand on. With the per-landblock delta commit and + /// commit-time refloods against the live world (retail + /// CObjCell::init_objects 0x0052B420 → + /// CPhysicsObj::recalc_cross_cells 0x00515A30), admission is O(1) + /// and the staging root holds only the target landblock's authored + /// content. Immutable GfxObj/Setup catalogs still read through to the + /// active cache via the staging cache's read fallback. /// internal sealed class CollisionStagingBuilder : IDisposable { - private readonly PhysicsEngine _active; - private readonly CollisionWorldState _source; - private readonly CollisionWorldState _destination; - private readonly ShadowObjectRegistry _sourceShadows; - private readonly uint _targetPrefix; - private readonly HashSet _suppressedPrefixes = new(); - private readonly int _landblockSlotLimit; - private readonly int _ownerSlotLimit; - private IEnumerator>? _cells; - private IEnumerator>? _flatCells; - private IEnumerator>? _flatEnvCells; - private IEnumerator>? _buildings; - private IEnumerator>? _envCells; - private IEnumerator>? _terrain; - private IEnumerator>? _outdoorCells; - private int _landblockIndex; - private int _ownerIndex; - private int _phase; - internal CollisionStagingBuilder( PhysicsEngine active, - PhysicsDataCache activeCache, - uint targetPrefix) + PhysicsDataCache activeCache) { - _active = active; - _targetPrefix = targetPrefix; - _source = active._collisionWorld.Capture(); var stagingSlot = new CollisionWorldStateSlot(); StagingCache = activeCache.CreateEmptyCollisionStaging(stagingSlot); StagingEngine = new PhysicsEngine @@ -818,170 +660,13 @@ public sealed class PhysicsEngine DataCache = StagingCache, Objects = active.Objects, }; - _destination = stagingSlot.Capture(); - _sourceShadows = new ShadowObjectRegistry( - new CollisionWorldStateSlot(_source)); - _landblockSlotLimit = _source.LandblockSlots.Count; - _ownerSlotLimit = _source.ShadowOwnerSlots.Count; } internal PhysicsDataCache StagingCache { get; } internal PhysicsEngine StagingEngine { get; } - internal int WorkUnits { get; private set; } - internal bool Completed => _phase == 9; - - /// - /// Prevent a landblock retired after this cursor captured its source - /// root from being copied back into the draft by a later phase. - /// Already-copied leaves are retired by the caller before cloning - /// resumes; this tombstone covers every leaf not visited yet. - /// - internal void SuppressLandblock(uint landblockId) => - _suppressedPrefixes.Add(landblockId & 0xFFFF0000u); - - internal bool Advance() - { - switch (_phase) - { - case 0: - if (_landblockIndex < _landblockSlotLimit) - { - uint id = _source.LandblockSlots[_landblockIndex++]; - if (id != 0u - && (id & 0xFFFF0000u) != _targetPrefix - && !_suppressedPrefixes.Contains( - id & 0xFFFF0000u) - && _source.Landblocks.TryGetValue( - id, - out LandblockPhysics? landblock)) - { - StagingEngine.InstallLandblockClone(id, landblock); - } - WorkUnits++; - return false; - } - _phase++; - return false; - case 1: - _cells ??= _source.CellStruct.GetEnumerator(); - if (CopyOneOutsideTarget(_cells, _destination.CellStruct)) - return CountOne(); - DisposeEnumerator(ref _cells); - _phase++; - return false; - case 2: - _flatCells ??= _source.FlatCellStruct.GetEnumerator(); - if (CopyOneOutsideTarget(_flatCells, _destination.FlatCellStruct)) - return CountOne(); - DisposeEnumerator(ref _flatCells); - _phase++; - return false; - case 3: - _flatEnvCells ??= _source.FlatEnvCell.GetEnumerator(); - if (CopyOneOutsideTarget(_flatEnvCells, _destination.FlatEnvCell)) - return CountOne(); - DisposeEnumerator(ref _flatEnvCells); - _phase++; - return false; - case 4: - _buildings ??= _source.Buildings.GetEnumerator(); - if (CopyOneOutsideTarget(_buildings, _destination.Buildings)) - return CountOne(); - DisposeEnumerator(ref _buildings); - _phase++; - return false; - case 5: - _envCells ??= _source.EnvCells.GetEnumerator(); - if (CopyOneOutsideTarget(_envCells, _destination.EnvCells)) - return CountOne(); - DisposeEnumerator(ref _envCells); - _phase++; - return false; - case 6: - _terrain ??= _source.Terrain.GetEnumerator(); - if (CopyOneOutsideTarget(_terrain, _destination.Terrain)) - return CountOne(); - DisposeEnumerator(ref _terrain); - _phase++; - return false; - case 7: - _outdoorCells ??= _source.OutdoorCells.GetEnumerator(); - if (CopyOneOutsideTarget(_outdoorCells, _destination.OutdoorCells)) - return CountOne(); - DisposeEnumerator(ref _outdoorCells); - _phase++; - return false; - case 8: - if (_ownerIndex < _ownerSlotLimit) - { - uint ownerId = _source.ShadowOwnerSlots[_ownerIndex++]; - if (ownerId != 0u - && !_sourceShadows.IsStaticOwnerRootedIn( - ownerId, - _targetPrefix) - && !IsSuppressedStaticOwner(ownerId) - && !StagingEngine.ShadowObjects.HasLogicalOwner( - ownerId)) - { - StagingEngine.ShadowObjects.MirrorOwnerFrom( - _sourceShadows, - ownerId); - } - WorkUnits++; - return false; - } - uint currentCellId = _active.DataCache?.CellGraph.CurrCell?.Id ?? 0u; - StagingCache.CellGraph.CurrCell = - StagingCache.CellGraph.GetVisible(currentCellId); - _phase++; - return true; - default: - return true; - } - } - - private bool CountOne() - { - WorkUnits++; - return false; - } - - private bool CopyOneOutsideTarget( - IEnumerator> source, - IDictionary destination) - { - if (!source.MoveNext()) - return false; - KeyValuePair pair = source.Current; - uint prefix = pair.Key & 0xFFFF0000u; - if (prefix != _targetPrefix - && !_suppressedPrefixes.Contains(prefix)) - destination[pair.Key] = pair.Value; - return true; - } - - private bool IsSuppressedStaticOwner(uint ownerId) - => _sourceShadows.TryGetStaticOwnerRootPrefix( - ownerId, - out uint prefix) - && _suppressedPrefixes.Contains(prefix); - - private static void DisposeEnumerator( - ref IEnumerator>? enumerator) - { - enumerator?.Dispose(); - enumerator = null; - } public void Dispose() { - DisposeEnumerator(ref _cells); - DisposeEnumerator(ref _flatCells); - DisposeEnumerator(ref _flatEnvCells); - DisposeEnumerator(ref _buildings); - DisposeEnumerator(ref _envCells); - DisposeEnumerator(ref _terrain); - DisposeEnumerator(ref _outdoorCells); } } diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index e367b2c9..52a2006a 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -1983,6 +1983,78 @@ public sealed class ShadowObjectRegistry internal uint GetOwnerSlot(int index) => _ownerSlots[index]; + /// + /// O3 (2026-08-02): commit-time owner application for one landblock + /// delta, replacing the deleted staged whole-world reflood context. + /// Retail hydrates a cell and refloods the objects associated with it — + /// CObjCell::init_objects (0x0052B420) → + /// CPhysicsObj::recalc_cross_cells (0x00515A30); the per-landblock + /// streaming analogue is: adopt each staged owner's registration/shape + /// payload and recalculate its cross-cells against the live post-delta + /// world, retire the outgoing generation's authored statics that were not + /// re-authored, and re-run the flood for every retained live owner + /// touching the replaced landblock. + /// + internal void ApplyCommittedOwnerReplacement( + ShadowObjectRegistry stagingSource, + uint ownerId, + uint landblockId) + { + ArgumentNullException.ThrowIfNull(stagingSource); + if (stagingSource.HasLogicalOwner(ownerId)) + { + // Staged owner (authored target static, or an owner registered + // directly into the generation): adopt its payload, then + // recalc_cross_cells against the live world — the staged flood + // only saw the target-only staging root, so a seam footprint + // completes here. + MirrorOwnerFrom(stagingSource, ownerId); + RefloodOwnerForLandblock(ownerId, landblockId); + return; + } + if (IsStaticOwnerRootedIn(ownerId, landblockId)) + { + // Outgoing generation's authored static, not re-authored by the + // replacement: it ends with its landblock (same lifetime rule as + // RetireOwnerFromLandblock's static branch). + DeregisterCore(ownerId, publishMutation: false); + RemoveOwnerPrefixMembership(ownerId); + _ownerVersions.Remove(ownerId); + AdvanceMutationRevision(); + return; + } + // Retained live owner touching the replaced landblock: recalculate its + // cross-cells against the new topology. Suspended or since-removed + // owners no-op inside the reflood. + RefloodOwnerForLandblock(ownerId, landblockId); + } + + /// + /// O3 (2026-08-02): retail CObjCell::init_objects refloods every + /// object associated with the hydrated cell at hydration time. Owners + /// that became associated with the replaced landblock after the sealed + /// capture (a mover entering the prefix mid-publication) are present in + /// the live prefix-owner slots but absent from the sealed owner list; + /// recalculate their cross-cells against the just-installed topology. + /// + internal void RefloodPrefixOwnersAfterReplacement( + uint landblockId, + IReadOnlyList sealedOwnerIds) + { + uint prefix = landblockId & 0xFFFF0000u; + if (!_prefixOwnerSlots.TryGetValue(prefix, out List? slots)) + return; + var applied = new HashSet(sealedOwnerIds); + int limit = slots.Count; + for (int index = 0; index < limit; index++) + { + uint ownerId = slots[index]; + if (ownerId == 0u || !applied.Add(ownerId)) + continue; + RefloodOwnerForLandblock(ownerId, landblockId); + } + } + /// /// Refreshes one staging owner from the exact active payload, then floods /// it against the staging generation's complete cell graph. The returned @@ -2321,14 +2393,16 @@ public sealed class ShadowObjectRegistry if (_stagingSlotIndex < _stagingSlotLimit) { uint ownerId = _stagingSlots![_stagingSlotIndex++]; - if (_staging._entityReg.TryGetValue( - ownerId, - out RegistrationRecord? registration) - && registration.IsStatic - && (registration.SeedCellId & 0xFFFF0000u) == _prefix) - { + // O2 (2026-08-02): every staged logical owner in the + // target's prefix slots — authored statics AND owners + // registered directly into the staging generation — + // must reach the active world through the delta + // commit's owner installs; the old whole-root transfer + // carried them implicitly. Mirrored owners identical + // to their active state reinstall in place, so the + // wider filter stays exact and prefix-scoped. + if (_staging._entityReg.ContainsKey(ownerId)) AddOwner(ownerId); - } WorkUnits++; return false; } diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index 7b4f05b7..cfdbb70a 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -46,7 +46,8 @@ public sealed class CellGraph public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId); - public void Add(EnvCell cell) => _envCells.TryAdd(cell.Id, cell); + public void Add(EnvCell cell) => + _collisionWorld.Current.TryAddEnvCell(cell.Id, cell); /// Any id in the cell's landblock; masked to (id & 0xFFFF0000). public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin) @@ -96,8 +97,27 @@ public sealed class CellGraph _terrain.TryRemove(lb, out _); for (uint low = 1u; low <= 0x40u; low++) _outdoorCells.TryRemove(lb | low, out _); - foreach (var id in new List(_envCells.Keys)) - if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _); + RemoveEnvCellPrefixKeys(lb); + } + + /// + /// O1: retire one prefix's EnvCells through the installed-key ledger — + /// O(prefix keys), never a whole-map scan. Removal tombstones the + /// captured slot list, so index iteration stays exact. + /// + private void RemoveEnvCellPrefixKeys(uint prefix) + { + CollisionWorldState world = _collisionWorld.Current; + List? slots = world.EnvCellKeys.SlotsForPrefix(prefix); + if (slots is null) + return; + int limit = slots.Count; + for (int index = 0; index < limit; index++) + { + uint id = slots[index]; + if (id != 0u) + world.RemoveEnvCell(id); + } } /// @@ -113,8 +133,7 @@ public sealed class CellGraph { CurrCell = null; } - foreach (var id in new List(_envCells.Keys)) - if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _); + RemoveEnvCellPrefixKeys(lb); } /// The universal id->cell resolver (retail CObjCell::GetVisible). @@ -170,7 +189,10 @@ public sealed class CellGraph private readonly List> _envCells = new(); private readonly HashSet _stagingIds = new(); private readonly List _removeIds = new(); - private IEnumerator>? _enumerator; + private List? _keySlots; + private int _keySlotLimit; + private bool _keySlotsCaptured; + private int _cursor; private int _phase; internal LandblockReplacementBuilder( @@ -181,44 +203,48 @@ public sealed class CellGraph _active = active; _staging = staging; _prefix = landblockId & 0xFFFF0000u; - _enumerator = staging._envCells.GetEnumerator(); } internal bool Advance() { if (_phase == 0) { - if (_enumerator!.MoveNext()) + // O1: enumerate the staging root's installed target-prefix + // EnvCell keys via the ledger instead of scanning the map. + if (TryTakeNextPrefixKey( + _staging._collisionWorld.Current.EnvCellKeys, + out uint stagingId)) { - KeyValuePair pair = _enumerator.Current; - if ((pair.Key & 0xFFFF0000u) == _prefix - && (pair.Key & 0xFFFFu) >= 0x0100u) + if ((stagingId & 0xFFFFu) >= 0x0100u + && _staging._envCells.TryGetValue( + stagingId, + out EnvCell? cell)) { - _envCells.Add(pair); - _stagingIds.Add(pair.Key); + _envCells.Add( + new KeyValuePair(stagingId, cell)); + _stagingIds.Add(stagingId); } return false; } - _enumerator.Dispose(); - _enumerator = _active._envCells.GetEnumerator(); _phase = 1; return false; } if (_phase == 1) { - if (_enumerator!.MoveNext()) + // O1: active-side removal capture through the ledger — no + // cross-frame live enumerator over the active map. + if (TryTakeNextPrefixKey( + _active._collisionWorld.Current.EnvCellKeys, + out uint activeId)) { - uint id = _enumerator.Current.Key; - if ((id & 0xFFFF0000u) == _prefix - && (id & 0xFFFFu) >= 0x0100u - && !_stagingIds.Contains(id)) + if ((activeId & 0xFFFFu) >= 0x0100u + && !_stagingIds.Contains(activeId) + && _active._envCells.ContainsKey(activeId)) { - _removeIds.Add(id); + _removeIds.Add(activeId); } return false; } - _enumerator.Dispose(); - _enumerator = null; bool hasTerrain = _staging._terrain.TryGetValue( _prefix, out var terrain); @@ -233,12 +259,36 @@ public sealed class CellGraph return true; } + private bool TryTakeNextPrefixKey(PrefixKeyIndex ledger, out uint key) + { + if (!_keySlotsCaptured) + { + _keySlots = ledger.SlotsForPrefix(_prefix); + _keySlotLimit = _keySlots?.Count ?? 0; + _keySlotsCaptured = true; + _cursor = 0; + } + while (_cursor < _keySlotLimit) + { + uint candidate = _keySlots![_cursor++]; + if (candidate != 0u) + { + key = candidate; + return true; + } + } + key = 0u; + _keySlots = null; + _keySlotsCaptured = false; + return false; + } + internal PreparedCellGraphLandblock? Prepared { get; private set; } public void Dispose() { - _enumerator?.Dispose(); - _enumerator = null; + _keySlots = null; + _keySlotsCaptured = false; } } } diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index c6fbc561..b5ee2bf8 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -176,186 +176,38 @@ public readonly record struct RuntimeCollisionGenerationCommitted( ulong Generation, bool Ready); -/// -/// One process-local, versioned owner-mutation stream shared by every -/// collision draft. A live mutation is appended once; drafts consume only the -/// newest still-relevant record for each owner at their own metered cursor. -/// -internal sealed class CollisionOwnerMutationJournal -{ - private readonly List _entries = new(capacity: 256); - private readonly Dictionary _entryIndexByOwner = new(); - private long _nextSequence = 1; - private long _compactionThreshold; - private int _compactionIndex; - - internal long NextSequence => _nextSequence; - internal int Count => _entries.Count; - internal int ActiveCount => _entryIndexByOwner.Count; - internal bool HasPendingCompaction => _compactionThreshold != 0L; - - internal MutationRecord Record( - uint ownerId, - ulong ownerVersion, - long latestPreparationStartSequence) - { - long sequence = _nextSequence; - _nextSequence = checked(_nextSequence + 1L); - if (_entryIndexByOwner.TryGetValue(ownerId, out int index)) - { - if (_entries[index].Sequence >= latestPreparationStartSequence) - { - _entries[index] = new Entry(sequence, ownerId, ownerVersion); - return new MutationRecord(sequence, index); - } - // At least one newer draft captured the live root after this slot. - // Leave a tombstone for older cursors and append one new coalescing - // slot that every draft created since then can observe. - _entries[index] = default; - } - _entryIndexByOwner[ownerId] = _entries.Count; - _entries.Add(new Entry(sequence, ownerId, ownerVersion)); - return new MutationRecord( - sequence, - _entries.Count - 1); - } - - internal Entry Get(int index) => _entries[index]; - - internal bool TryGet( - uint ownerId, - out Entry entry, - out int slotIndex) - { - if (_entryIndexByOwner.TryGetValue(ownerId, out slotIndex)) - { - entry = _entries[slotIndex]; - return true; - } - entry = default; - slotIndex = -1; - return false; - } - - internal void RequestCompactionBefore(long sequence) - { - if (sequence <= _compactionThreshold) - return; - _compactionThreshold = sequence; - _compactionIndex = 0; - } - - internal bool AdvanceCompaction() - { - if (!HasPendingCompaction) - return false; - if (_compactionIndex < _entries.Count) - { - int index = _compactionIndex++; - Entry entry = _entries[index]; - if (entry.OwnerId != 0u - && entry.Sequence < _compactionThreshold - && _entryIndexByOwner.TryGetValue( - entry.OwnerId, - out int currentIndex) - && currentIndex == index) - { - _entryIndexByOwner.Remove(entry.OwnerId); - _entries[index] = default; - } - return true; - } - if (_entries.Count != 0 && _entries[^1].OwnerId == 0u) - { - _entries.RemoveAt(_entries.Count - 1); - return true; - } - _compactionThreshold = 0L; - _compactionIndex = 0; - return false; - } - - internal void Clear() - { - _entries.Clear(); - _entryIndexByOwner.Clear(); - _compactionThreshold = 0L; - _compactionIndex = 0; - } - - internal readonly record struct Entry( - long Sequence, - uint OwnerId, - ulong OwnerVersion); - - internal readonly record struct MutationRecord( - long Sequence, - int SlotIndex); -} - /// /// One off-side collision generation. It owns a private cache, cell graph, -/// engine, and shadow registry materialized incrementally from an O(1) root -/// snapshot. Hosts may populate it incrementally, but only Runtime can activate -/// it. +/// engine, and shadow registry holding ONLY the target landblock's authored +/// content — O3 (2026-08-02): admission no longer materializes a clone of the +/// resident world. The commit installs the sealed per-landblock delta into +/// the active root and recalculates owner cross-cells against the live world +/// (retail CObjCell::init_objects 0x0052B420 → +/// CPhysicsObj::recalc_cross_cells 0x00515A30), so no owner-mutation +/// journal, peer rebase, or draft retirement machinery exists. Hosts may +/// populate the generation incrementally, but only Runtime can activate it. /// internal sealed class PreparedLandblockCollisionGeneration : IDisposable { internal const int MaxConcurrentCollisionPreparations = 256; private readonly RuntimePhysicsState _owner; private readonly RuntimeCollisionAdmission _admission; - private readonly Dictionary _retainedOwnerVersions = new(); private readonly List _retainedOwnerIds = new(); private readonly HashSet _retainedOwnerSet = new(); - private readonly HashSet _armedOwners = new(); - private readonly HashSet _pendingOwners = new(); - private readonly Queue _pendingOwnerQueue = new(); - private readonly PhysicsEngine.PreparedPhysicsEngineLandblock?[] - _pendingCommittedRebases = - new PhysicsEngine.PreparedPhysicsEngineLandblock?[ - MaxConcurrentCollisionPreparations]; - private int _pendingCommittedRebaseHead; - private int _pendingCommittedRebaseCount; - private PhysicsEngine.LandblockReplacementApplyCursor? - _activeCommittedRebase; - private readonly Dictionary _retiredLandblocks = new(); - private readonly HashSet _pendingRetirementSet = new(); - private readonly Queue _pendingRetirements = new(); - private PhysicsEngine.LandblockRetirementCursor? _activeRetirement; - private readonly HashSet _pendingCloneOwnerMutations = new(); - private readonly Queue _pendingCloneOwnerMutationQueue = new(); - private readonly HashSet _pendingRoutedOwnerMutations = new(); - private readonly Queue _pendingRoutedOwnerMutationQueue = new(); - private PhysicsEngine.CollisionStagingBuilder? _stagingBuilder; private ShadowObjectRegistry.RetainedRefloodOwnerScan? _retainedOwnerScan; private PhysicsEngine.LandblockReplacementBuilder? _sealBuilder; private PhysicsEngine.PreparedPhysicsEngineLandblock? _sealedReplacement; - private readonly CollisionOwnerMutationJournal _ownerMutationJournal; - private readonly long _ownerMutationStartSequence; - private readonly Dictionary _observedOwnerMutationSequences = new(); - private readonly HashSet _subscribedOwners = new(); - private readonly HashSet _exactSubscribedOwners = new(); - private long _ownerMutationScanEpoch; - private int _ownerMutationScanIndex; - private bool _sealedExactWriteThrough; private bool _disposed; internal PreparedLandblockCollisionGeneration( RuntimePhysicsState owner, RuntimeCollisionAdmission admission, PhysicsEngine.CollisionStagingBuilder stagingBuilder, - CollisionOwnerMutationJournal ownerMutationJournal, long sequence) { _owner = owner; _admission = admission; - _stagingBuilder = stagingBuilder - ?? throw new ArgumentNullException(nameof(stagingBuilder)); - _ownerMutationJournal = ownerMutationJournal - ?? throw new ArgumentNullException(nameof(ownerMutationJournal)); - _ownerMutationStartSequence = ownerMutationJournal.NextSequence; - _ownerMutationScanEpoch = ownerMutationJournal.NextSequence; - _ownerMutationScanIndex = ownerMutationJournal.Count; + ArgumentNullException.ThrowIfNull(stagingBuilder); DataCache = stagingBuilder.StagingCache; Engine = stagingBuilder.StagingEngine; Sequence = sequence; @@ -364,358 +216,25 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal PhysicsDataCache DataCache { get; } internal PhysicsEngine Engine { get; } internal long Sequence { get; } - internal long OwnerMutationStartSequence => _ownerMutationStartSequence; internal uint[] GfxObjectIds { get; private set; } = Array.Empty(); internal uint[] SetupIds { get; private set; } = Array.Empty(); - internal IReadOnlyDictionary RetainedOwnerVersions => - _retainedOwnerVersions; internal bool IsDisposed => _disposed; internal bool RetainedOwnerCaptureComplete { get; private set; } - internal bool StagingCloneComplete { get; private set; } internal bool IsSealed => _sealedReplacement is not null; - internal bool IsReadyForActivation => - _sealedReplacement is not null - && _pendingOwners.Count == 0 - && _pendingRoutedOwnerMutations.Count == 0; - - internal bool HasPendingCommittedRebase => - _activeCommittedRebase is not null - || _pendingCommittedRebaseCount != 0; - - internal bool HasPendingRetirement => - _activeRetirement is not null - || _pendingRetirements.Count != 0; - - internal bool IsOwnerMutationReconciliationCurrent => - _ownerMutationScanEpoch == _ownerMutationJournal.NextSequence - && _ownerMutationScanIndex >= _ownerMutationJournal.Count; - - internal RuntimeCollisionJournalStep AdvanceOwnerMutationReconciliation() - { - EnsureUsable(); - if (!StagingCloneComplete) - { - throw new InvalidOperationException( - "Collision owner mutations cannot reconcile before staging materialization."); - } - long epoch = _ownerMutationJournal.NextSequence; - // Already-visited slots are subscribed and receive either exact - // target write-through or a coalesced metered replay. - // Continue from the current cursor when the journal epoch advances; - // restarting at zero would let one continuously-moving unrelated - // owner starve every later slot. - _ownerMutationScanEpoch = epoch; - if (_ownerMutationScanIndex < _ownerMutationJournal.Count) - { - CollisionOwnerMutationJournal.Entry entry = - _ownerMutationJournal.Get(_ownerMutationScanIndex++); - if (entry.OwnerId == 0u) - { - return new RuntimeCollisionJournalStep( - Completed: _ownerMutationScanIndex - >= _ownerMutationJournal.Count, - Worked: true); - } - - // Every visited slot gets a cheap notification subscription so a - // later same-prefix mutation can enqueue one metered replay. Only - // target-relevant owners are promoted to exact write-through. - SubscribeOwner(entry.OwnerId, exact: false); - - if (entry.Sequence < _ownerMutationStartSequence - || (_observedOwnerMutationSequences.TryGetValue( - entry.OwnerId, - out long observed) - && observed >= entry.Sequence)) - { - return new RuntimeCollisionJournalStep( - Completed: _ownerMutationScanIndex - >= _ownerMutationJournal.Count, - Worked: true); - } - _observedOwnerMutationSequences[entry.OwnerId] = entry.Sequence; - if (ObserveOwnerMutation(entry.OwnerId)) - SubscribeOwner(entry.OwnerId, exact: true); - return new RuntimeCollisionJournalStep( - Completed: _ownerMutationScanIndex - >= _ownerMutationJournal.Count, - Worked: true); - } - return new RuntimeCollisionJournalStep( - Completed: true, - Worked: false); - } - - internal void ObserveSubscribedOwnerMutation( - uint ownerId, - long sequence, - long epochBefore, - int countBefore) - { - if (_disposed) - return; - bool wasCurrent = _ownerMutationScanEpoch == epochBefore - && _ownerMutationScanIndex >= countBefore; - _observedOwnerMutationSequences[ownerId] = sequence; - if (!_sealedExactWriteThrough - && !_exactSubscribedOwners.Contains(ownerId)) - { - if (_pendingRoutedOwnerMutations.Add(ownerId)) - _pendingRoutedOwnerMutationQueue.Enqueue(ownerId); - return; - } - _ = ObserveOwnerMutation(ownerId); - if (wasCurrent) - { - _ownerMutationScanEpoch = _ownerMutationJournal.NextSequence; - _ownerMutationScanIndex = _ownerMutationJournal.Count; - } - } - - internal void ObserveRoutedOwnerMembershipMutation(uint ownerId) - { - if (_disposed || _exactSubscribedOwners.Contains(ownerId)) - return; - if (_sealedExactWriteThrough) - { - _ = ObserveOwnerMutation(ownerId); - SubscribeOwner(ownerId, exact: true); - return; - } - if (_pendingRoutedOwnerMutations.Add(ownerId)) - _pendingRoutedOwnerMutationQueue.Enqueue(ownerId); - } - - internal RuntimeCollisionJournalStep AdvanceRoutedOwnerMutation() - { - EnsureUsable(); - while (_pendingRoutedOwnerMutationQueue.Count != 0) - { - uint ownerId = _pendingRoutedOwnerMutationQueue.Dequeue(); - if (!_pendingRoutedOwnerMutations.Remove(ownerId)) - continue; - if (ObserveOwnerMutation(ownerId)) - SubscribeOwner(ownerId, exact: true); - if (_ownerMutationJournal.TryGet( - ownerId, - out CollisionOwnerMutationJournal.Entry entry, - out int slotIndex)) - { - _observedOwnerMutationSequences[ownerId] = entry.Sequence; - if (slotIndex == _ownerMutationScanIndex) - { - _ownerMutationScanIndex++; - _ownerMutationScanEpoch = - _ownerMutationJournal.NextSequence; - } - else if (_ownerMutationScanIndex - >= _ownerMutationJournal.Count) - { - // This slot was already behind the cursor. The routed - // prefix transition is the only previously-unsubscribed - // mutation that can make it target-relevant, so observing - // its latest coalesced entry closes the current epoch. - _ownerMutationScanEpoch = - _ownerMutationJournal.NextSequence; - } - } - return new RuntimeCollisionJournalStep( - Completed: _pendingRoutedOwnerMutations.Count == 0, - Worked: true); - } - return new RuntimeCollisionJournalStep( - Completed: true, - Worked: false); - } + internal bool IsReadyForActivation => _sealedReplacement is not null; + /// + /// O3: admission captures the O(1) empty staging root at construction; + /// there is no resident-world materialization to advance. + /// internal RuntimeCollisionPreparationStep AdvanceStagingClone() { EnsureUsable(); - if (!StagingCloneComplete) - { - int before = _stagingBuilder!.WorkUnits; - if (!_stagingBuilder.Advance()) - { - return new RuntimeCollisionPreparationStep( - Completed: false, - WorkUnits: _stagingBuilder.WorkUnits - before); - } - _stagingBuilder.Dispose(); - _stagingBuilder = null; - StagingCloneComplete = true; - } - - while (_pendingCloneOwnerMutationQueue.Count != 0) - { - uint ownerId = _pendingCloneOwnerMutationQueue.Dequeue(); - if (!_pendingCloneOwnerMutations.Remove(ownerId)) - continue; - ObserveOwnerMutation(ownerId); - return new RuntimeCollisionPreparationStep( - Completed: false, - WorkUnits: 1); - } return new RuntimeCollisionPreparationStep( Completed: true, WorkUnits: 0); } - internal void EnqueueCommittedRebase( - PhysicsEngine.PreparedPhysicsEngineLandblock replacement) - { - EnsureUsable(); - if (_pendingCommittedRebaseCount - == _pendingCommittedRebases.Length) - { - throw new InvalidOperationException( - "Collision preparation rebase capacity was exceeded."); - } - int tail = (_pendingCommittedRebaseHead - + _pendingCommittedRebaseCount) - % _pendingCommittedRebases.Length; - _pendingCommittedRebases[tail] = replacement; - _pendingCommittedRebaseCount++; - } - - internal void RecordDemotion(uint landblockId) - { - EnsureUsable(); - uint canonical = CanonicalLandblock(landblockId); - InvalidateCommittedRebases(canonical); - _stagingBuilder?.SuppressLandblock(canonical); - EnqueueRetirement(canonical, withdraw: false); - } - - internal void RecordWithdrawal(uint landblockId) - { - EnsureUsable(); - uint canonical = CanonicalLandblock(landblockId); - InvalidateCommittedRebases(canonical); - _stagingBuilder?.SuppressLandblock(canonical); - EnqueueRetirement(canonical, withdraw: true); - } - - internal PhysicsEngine.LandblockRetirementStep AdvanceRetirement() - { - EnsureUsable(); - if (_activeRetirement is null) - { - uint canonical = DequeueRetirement(); - _activeRetirement = Engine.CreateLandblockRetirementCursor( - _owner.Engine, - canonical, - _retiredLandblocks[canonical]); - } - PhysicsEngine.LandblockRetirementStep step = - _activeRetirement.Advance(); - if (step.Completed) - { - _activeRetirement.Dispose(); - _activeRetirement = null; - } - return step; - } - - internal PhysicsEngine.LandblockReplacementApplyStep - AdvanceCommittedRebase() - { - EnsureUsable(); - if (_activeCommittedRebase is null) - { - PhysicsEngine.PreparedPhysicsEngineLandblock replacement = - DequeueCommittedRebase(); - if (_retiredLandblocks.ContainsKey(replacement.LandblockId)) - { - return new PhysicsEngine.LandblockReplacementApplyStep( - Completed: false, - Worked: false, - HasOwner: false, - OwnerId: 0u); - } - _activeCommittedRebase = - Engine.CreateLandblockReplacementApplyCursor(replacement); - } - PhysicsEngine.LandblockReplacementApplyStep step = - _activeCommittedRebase.Advance(); - if (step.HasOwner) - ForceOwnerReflood(step.OwnerId); - if (step.Completed) - { - _activeCommittedRebase.Dispose(); - _activeCommittedRebase = null; - } - return step; - } - - private PhysicsEngine.PreparedPhysicsEngineLandblock - DequeueCommittedRebase() - { - PhysicsEngine.PreparedPhysicsEngineLandblock replacement = - _pendingCommittedRebases[_pendingCommittedRebaseHead] - ?? throw new InvalidOperationException( - "Collision rebase queue contained an empty slot."); - _pendingCommittedRebases[_pendingCommittedRebaseHead] = null; - _pendingCommittedRebaseHead = (_pendingCommittedRebaseHead + 1) - % _pendingCommittedRebases.Length; - _pendingCommittedRebaseCount--; - return replacement; - } - - private void ClearCommittedRebases() - { - while (_pendingCommittedRebaseCount != 0) - _ = DequeueCommittedRebase(); - _pendingCommittedRebaseHead = 0; - } - - private void InvalidateCommittedRebases(uint landblockId) - { - uint canonical = CanonicalLandblock(landblockId); - if (_activeCommittedRebase?.LandblockId == canonical) - { - _activeCommittedRebase.Dispose(); - _activeCommittedRebase = null; - } - - // Queued entries are left in their fixed ring and skipped one per - // later seal step. This keeps retirement admission O(1). - } - - private void EnqueueRetirement(uint canonical, bool withdraw) - { - bool changed = !_retiredLandblocks.TryGetValue( - canonical, - out bool previousWithdraw) - || (withdraw && !previousWithdraw); - _retiredLandblocks[canonical] = previousWithdraw || withdraw; - if (!changed) - return; - if (_activeRetirement?.LandblockId == canonical) - { - _activeRetirement.Dispose(); - _activeRetirement = null; - } - if (!_pendingRetirementSet.Add(canonical)) - return; - _pendingRetirements.Enqueue(canonical); - } - - private uint DequeueRetirement() - { - uint canonical = _pendingRetirements.Dequeue(); - _pendingRetirementSet.Remove(canonical); - return canonical; - } - - private void ClearRetirements() - { - _pendingRetirements.Clear(); - _activeRetirement?.Dispose(); - _activeRetirement = null; - _retiredLandblocks.Clear(); - _pendingRetirementSet.Clear(); - } - internal bool Matches( RuntimePhysicsState owner, RuntimeCollisionAdmission admission) => @@ -725,92 +244,22 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal void SetAssetClosure(uint[] gfxObjectIds, uint[] setupIds) { EnsureUsable(); - GfxObjectIds = gfxObjectIds ?? throw new ArgumentNullException(nameof(gfxObjectIds)); + GfxObjectIds = gfxObjectIds + ?? throw new ArgumentNullException(nameof(gfxObjectIds)); SetupIds = setupIds ?? throw new ArgumentNullException(nameof(setupIds)); } + /// + /// Arms one retained owner into the sealed owner list. The owner's + /// cross-cells are recalculated at commit against the live post-delta + /// world (retail recalc_cross_cells), so no staged mirror or version + /// bookkeeping exists. + /// internal void RefreshRetainedOwner(uint ownerId) { EnsureUsable(); EnsureRetainedOwner(ownerId); - _ = Engine.ShadowObjects.RefreshRetainedOwnerFrom( - _owner.Engine.ShadowObjects, - ownerId, - _admission.LandblockId, - out ulong version); - _retainedOwnerVersions[ownerId] = version; - _pendingOwners.Remove(ownerId); - _armedOwners.Add(ownerId); _sealBuilder?.RefreshRetainedOwner(ownerId); - SubscribeOwner(ownerId); - } - - internal bool ObserveOwnerMutation(uint ownerId) - { - if (_disposed) - return false; - if (!StagingCloneComplete) - { - if (_pendingCloneOwnerMutations.Add(ownerId)) - _pendingCloneOwnerMutationQueue.Enqueue(ownerId); - return false; - } - if (_owner.Engine.ShadowObjects.IsStaticOwnerRootedIn( - ownerId, - _admission.LandblockId) - || Engine.ShadowObjects.IsStaticOwnerRootedIn( - ownerId, - _admission.LandblockId)) - { - // The staged build is authoritative for target-root statics. Never - // mirror the outgoing generation back over an omitted/replaced - // authored owner merely because its old live state changed. - return false; - } - bool relevantBefore = Engine.ShadowObjects.OwnerTouchesLandblock( - ownerId, - _admission.LandblockId); - Engine.ShadowObjects.MirrorOwnerFrom( - _owner.Engine.ShadowObjects, - ownerId); - bool relevant = _armedOwners.Contains(ownerId) - || relevantBefore - || _owner.Engine.ShadowObjects.OwnerTouchesLandblock( - ownerId, - _admission.LandblockId); - if (!relevant) - return false; - - EnsureRetainedOwner(ownerId); - RefreshRetainedOwner(ownerId); - return true; - } - - private void ForceOwnerReflood(uint ownerId) - { - if (_disposed) - return; - if (!StagingCloneComplete) - { - if (_pendingCloneOwnerMutations.Add(ownerId)) - _pendingCloneOwnerMutationQueue.Enqueue(ownerId); - return; - } - if (_owner.Engine.ShadowObjects.IsStaticOwnerRootedIn( - ownerId, - _admission.LandblockId) - || Engine.ShadowObjects.IsStaticOwnerRootedIn( - ownerId, - _admission.LandblockId)) - { - return; - } - - Engine.ShadowObjects.MirrorOwnerFrom( - _owner.Engine.ShadowObjects, - ownerId); - EnsureRetainedOwner(ownerId); - RefreshRetainedOwner(ownerId); } internal RuntimeCollisionOwnerCaptureStep AdvanceRetainedOwnerCapture() @@ -860,20 +309,10 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal void ResetRetainedOwnerCapture() { EnsureUsable(); - ClearOwnerSubscriptions(); _retainedOwnerScan?.Dispose(); _retainedOwnerScan = null; _retainedOwnerIds.Clear(); _retainedOwnerSet.Clear(); - _retainedOwnerVersions.Clear(); - _armedOwners.Clear(); - _pendingOwners.Clear(); - _pendingOwnerQueue.Clear(); - _pendingCloneOwnerMutations.Clear(); - _pendingCloneOwnerMutationQueue.Clear(); - _pendingRoutedOwnerMutations.Clear(); - _pendingRoutedOwnerMutationQueue.Clear(); - _sealedExactWriteThrough = false; RetainedOwnerCaptureComplete = false; _sealedReplacement = null; _sealBuilder?.Dispose(); @@ -890,29 +329,6 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable Restarted: true, WorkUnits: 0); } - while (_pendingOwnerQueue.Count != 0) - { - uint ownerId = _pendingOwnerQueue.Dequeue(); - if (!_pendingOwners.Remove(ownerId)) - continue; - RefreshRetainedOwner(ownerId); - if (_sealedReplacement is not null) - { - return new RuntimeCollisionSealStep( - Completed: _pendingOwners.Count == 0, - Restarted: false, - WorkUnits: 1); - } - return new RuntimeCollisionSealStep( - Completed: false, - Restarted: false, - WorkUnits: 1); - } - if (_retainedOwnerVersions.Count != _retainedOwnerIds.Count) - { - throw new InvalidOperationException( - "Every retained collision owner must refresh before sealing."); - } _sealBuilder ??= _owner.Engine.CreateLandblockReplacementBuilder( Engine, @@ -939,11 +355,6 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable workUnits); } _sealedReplacement = _sealBuilder.Prepared; - // From this point through the same-call activation, every already- - // observed owner mutation writes through exactly. The finite dirty - // queue accumulated during topology construction can now drain even - // when several unrelated owners keep moving every update tick. - _sealedExactWriteThrough = true; return new RuntimeCollisionSealStep( Completed: true, Restarted: false, @@ -961,27 +372,10 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal void MarkCommitted() { EnsureUsable(); - _sealedExactWriteThrough = false; _sealBuilder = null; _sealedReplacement = null; _retainedOwnerIds.Clear(); _retainedOwnerSet.Clear(); - _retainedOwnerVersions.Clear(); - _armedOwners.Clear(); - _pendingOwners.Clear(); - _pendingOwnerQueue.Clear(); - ClearCommittedRebases(); - ClearRetirements(); - _activeCommittedRebase?.Dispose(); - _activeCommittedRebase = null; - _pendingCloneOwnerMutations.Clear(); - _pendingCloneOwnerMutationQueue.Clear(); - _pendingRoutedOwnerMutations.Clear(); - _pendingRoutedOwnerMutationQueue.Clear(); - _observedOwnerMutationSequences.Clear(); - ClearOwnerSubscriptions(); - _stagingBuilder?.Dispose(); - _stagingBuilder = null; _disposed = true; } @@ -994,26 +388,9 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable _retainedOwnerScan = null; _retainedOwnerIds.Clear(); _retainedOwnerSet.Clear(); - _retainedOwnerVersions.Clear(); - _armedOwners.Clear(); - _pendingOwners.Clear(); - _pendingOwnerQueue.Clear(); - ClearCommittedRebases(); - ClearRetirements(); - _activeCommittedRebase?.Dispose(); - _activeCommittedRebase = null; - _pendingCloneOwnerMutations.Clear(); - _pendingCloneOwnerMutationQueue.Clear(); - _pendingRoutedOwnerMutations.Clear(); - _pendingRoutedOwnerMutationQueue.Clear(); - _observedOwnerMutationSequences.Clear(); - ClearOwnerSubscriptions(); - _stagingBuilder?.Dispose(); - _stagingBuilder = null; _sealBuilder?.Dispose(); _sealBuilder = null; _sealedReplacement = null; - _sealedExactWriteThrough = false; _disposed = true; } @@ -1023,25 +400,6 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable _retainedOwnerIds.Add(ownerId); } - private void SubscribeOwner(uint ownerId, bool exact = true) - { - if (exact) - _exactSubscribedOwners.Add(ownerId); - if (_subscribedOwners.Add(ownerId)) - _owner.SubscribeCollisionOwner(ownerId, this); - } - - private void ClearOwnerSubscriptions() - { - foreach (uint ownerId in _subscribedOwners) - _owner.UnsubscribeCollisionOwner(ownerId, this); - _subscribedOwners.Clear(); - _exactSubscribedOwners.Clear(); - } - - private static uint CanonicalLandblock(uint value) => - (value & 0xFFFF0000u) | 0xFFFFu; - private void EnsureUsable() { if (_disposed) @@ -1059,10 +417,6 @@ internal readonly record struct RuntimeCollisionPreparationStep( bool Completed, int WorkUnits); -internal readonly record struct RuntimeCollisionJournalStep( - bool Completed, - bool Worked); - internal readonly record struct RuntimeCollisionSealStep( bool Completed, bool Restarted, @@ -1091,13 +445,8 @@ public sealed class RuntimePhysicsState : IDisposable _preparedCollisionGenerations = new(); private readonly Dictionary _collisionPrefixMutations = new(); - private readonly CollisionOwnerMutationJournal _collisionOwnerJournal = new(); - private readonly Dictionary> - _collisionOwnerSubscribers = new(); private int _collisionMutationThreadId; - private bool _suppressCollisionOwnerJournal; private long _nextCollisionPreparationSequence; - private long _latestCollisionPreparationStartSequence; private ulong _collisionWorldAuthority = 1UL; private readonly List> _collisionGenerationCommittedObservers = new(); @@ -1133,9 +482,6 @@ public sealed class RuntimePhysicsState : IDisposable { DataCache = DataCache, }; - Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; - Engine.ShadowObjects.OwnerPrefixMembershipChanged += - OnCollisionOwnerPrefixMembershipChanged; CollisionReports = new RuntimeCollisionReportingState( Entities, Engine.ShadowObjects); @@ -1155,9 +501,6 @@ public sealed class RuntimePhysicsState : IDisposable DataCache = engine.DataCache ?? PhysicsDataCache.CreateProduction(engine.CollisionWorld); Engine.DataCache = DataCache; - Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; - Engine.ShadowObjects.OwnerPrefixMembershipChanged += - OnCollisionOwnerPrefixMembershipChanged; CollisionReports = new RuntimeCollisionReportingState( Entities, Engine.ShadowObjects); @@ -1172,8 +515,6 @@ public sealed class RuntimePhysicsState : IDisposable public int SpatialRootCount => _spatialRoots.Count; public int SpatialRemoteCount => _spatialRemotes.Count; public int SpatialProjectileCount => _spatialProjectiles.Count; - internal int CollisionOwnerJournalEntryCountForDiagnostics => - _collisionOwnerJournal.ActiveCount; internal double UtcNowSeconds => (_timeProvider.GetUtcNow() - DateTimeOffset.UnixEpoch) .TotalSeconds; @@ -1974,7 +1315,6 @@ public sealed class RuntimePhysicsState : IDisposable _collisionAdmissions.Clear(); SetPosition.ResetSession(); CollisionReports.ResetSession(); - TrimCollisionOwnerJournal(); AdvanceCollisionWorldAuthority(); Volatile.Write(ref _collisionMutationThreadId, 0); } @@ -2105,12 +1445,8 @@ public sealed class RuntimePhysicsState : IDisposable this, admission, stagingBuilder, - _collisionOwnerJournal, checked(++_nextCollisionPreparationSequence)); _preparedCollisionGenerations[admission.LandblockId] = prepared; - _latestCollisionPreparationStartSequence = Math.Max( - _latestCollisionPreparationStartSequence, - prepared.OwnerMutationStartSequence); return prepared; } @@ -2205,7 +1541,6 @@ public sealed class RuntimePhysicsState : IDisposable admission.Generation + 1UL); AdvanceCollisionWorldAuthority(); } - TrimCollisionOwnerJournal(); return true; } @@ -2302,76 +1637,12 @@ public sealed class RuntimePhysicsState : IDisposable throw new InvalidOperationException( "Collision generation cannot seal before its assets are prepared."); } - if (!prepared.StagingCloneComplete) - { - RuntimeCollisionPreparationStep preparation = - prepared.AdvanceStagingClone(); - return new RuntimeCollisionSealStep( - Completed: false, - Restarted: false, - WorkUnits: preparation.WorkUnits); - } - RuntimeCollisionJournalStep routed = - prepared.AdvanceRoutedOwnerMutation(); - if (routed.Worked) - { - return new RuntimeCollisionSealStep( - Completed: routed.Completed - && prepared.IsOwnerMutationReconciliationCurrent - && prepared.IsReadyForActivation, - Restarted: false, - WorkUnits: 1); - } - if (prepared.HasPendingRetirement) - { - PhysicsEngine.LandblockRetirementStep retirement = - prepared.AdvanceRetirement(); - return new RuntimeCollisionSealStep( - Completed: false, - Restarted: false, - WorkUnits: retirement.Worked ? 1 : 0); - } - if (prepared.HasPendingCommittedRebase) - { - PhysicsEngine.LandblockReplacementApplyStep rebase = - prepared.AdvanceCommittedRebase(); - return new RuntimeCollisionSealStep( - Completed: false, - Restarted: false, - WorkUnits: rebase.Worked ? 1 : 0); - } - RuntimeCollisionSealStep seal = prepared.AdvanceSeal(); - if (!seal.Completed) - return seal; - if (seal.WorkUnits != 0 - && !prepared.IsOwnerMutationReconciliationCurrent) - { - return new RuntimeCollisionSealStep( - Completed: false, - Restarted: false, - WorkUnits: seal.WorkUnits); - } - RuntimeCollisionJournalStep journal = - prepared.AdvanceOwnerMutationReconciliation(); - if (journal.Worked) - { - return new RuntimeCollisionSealStep( - Completed: journal.Completed, - Restarted: false, - WorkUnits: 1); - } - if (journal.Completed - && _collisionOwnerJournal.AdvanceCompaction()) - { - return new RuntimeCollisionSealStep( - Completed: true, - Restarted: false, - WorkUnits: 1); - } - return new RuntimeCollisionSealStep( - Completed: journal.Completed, - Restarted: false, - WorkUnits: seal.WorkUnits); + // O3 (2026-08-02): no staged clone, journal, retirement, or peer + // rebase remains — the seal is exactly the target-scoped replacement + // builder. Owner cross-cells recalculate at commit against the live + // world (retail recalc_cross_cells), so post-seal owner movement + // needs no reconciliation here. + return prepared.AdvanceSeal(); } internal RuntimeCollisionGenerationCommit CommitCollisionGeneration( @@ -2413,11 +1684,8 @@ public sealed class RuntimePhysicsState : IDisposable "Collision generation has already completed."); } - if (!prepared.IsOwnerMutationReconciliationCurrent - || !prepared.IsReadyForActivation - || HasOlderPreparedGeneration(prepared) - || prepared.HasPendingCommittedRebase - || prepared.HasPendingRetirement) + if (!prepared.IsReadyForActivation + || HasOlderPreparedGeneration(prepared)) { if (!prepared.IsSealed) { @@ -2473,38 +1741,20 @@ public sealed class RuntimePhysicsState : IDisposable } mutation.Permission = permission; - // Parking a live owner writes to the canonical shadow journal. The - // prepared replacement must be resealed against that exact journal - // tail before permission can be consumed. + // Parking a live owner can mutate shadow rows; the prepared + // replacement's owner list stays valid regardless because the commit + // recalculates each owner's cross-cells against the live world. if (!IsCollisionPrefixMutationPermissionCurrent(permission) - || !prepared.IsOwnerMutationReconciliationCurrent || !prepared.IsReadyForActivation - || HasOlderPreparedGeneration(prepared) - || prepared.HasPendingCommittedRebase - || prepared.HasPendingRetirement) + || HasOlderPreparedGeneration(prepared)) { return PendingActivation(mutation); } PhysicsEngine.PreparedPhysicsEngineLandblock replacement = prepared.TakeSealedReplacement(); - bool suppressOwnerJournal = _suppressCollisionOwnerJournal; - _suppressCollisionOwnerJournal = true; - try - { - Engine.CommitLandblockReplacement(replacement); - AdvanceCollisionWorldAuthority(); - } - finally - { - _suppressCollisionOwnerJournal = suppressOwnerJournal; - } - foreach ((_, PreparedLandblockCollisionGeneration later) in - _preparedCollisionGenerations) - { - if (later.Sequence > prepared.Sequence) - later.EnqueueCommittedRebase(replacement); - } + Engine.CommitLandblockReplacement(replacement); + AdvanceCollisionWorldAuthority(); _preparedCollisionGenerations.Remove(admission.LandblockId); prepared.MarkCommitted(); mutation.EngineMutationCommitted = true; @@ -2568,7 +1818,6 @@ public sealed class RuntimePhysicsState : IDisposable _collisionAdmissions.Remove(mutation.LandblockId); } _collisionPrefixMutations.Remove(mutation.LandblockId); - TrimCollisionOwnerJournal(); PublishCollisionGenerationCommitted( new RuntimeCollisionGenerationCommitted( acknowledgement.LandblockId, @@ -2703,28 +1952,15 @@ public sealed class RuntimePhysicsState : IDisposable mutation.Permission = permission; CommitCollisionInvalidation(mutation); - bool suppressOwnerJournal = _suppressCollisionOwnerJournal; - _suppressCollisionOwnerJournal = true; - try - { - if (kind is RuntimeCollisionPrefixMutationKind.Demotion) - Engine.DemoteLandblockToTerrain(canonical); - else - Engine.RemoveLandblock(canonical); - AdvanceCollisionWorldAuthority(); - } - finally - { - _suppressCollisionOwnerJournal = suppressOwnerJournal; - } - foreach ((_, PreparedLandblockCollisionGeneration prepared) in - _preparedCollisionGenerations) - { - if (kind is RuntimeCollisionPrefixMutationKind.Demotion) - prepared.RecordDemotion(canonical); - else - prepared.RecordWithdrawal(canonical); - } + // O3 (2026-08-02): drafts hold only their own target's content, + // so retiring another landblock needs no per-draft fan-out — a + // draft's commit refloods its owners against the live world and + // observes this retirement there. + if (kind is RuntimeCollisionPrefixMutationKind.Demotion) + Engine.DemoteLandblockToTerrain(canonical); + else + Engine.RemoveLandblock(canonical); + AdvanceCollisionWorldAuthority(); mutation.EngineMutationCommitted = true; mutation.Ready = kind is RuntimeCollisionPrefixMutationKind.Demotion && Engine.IsLandblockTerrainResident(canonical); @@ -2742,10 +1978,7 @@ public sealed class RuntimePhysicsState : IDisposable mutation.TargetGeneration, mutation.Ready); if (completed) - { _collisionPrefixMutations.Remove(canonical); - TrimCollisionOwnerJournal(); - } return new RuntimeCollisionMutationResult( new RuntimeCollisionAcknowledgement( canonical, @@ -2759,10 +1992,6 @@ public sealed class RuntimePhysicsState : IDisposable { if (_disposed) return; - _suppressCollisionOwnerJournal = true; - Engine.ShadowObjects.OwnerMutated -= OnCollisionOwnerMutated; - Engine.ShadowObjects.OwnerPrefixMembershipChanged -= - OnCollisionOwnerPrefixMembershipChanged; foreach ((_, PreparedLandblockCollisionGeneration prepared) in _preparedCollisionGenerations) { @@ -2771,8 +2000,6 @@ public sealed class RuntimePhysicsState : IDisposable _preparedCollisionGenerations.Clear(); SetPosition.Dispose(); CollisionReports.Dispose(); - _collisionOwnerJournal.Clear(); - _collisionOwnerSubscribers.Clear(); Engine.Clear(); _spatialRemotes.Clear(); _spatialProjectiles.Clear(); @@ -3237,113 +2464,6 @@ public sealed class RuntimePhysicsState : IDisposable && body.InWorld; } - private void OnCollisionOwnerMutated(uint ownerId, ulong version) - { - _ = version; - if (_suppressCollisionOwnerJournal || _disposed) - return; - if (_preparedCollisionGenerations.Count == 0) - return; - long epochBefore = _collisionOwnerJournal.NextSequence; - int countBefore = _collisionOwnerJournal.Count; - CollisionOwnerMutationJournal.MutationRecord mutation = - _collisionOwnerJournal.Record( - ownerId, - version, - _latestCollisionPreparationStartSequence); - if (!_collisionOwnerSubscribers.TryGetValue( - ownerId, - out List? subscribers)) - { - return; - } - // Subscription mutation is update-thread confined. Iterate by index so - // no delegate-array or enumerator allocation enters the hot path. - for (int index = 0; index < subscribers.Count; index++) - { - subscribers[index].ObserveSubscribedOwnerMutation( - ownerId, - mutation.Sequence, - epochBefore, - countBefore); - } - } - - private void OnCollisionOwnerPrefixMembershipChanged( - uint ownerId, - uint landblockPrefix) - { - if (_suppressCollisionOwnerJournal || _disposed) - return; - uint canonical = (landblockPrefix & 0xFFFF0000u) | 0xFFFFu; - if (_preparedCollisionGenerations.TryGetValue( - canonical, - out PreparedLandblockCollisionGeneration? prepared)) - { - prepared.ObserveRoutedOwnerMembershipMutation(ownerId); - } - } - - internal void SubscribeCollisionOwner( - uint ownerId, - PreparedLandblockCollisionGeneration prepared) - { - if (!_collisionOwnerSubscribers.TryGetValue( - ownerId, - out List? subscribers)) - { - subscribers = new List(); - _collisionOwnerSubscribers[ownerId] = subscribers; - } - if (!subscribers.Contains(prepared)) - subscribers.Add(prepared); - } - - internal void UnsubscribeCollisionOwner( - uint ownerId, - PreparedLandblockCollisionGeneration prepared) - { - if (!_collisionOwnerSubscribers.TryGetValue( - ownerId, - out List? subscribers)) - { - return; - } - for (int index = 0; index < subscribers.Count; index++) - { - if (!ReferenceEquals(subscribers[index], prepared)) - continue; - subscribers.RemoveAt(index); - break; - } - if (subscribers.Count == 0) - _collisionOwnerSubscribers.Remove(ownerId); - } - - private void TrimCollisionOwnerJournal() - { - if (_preparedCollisionGenerations.Count == 0) - { - _collisionOwnerJournal.Clear(); - _latestCollisionPreparationStartSequence = 0L; - return; - } - long minimumStart = long.MaxValue; - long latestStart = 0L; - foreach ((_, PreparedLandblockCollisionGeneration prepared) in - _preparedCollisionGenerations) - { - minimumStart = Math.Min( - minimumStart, - prepared.OwnerMutationStartSequence); - latestStart = Math.Max( - latestStart, - prepared.OwnerMutationStartSequence); - } - _latestCollisionPreparationStartSequence = latestStart; - _collisionOwnerJournal.RequestCompactionBefore(minimumStart); - } - private bool HasOlderPreparedGeneration( PreparedLandblockCollisionGeneration candidate) { diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs index 78349ead..eba53cc6 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -576,7 +576,12 @@ public sealed class RuntimePhysicsStateTests GC.GetAllocatedBytesForCurrentThread() - before; Assert.True(commit.Committed); - Assert.Equal(0L, allocated); + // O2 (2026-08-02): the commit is a per-landblock delta apply, so it + // allocates O(target payload) — dictionary nodes for installed leaves, + // synthesized outdoor cells, and per-owner row installs — never + // O(resident world). CollisionPreparationCostIsIndependentOfResident- + // WorldSize pins the world-size independence exactly. + Assert.InRange(allocated, 0L, 4L * 1024L * 1024L); Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); Assert.Equal(ownerCount, physics.Engine.ShadowObjects.TotalRegistered); } @@ -907,7 +912,10 @@ public sealed class RuntimePhysicsStateTests GC.GetAllocatedBytesForCurrentThread() - before; Assert.True(commit.Committed); - Assert.Equal(0L, allocated); + // O2 (2026-08-02): delta-apply commit allocates O(target payload) + // (installed leaves + synthesized outdoor cells + one owner install), + // never O(resident world). + Assert.InRange(allocated, 0L, 1L * 1024L * 1024L); Assert.Equal(1, notifications); Assert.Same(cacheFacade, physics.DataCache); Assert.Same(graphFacade, physics.DataCache.CellGraph); @@ -918,14 +926,25 @@ public sealed class RuntimePhysicsStateTests } [Fact] - public void DenseResidentWorldAdmissionIsConstantAndMaterializesOneLeafPerStep() + public void CollisionSealWorkIsIndependentOfResidentWorldSize() + { + // O1 (2026-08-02 collision publication-throughput fix): the seal's + // landblock-replacement builders enumerate the per-prefix installed-key + // ledger, so total seal work depends only on the target payload, never + // on how many landblocks are resident. + Assert.Equal( + MeasureSealWorkUnits(residentLandblocks: 32), + MeasureSealWorkUnits(residentLandblocks: 256)); + } + + private static int MeasureSealWorkUnits(int residentLandblocks) { using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; - const int residentLandblocks = 32; for (int index = 0; index < residentLandblocks; index++) { - uint prefix = (uint)(0x10 + index) << 24 | 0x010000u; + uint prefix = (uint)(0x10 + (index % 128)) << 24 + | (uint)(0x01 + (index / 128)) << 16; uint landblockId = prefix | 0xFFFFu; RuntimeLandblockCollisionAssets assets = CollisionAssets( landblockId, @@ -954,9 +973,101 @@ public sealed class RuntimePhysicsStateTests isStatic: false); } - const uint target = 0x4001FFFFu; + const uint target = 0x0901FFFFu; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 21f)); + AddSyntheticCell(prepared.DataCache, 0x09010100u); + prepared.DataCache.RegisterBuildingForTest( + 0x09010001u, + SyntheticBuilding(Matrix4x4.Identity)); + + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId); + + int workUnits = 0; + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal(admission, prepared); + Assert.False(seal.Restarted); + Assert.InRange(seal.WorkUnits, 0, 1); + workUnits += seal.WorkUnits; + } + while (!seal.Completed); + physics.CancelCollisionGeneration(admission, prepared); + return workUnits; + } + + [Fact] + public void CollisionPreparationCostIsIndependentOfResidentWorldSize() + { + // O3 (2026-08-02): the whole-world staging clone is gone. Admission + // captures an O(1) empty root, preparation completes without walking + // the resident world, and the seal enumerates only the target + // prefix's installed keys — so the complete admission → preparation → + // seal → commit sequence performs identical work at 32 and 256 + // resident landblocks. This is the strictly stronger replacement for + // the deleted one-leaf-per-step clone invariant: it pins the property + // (bounded, world-size-independent work), not the mechanism. + (int prepared32, int seal32) = + MeasurePreparationAndSealWork(residentLandblocks: 32); + (int prepared256, int seal256) = + MeasurePreparationAndSealWork(residentLandblocks: 256); + Assert.Equal(prepared32, prepared256); + Assert.Equal(seal32, seal256); + } + + private static (int PreparationAdvances, int SealWorkUnits) + MeasurePreparationAndSealWork(int residentLandblocks) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + for (int index = 0; index < residentLandblocks; index++) + { + uint prefix = (uint)(0x10 + (index % 128)) << 24 + | (uint)(0x01 + (index / 128)) << 16; + uint landblockId = prefix | 0xFFFFu; + RuntimeLandblockCollisionAssets assets = CollisionAssets( + landblockId, + terrainHeight: index); + physics.Engine.AddLandblock( + assets.LandblockId, + assets.Terrain, + assets.CellSurfaces, + assets.PortalPlanes, + assets.WorldOffsetX, + assets.WorldOffsetY); + AddSyntheticCell(physics.DataCache, prefix | 0x0100u); + physics.DataCache.RegisterBuildingForTest( + prefix | 1u, + SyntheticBuilding(Matrix4x4.Identity)); + physics.Engine.ShadowObjects.Register( + (uint)(20_000 + index), + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + landblockId, + seedCellId: prefix | 1u, + isStatic: false); + } + + const uint target = 0x0901FFFFu; RuntimeCollisionAdmission warmAdmission = - physics.BeginCollisionAdmission(0x3F01FFFFu); + physics.BeginCollisionAdmission(0x0A01FFFFu); PreparedLandblockCollisionGeneration warm = physics.PrepareCollisionGeneration(warmAdmission); physics.CancelCollisionGeneration(warmAdmission, warm); @@ -969,11 +1080,9 @@ public sealed class RuntimePhysicsStateTests physics.PrepareCollisionGeneration(admission); long admissionAllocation = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.InRange(admissionAllocation, 1L, 128L * 1024L); - Assert.Equal(0, prepared.Engine.LandblockCount); - int advances = 0; + int preparationAdvances = 0; RuntimeCollisionPreparationStep step; do { @@ -981,13 +1090,461 @@ public sealed class RuntimePhysicsStateTests admission, prepared); Assert.InRange(step.WorkUnits, 0, 1); - Assert.True(++advances < 10_000); + Assert.True(++preparationAdvances < 10_000); } while (!step.Completed); - Assert.True(advances > residentLandblocks); - Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount); - physics.CancelCollisionGeneration(admission, prepared); + // The draft holds ONLY the target after preparation completes — + // stronger than the deleted assertion, which expected the whole + // resident world to have been materialized into the draft. + Assert.Equal(0, prepared.Engine.LandblockCount); + + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 33f)); + AddSyntheticCell(prepared.DataCache, 0x09010100u); + prepared.DataCache.RegisterBuildingForTest( + 0x09010001u, + SyntheticBuilding(Matrix4x4.Identity)); + + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId); + + int sealWorkUnits = 0; + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal(admission, prepared); + Assert.False(seal.Restarted); + Assert.InRange(seal.WorkUnits, 0, 1); + sealWorkUnits += seal.WorkUnits; + } + while (!seal.Completed); + + Assert.True(CompleteSealedCommit( + physics, + admission, + prepared).Committed); + Assert.True(physics.Engine.IsLandblockTerrainResident(target)); + return (preparationAdvances, sealWorkUnits); + } + + [Fact] + public void CommitTimeRefloodMatchesPrecomputedReflood() + { + // O3 (2026-08-02): the equivalence proof that moving the retained- + // owner reflood from the deleted staged whole-world context to the + // commit call (retail CObjCell::init_objects 0x0052B420 → + // CPhysicsObj::recalc_cross_cells 0x00515A30) is a scheduling change, + // not a semantics change. The oracle is the pre-change staged world — + // neighbor content plus the NEW target content in one flat engine — + // with every owner flooded directly against it; the production + // commit-time reflood must produce identical per-cell rows. + + // Corner landblock 0x0000FFFF (map corner is a real landblock, + // C3c-F3) with a seam dynamic, a neighbor-rooted static flooding + // across, and one authored target static. + AssertCommitTimeRefloodMatchesOracle( + targetLandblock: 0x0000FFFFu, + targetOrigin: new Vector3(0f, 0f, 0f), + neighborLandblock: 0x0001FFFFu, + neighborOrigin: new Vector3(0f, 192f, 0f), + targetEnvCells: Array.Empty(), + owners: + [ + new RefloodOwnerSpec( + 800u, + new Vector3(10f, 193f, 0f), + Radius: 4f, + SeedCellId: 0x00010001u, + IsStatic: false, + Staged: false), + new RefloodOwnerSpec( + 801u, + new Vector3(20f, 195f, 0f), + Radius: 6f, + SeedCellId: 0x00010001u, + IsStatic: true, + Staged: false), + new RefloodOwnerSpec( + 802u, + new Vector3(30f, 20f, 0f), + Radius: 5f, + SeedCellId: 0x00000009u, + IsStatic: true, + Staged: true), + ]); + + // EnvCell-heavy target: authored indoor statics seeded in target + // EnvCells plus a retained seam dynamic. + AssertCommitTimeRefloodMatchesOracle( + targetLandblock: 0x0301FFFFu, + targetOrigin: new Vector3(576f, 192f, 0f), + neighborLandblock: 0x0302FFFFu, + neighborOrigin: new Vector3(576f, 384f, 0f), + targetEnvCells: [0x03010100u, 0x03010101u, 0x03010102u], + owners: + [ + new RefloodOwnerSpec( + 810u, + new Vector3(600f, 385f, 0f), + Radius: 3f, + SeedCellId: 0x03020001u, + IsStatic: false, + Staged: false), + new RefloodOwnerSpec( + 811u, + new Vector3(580f, 200f, 0f), + Radius: 1.5f, + SeedCellId: 0x03010100u, + IsStatic: true, + Staged: true), + new RefloodOwnerSpec( + 812u, + new Vector3(590f, 210f, 0f), + Radius: 1.5f, + SeedCellId: 0x03010101u, + IsStatic: true, + Staged: true), + ]); + + // Scenery-dense target: sixteen authored outdoor statics across the + // block plus retained seam owners. + var sceneryOwners = new List + { + new( + 820u, + new Vector3(970f, 1153f, 0f), + Radius: 4f, + SeedCellId: 0x05060001u, + IsStatic: false, + Staged: false), + new( + 821u, + new Vector3(1100f, 1150f, 0f), + Radius: 5f, + SeedCellId: 0x05060031u, + IsStatic: true, + Staged: false), + }; + for (int index = 0; index < 16; index++) + { + float localX = 12f + (index % 4) * 48f; + float localY = 12f + (index / 4) * 48f; + uint low = (uint)( + ((int)(localX / 24f) * 8) + (int)(localY / 24f) + 1); + sceneryOwners.Add(new RefloodOwnerSpec( + (uint)(830 + index), + new Vector3(960f + localX, 960f + localY, 0f), + Radius: 3f, + SeedCellId: 0x05050000u | low, + IsStatic: true, + Staged: true)); + } + AssertCommitTimeRefloodMatchesOracle( + targetLandblock: 0x0505FFFFu, + targetOrigin: new Vector3(960f, 960f, 0f), + neighborLandblock: 0x0506FFFFu, + neighborOrigin: new Vector3(960f, 1152f, 0f), + targetEnvCells: Array.Empty(), + owners: [.. sceneryOwners]); + } + + private sealed record RefloodOwnerSpec( + uint OwnerId, + Vector3 Position, + float Radius, + uint SeedCellId, + bool IsStatic, + bool Staged); + + private static void AssertCommitTimeRefloodMatchesOracle( + uint targetLandblock, + Vector3 targetOrigin, + uint neighborLandblock, + Vector3 neighborOrigin, + uint[] targetEnvCells, + RefloodOwnerSpec[] owners) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + + RuntimeCollisionAdmission neighborAdmission = + physics.BeginCollisionAdmission(neighborLandblock); + using (PreparedLandblockCollisionGeneration neighborPrepared = + physics.PrepareCollisionGeneration(neighborAdmission)) + { + physics.StageCollisionAssets( + neighborAdmission, + neighborPrepared, + CollisionAssetsAt(neighborLandblock, 2f, neighborOrigin)); + Assert.True(CommitPrepared( + physics, + neighborAdmission, + neighborPrepared).Committed); + } + foreach (RefloodOwnerSpec owner in owners) + { + if (owner.Staged) + continue; + physics.Engine.ShadowObjects.Register( + owner.OwnerId, + 0x01000001u, + owner.Position, + Quaternion.Identity, + owner.Radius, + 0f, + 0f, + neighborLandblock, + seedCellId: owner.SeedCellId, + isStatic: owner.IsStatic); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(targetLandblock); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssetsAt(targetLandblock, 1f, targetOrigin)); + foreach (uint envCellId in targetEnvCells) + AddSyntheticCell(prepared.DataCache, envCellId); + foreach (RefloodOwnerSpec owner in owners) + { + if (!owner.Staged) + continue; + prepared.Engine.ShadowObjects.Register( + owner.OwnerId, + 0x01000001u, + owner.Position, + Quaternion.Identity, + owner.Radius, + 0f, + 0f, + targetLandblock, + seedCellId: owner.SeedCellId, + isStatic: owner.IsStatic); + } + Assert.True(CommitPrepared(physics, admission, prepared).Committed); + + // Oracle: the pre-change staged world — full context in one flat + // engine; every owner flooded directly against it. + var oracleCache = new PhysicsDataCache(); + var oracleEngine = new PhysicsEngine { DataCache = oracleCache }; + RuntimeLandblockCollisionAssets neighborAssets = + CollisionAssetsAt(neighborLandblock, 2f, neighborOrigin); + oracleEngine.AddLandblock( + neighborAssets.LandblockId, + neighborAssets.Terrain, + neighborAssets.CellSurfaces, + neighborAssets.PortalPlanes, + neighborAssets.WorldOffsetX, + neighborAssets.WorldOffsetY); + RuntimeLandblockCollisionAssets targetAssets = + CollisionAssetsAt(targetLandblock, 1f, targetOrigin); + oracleEngine.AddLandblock( + targetAssets.LandblockId, + targetAssets.Terrain, + targetAssets.CellSurfaces, + targetAssets.PortalPlanes, + targetAssets.WorldOffsetX, + targetAssets.WorldOffsetY); + foreach (uint envCellId in targetEnvCells) + AddSyntheticCell(oracleCache, envCellId); + foreach (RefloodOwnerSpec owner in owners) + { + oracleEngine.ShadowObjects.Register( + owner.OwnerId, + 0x01000001u, + owner.Position, + Quaternion.Identity, + owner.Radius, + 0f, + 0f, + owner.Staged ? targetLandblock : neighborLandblock, + seedCellId: owner.SeedCellId, + isStatic: owner.IsStatic); + } + + // The owner set and every owner's per-cell rows must be equal. + foreach (RefloodOwnerSpec owner in owners) + { + Assert.Equal( + oracleEngine.ShadowObjects.HasLogicalOwner(owner.OwnerId), + physics.Engine.ShadowObjects.HasLogicalOwner(owner.OwnerId)); + } + var candidateCells = new List(); + for (uint low = 1u; low <= 0x40u; low++) + { + candidateCells.Add((targetLandblock & 0xFFFF0000u) | low); + candidateCells.Add((neighborLandblock & 0xFFFF0000u) | low); + } + candidateCells.AddRange(targetEnvCells); + foreach (uint cellId in candidateCells) + { + ShadowEntry[] actual = physics.Engine.ShadowObjects + .GetObjectsInCell(cellId) + .OrderBy(entry => entry.EntityId) + .ThenBy(entry => entry.GfxObjId) + .ToArray(); + ShadowEntry[] expected = oracleEngine.ShadowObjects + .GetObjectsInCell(cellId) + .OrderBy(entry => entry.EntityId) + .ThenBy(entry => entry.GfxObjId) + .ToArray(); + Assert.Equal(expected, actual); + } + } + + private static RuntimeLandblockCollisionAssets CollisionAssetsAt( + uint landblockId, + float terrainHeight, + Vector3 origin) + { + var heights = new byte[81]; + var table = new float[256]; + table[0] = terrainHeight; + return new RuntimeLandblockCollisionAssets( + landblockId, + new TerrainSurface(heights, table), + Array.Empty(), + Array.Empty(), + origin.X, + origin.Y, + 0u); + } + + [Fact] + public void CommitAppliesOneLandblockDeltaInASingleCall() + { + // O2 (2026-08-02): the engine-mutating CommitCollisionGeneration call + // drains the landblock-replacement apply cursor to completion before + // it returns — the active world holds no target content before that + // call and the complete target content after it, with unrelated + // resident content untouched by identity. + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint neighbor = 0x0202FFFFu; + const uint neighborCellId = 0x02020100u; + const uint target = 0x0101FFFFu; + const uint targetCellId = 0x01010100u; + const uint targetBuildingId = 0x01010001u; + + RuntimeCollisionAdmission neighborAdmission = + physics.BeginCollisionAdmission(neighbor); + using (PreparedLandblockCollisionGeneration neighborPrepared = + physics.PrepareCollisionGeneration(neighborAdmission)) + { + physics.StageCollisionAssets( + neighborAdmission, + neighborPrepared, + CollisionAssets(neighbor, terrainHeight: 3f)); + AddSyntheticCell(neighborPrepared.DataCache, neighborCellId); + Assert.True(CommitPrepared( + physics, + neighborAdmission, + neighborPrepared).Committed); + } + physics.Engine.ShadowObjects.Register( + 700u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + neighbor, + seedCellId: 0x02020001u, + isStatic: false); + // An outgoing target-rooted static the replacement does not re-author: + // the delta commit must retire it. + physics.Engine.ShadowObjects.Register( + 699u, + 0x01000001u, + new Vector3(11f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: true); + CellPhysics? neighborCell = + physics.DataCache.GetCellStruct(neighborCellId); + Assert.NotNull(neighborCell); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 9f)); + AddSyntheticCell(prepared.DataCache, targetCellId); + prepared.DataCache.RegisterBuildingForTest( + targetBuildingId, + SyntheticBuilding(Matrix4x4.Identity)); + prepared.Engine.ShadowObjects.Register( + 701u, + 0x01000001u, + new Vector3(12f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: true); + _ = SealPrepared(physics, admission, prepared); + + RuntimeCollisionGenerationCommit commit = default; + for (int poll = 0; poll < 10_000 && !commit.EngineCommitted; poll++) + { + // No observable intermediate exists before the engine-mutating + // call: the target stays completely absent. + Assert.Null(physics.DataCache.GetCellStruct(targetCellId)); + Assert.Null(physics.DataCache.GetBuilding(targetBuildingId)); + Assert.False(physics.Engine.IsLandblockTerrainResident(target)); + commit = physics.CommitCollisionGeneration(admission, prepared); + if (!commit.EngineCommitted && !commit.Completed) + _ = SealPrepared(physics, admission, prepared); + } + + // The single engine-mutating call published the complete delta. + Assert.True(commit.EngineCommitted); + Assert.True(physics.Engine.IsLandblockTerrainResident(target)); + Assert.NotNull(physics.DataCache.GetCellStruct(targetCellId)); + Assert.NotNull(physics.DataCache.GetBuilding(targetBuildingId)); + Assert.True(physics.DataCache.CellGraph.Contains(targetCellId)); + uint[] owners = physics.Engine.ShadowObjects + .AllEntriesForDebug() + .Select(entry => entry.EntityId) + .Distinct() + .OrderBy(id => id) + .ToArray(); + Assert.Equal(new[] { 700u, 701u }, owners); + Assert.False(physics.Engine.ShadowObjects.HasLogicalOwner(699u)); + // Unrelated resident content is untouched by identity, not replaced. + Assert.Same(neighborCell, physics.DataCache.GetCellStruct(neighborCellId)); + // The consumed staging root is revoked exactly as the old transfer did. + Assert.Throws( + () => _ = prepared.Engine.LandblockCount); + + if (!commit.Completed) + { + Assert.True(CompleteSealedCommit( + physics, + admission, + prepared).Committed); + } } [Fact] @@ -1075,7 +1632,9 @@ public sealed class RuntimePhysicsStateTests GC.GetAllocatedBytesForCurrentThread() - before; Assert.True(commit.Committed); - Assert.Equal(0L, allocated); + // O2 (2026-08-02): delta-apply commit allocates O(target payload), + // never O(resident world). + Assert.InRange(allocated, 0L, 1L * 1024L * 1024L); Assert.Same(cacheFacade, physics.DataCache); Assert.Same(graphFacade, physics.DataCache.CellGraph); Assert.Same(shadowFacade, physics.Engine.ShadowObjects); @@ -1137,7 +1696,9 @@ public sealed class RuntimePhysicsStateTests first).Committed); long firstAllocated = GC.GetAllocatedBytesForCurrentThread() - firstBefore; - Assert.Equal(0L, firstAllocated); + // O2 (2026-08-02): delta-apply commit allocates O(target payload), + // never O(resident world). + Assert.InRange(firstAllocated, 0L, 1L * 1024L * 1024L); Assert.True(second.IsSealed); Assert.True(physics.Engine.IsLandblockTerrainResident(firstLandblock)); Assert.False(physics.Engine.IsLandblockTerrainResident(secondLandblock)); @@ -1155,7 +1716,9 @@ public sealed class RuntimePhysicsStateTests second).Committed); long secondAllocated = GC.GetAllocatedBytesForCurrentThread() - secondBefore; - Assert.Equal(0L, secondAllocated); + // O2 (2026-08-02): delta-apply commit allocates O(target payload), + // never O(resident world). + Assert.InRange(secondAllocated, 0L, 1L * 1024L * 1024L); Assert.Equal(2, physics.Engine.LandblockCount); Assert.True(physics.Engine.IsLandblockTerrainResident(firstLandblock)); Assert.True(physics.Engine.IsLandblockTerrainResident(secondLandblock)); @@ -1569,54 +2132,6 @@ public sealed class RuntimePhysicsStateTests destination)); } - [Fact] - public void UnrelatedStateMutationIsJournaledAfterAllRowsChange() - { - using var lifetime = new RuntimeEntityObjectLifetime(); - RuntimePhysicsState physics = lifetime.Physics; - const uint target = 0x0101FFFFu; - const uint unrelated = 0x0202FFFFu; - foreach (uint landblock in new[] { target, unrelated }) - { - RuntimeCollisionAdmission seed = - physics.BeginCollisionAdmission(landblock); - using PreparedLandblockCollisionGeneration initial = - physics.PrepareCollisionGeneration(seed); - physics.StageCollisionAssets( - seed, - initial, - CollisionAssets(landblock)); - Assert.True(CommitPrepared(physics, seed, initial).Committed); - } - physics.Engine.ShadowObjects.Register( - 99u, - 0x01000001u, - new Vector3(10f, 10f, 0f), - Quaternion.Identity, - 0.5f, - 0f, - 0f, - unrelated, - state: 1u, - seedCellId: 0x02020001u, - isStatic: false); - - RuntimeCollisionAdmission admission = - physics.BeginCollisionAdmission(target); - using PreparedLandblockCollisionGeneration prepared = - physics.PrepareCollisionGeneration(admission); - physics.StageCollisionAssets( - admission, - prepared, - CollisionAssets(target, terrainHeight: 6f)); - physics.Engine.ShadowObjects.UpdatePhysicsState(99u, 0x1234u); - Assert.True(CommitPrepared(physics, admission, prepared).Committed); - - Assert.All( - physics.Engine.ShadowObjects.AllEntriesForDebug(), - entry => Assert.Equal(0x1234u, entry.State)); - } - [Fact] public void PrefixOwnerSlotsReuseTombstonesUnderGuidChurn() { @@ -1897,210 +2412,6 @@ public sealed class RuntimePhysicsStateTests Assert.Equal(0x55u, authored.State); } - [Fact] - public void OwnerJournalCoalescesUnrelatedChurnAcrossManyDrafts() - { - using var lifetime = new RuntimeEntityObjectLifetime(); - RuntimePhysicsState physics = lifetime.Physics; - const uint ownerId = 900u; - physics.Engine.ShadowObjects.Register( - ownerId, - 0x01000001u, - new Vector3(10f, 10f, 0f), - Quaternion.Identity, - 0.5f, - 0f, - 0f, - 0x0101FFFFu, - seedCellId: 0x01010001u, - isStatic: false); - - physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 1u); - _ = GC.GetAllocatedBytesForCurrentThread(); - long baselineBefore = GC.GetAllocatedBytesForCurrentThread(); - for (uint version = 2u; version <= 10_001u; version++) - physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, version); - long baselineAllocated = - GC.GetAllocatedBytesForCurrentThread() - baselineBefore; - - var admissions = new List(); - var preparations = new List(); - for (int index = 0; index < 32; index++) - { - uint landblock = ((uint)(0x20 + index) << 24) | 0x0001FFFFu; - RuntimeCollisionAdmission admission = - physics.BeginCollisionAdmission(landblock); - admissions.Add(admission); - preparations.Add(physics.PrepareCollisionGeneration(admission)); - } - - physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 10_002u); - _ = GC.GetAllocatedBytesForCurrentThread(); - long before = GC.GetAllocatedBytesForCurrentThread(); - for (uint version = 10_003u; version <= 20_002u; version++) - physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, version); - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - - Assert.Equal(baselineAllocated, allocated); - Assert.Equal(1, physics.CollisionOwnerJournalEntryCountForDiagnostics); - for (int index = 0; index < admissions.Count; index++) - { - physics.CancelCollisionGeneration( - admissions[index], - preparations[index]); - } - Assert.Equal(0, physics.CollisionOwnerJournalEntryCountForDiagnostics); - } - - [Fact] - public void ManyUniqueOwnerMutationsReconcileOnePerSealAndCommitWithoutAllocation() - { - using var lifetime = new RuntimeEntityObjectLifetime(); - RuntimePhysicsState physics = lifetime.Physics; - const uint target = 0x0101FFFFu; - const uint unrelated = 0x0202FFFFu; - const int ownerCount = 512; - - RuntimeCollisionAdmission unrelatedAdmission = - physics.BeginCollisionAdmission(unrelated); - using (PreparedLandblockCollisionGeneration unrelatedPrepared = - physics.PrepareCollisionGeneration(unrelatedAdmission)) - { - physics.StageCollisionAssets( - unrelatedAdmission, - unrelatedPrepared, - CollisionAssets(unrelated)); - Assert.True(CommitPrepared( - physics, - unrelatedAdmission, - unrelatedPrepared).Committed); - } - for (uint index = 0; index < ownerCount; index++) - { - physics.Engine.ShadowObjects.Register( - 30_000u + index, - 0x01000001u, - new Vector3(10f, 10f, 0f), - Quaternion.Identity, - 0.5f, - 0f, - 0f, - unrelated, - seedCellId: 0x02020001u, - isStatic: false); - } - - RuntimeCollisionAdmission admission = - physics.BeginCollisionAdmission(target); - using PreparedLandblockCollisionGeneration prepared = - physics.PrepareCollisionGeneration(admission); - physics.StageCollisionAssets( - admission, - prepared, - CollisionAssets(target)); - _ = SealPrepared(physics, admission, prepared); - - for (uint index = 0; index < ownerCount; index++) - { - physics.Engine.ShadowObjects.UpdatePhysicsState( - 30_000u + index, - index + 1u); - } - Assert.False(physics.CommitCollisionGeneration( - admission, - prepared).Committed); - - int worked = 0; - RuntimeCollisionSealStep seal; - do - { - seal = physics.AdvanceCollisionGenerationSeal( - admission, - prepared); - Assert.InRange(seal.WorkUnits, 0, 1); - worked += seal.WorkUnits; - } - while (!seal.Completed); - Assert.Equal(ownerCount, worked); - - Assert.False(physics.CommitCollisionGeneration( - admission, - prepared).Committed); - _ = SealPrepared(physics, admission, prepared); - - _ = GC.GetAllocatedBytesForCurrentThread(); - long before = GC.GetAllocatedBytesForCurrentThread(); - RuntimeCollisionGenerationCommit commit = - physics.CommitCollisionGeneration(admission, prepared); - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.True(commit.Committed); - Assert.Equal(0L, allocated); - Assert.Equal(0, physics.CollisionOwnerJournalEntryCountForDiagnostics); - } - - [Fact] - public void CompactedJournalSupersessionPreservesTheMissedSuffix() - { - using var lifetime = new RuntimeEntityObjectLifetime(); - RuntimePhysicsState physics = lifetime.Physics; - const uint oldestTarget = 0x0101FFFFu; - const uint survivingTarget = 0x0202FFFFu; - - RuntimeCollisionAdmission oldestAdmission = - physics.BeginCollisionAdmission(oldestTarget); - PreparedLandblockCollisionGeneration oldest = - physics.PrepareCollisionGeneration(oldestAdmission); - physics.Engine.ShadowObjects.Register( - 41_000u, - 0x01000001u, - new Vector3(10f, 10f, 0f), - Quaternion.Identity, - 0.5f, - 0f, - 0f, - 0x0303FFFFu, - seedCellId: 0x03030001u, - isStatic: false); - - RuntimeCollisionAdmission survivingAdmission = - physics.BeginCollisionAdmission(survivingTarget); - using PreparedLandblockCollisionGeneration surviving = - physics.PrepareCollisionGeneration(survivingAdmission); - physics.StageCollisionAssets( - survivingAdmission, - surviving, - CollisionAssets(survivingTarget)); - physics.CancelCollisionGeneration(oldestAdmission, oldest); - - _ = SealPrepared(physics, survivingAdmission, surviving); - RuntimeCollisionSealStep compacted = - physics.AdvanceCollisionGenerationSeal( - survivingAdmission, - surviving); - Assert.True(compacted.Completed); - Assert.Equal(1, compacted.WorkUnits); - Assert.Equal(0, physics.CollisionOwnerJournalEntryCountForDiagnostics); - - physics.Engine.ShadowObjects.Register( - 42_000u, - 0x01000001u, - new Vector3(12f, 10f, 0f), - Quaternion.Identity, - 0.5f, - 0f, - 0f, - survivingTarget, - seedCellId: 0x02020001u, - isStatic: false); - Assert.True(CompleteSealedCommit( - physics, - survivingAdmission, - surviving).Committed); - Assert.Contains( - physics.Engine.ShadowObjects.AllEntriesForDebug(), - entry => entry.EntityId == 42_000u); - } - [Fact] public void UnrelatedOwnerEnteringTargetAfterJournalScanWritesThroughExactly() { @@ -2201,130 +2512,6 @@ public sealed class RuntimePhysicsStateTests Assert.Equal(0x55u, entry.State); } - [Fact] - public void JournalTailCompactionRetiresItsFreeSlotMetadataIncrementally() - { - var journal = new CollisionOwnerMutationJournal(); - const int ownerCount = 4_096; - for (uint ownerId = 1u; ownerId <= ownerCount; ownerId++) - _ = journal.Record(ownerId, ownerId, journal.NextSequence); - journal.RequestCompactionBefore(journal.NextSequence); - - int steps = 0; - while (journal.AdvanceCompaction()) - Assert.True(++steps <= ownerCount * 2); - Assert.Equal(0, journal.Count); - Assert.Equal(0, journal.ActiveCount); - - CollisionOwnerMutationJournal.MutationRecord next = - journal.Record( - 99_999u, - 1u, - journal.NextSequence); - Assert.Equal(0, next.SlotIndex); - } - - [Fact] - public void RetirementAfterSealBlocksCommitUntilItsCursorCompletes() - { - using var lifetime = new RuntimeEntityObjectLifetime(); - RuntimePhysicsState physics = lifetime.Physics; - const uint retired = 0x0101FFFFu; - const uint replacement = 0x0202FFFFu; - const uint retiredCell = 0x01010100u; - - RuntimeCollisionAdmission retiredAdmission = - physics.BeginCollisionAdmission(retired); - using (PreparedLandblockCollisionGeneration retiredPrepared = - physics.PrepareCollisionGeneration(retiredAdmission)) - { - physics.StageCollisionAssets( - retiredAdmission, - retiredPrepared, - CollisionAssets(retired)); - AddSyntheticCell(retiredPrepared.DataCache, retiredCell); - Assert.True(CommitPrepared( - physics, - retiredAdmission, - retiredPrepared).Committed); - } - - RuntimeCollisionAdmission replacementAdmission = - physics.BeginCollisionAdmission(replacement); - using PreparedLandblockCollisionGeneration replacementPrepared = - physics.PrepareCollisionGeneration(replacementAdmission); - physics.StageCollisionAssets( - replacementAdmission, - replacementPrepared, - CollisionAssets(replacement)); - _ = SealPrepared( - physics, - replacementAdmission, - replacementPrepared); - - Assert.True(CompleteWithdrawal(physics, retired).WasResident); - Assert.False(physics.CommitCollisionGeneration( - replacementAdmission, - replacementPrepared).Committed); - - _ = SealPrepared( - physics, - replacementAdmission, - replacementPrepared); - Assert.True(CompleteSealedCommit( - physics, - replacementAdmission, - replacementPrepared).Committed); - Assert.False(physics.Engine.IsLandblockTerrainResident(retired)); - Assert.Null(physics.DataCache.GetCellStruct(retiredCell)); - } - - [Fact] - public void MoreThanFixedRingWorthOfRetirementsRemainMeteredAndLossless() - { - using var lifetime = new RuntimeEntityObjectLifetime(); - RuntimePhysicsState physics = lifetime.Physics; - const uint target = 0x0101FFFFu; - RuntimeCollisionAdmission admission = - physics.BeginCollisionAdmission(target); - using PreparedLandblockCollisionGeneration prepared = - physics.PrepareCollisionGeneration(admission); - physics.StageCollisionAssets( - admission, - prepared, - CollisionAssets(target)); - _ = SealPrepared(physics, admission, prepared); - - for (uint index = 0; index < 300u; index++) - { - uint ordinal = index + 0x1000u; - uint x = ordinal & 0xFFu; - uint y = (ordinal >> 8) & 0xFFu; - _ = CompleteWithdrawal( - physics, - (x << 24) | (y << 16) | 0xFFFFu); - } - Assert.False(physics.CommitCollisionGeneration( - admission, - prepared).Committed); - - int steps = 0; - RuntimeCollisionSealStep seal; - do - { - seal = physics.AdvanceCollisionGenerationSeal( - admission, - prepared); - Assert.InRange(seal.WorkUnits, 0, 1); - Assert.True(++steps < 100_000); - } - while (!seal.Completed); - Assert.True(CompleteSealedCommit( - physics, - admission, - prepared).Committed); - } - [Fact] public void EmptyOwnerPrefixContainersAreReclaimedAcrossUniquePrefixes() {