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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-02 20:06:59 +02:00
parent c52ce14a07
commit 71604331cf
13 changed files with 6410 additions and 1894 deletions

View file

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

View file

@ -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 **23×
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<prefix, slice>` (~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 08 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 (~70200 leaves at the measured
~184 ns/leaf) + the owners touching the target, versus today's ~23 × 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 08. 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` | 92501 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.58.5 MB | `0` at all 9 |
| `resources.streamingWork.oldestDeferredAgeMilliseconds` | 37,76469,728 | `0` |
| `resources.loadedLandblocks` | 124533 | **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.

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -3,6 +3,81 @@ using AcDream.Core.World.Cells;
namespace AcDream.Core.Physics;
/// <summary>
/// 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
/// <see cref="CollisionWorldState"/> 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).
/// </summary>
internal sealed class PrefixKeyIndex
{
private readonly Dictionary<uint, List<uint>> _slots = new();
private readonly Dictionary<uint, Dictionary<uint, int>> _indices = new();
private readonly Dictionary<uint, Stack<int>> _freeSlots = new();
internal void Add(uint key)
{
uint prefix = key & 0xFFFF0000u;
if (!_slots.TryGetValue(prefix, out List<uint>? slots))
{
slots = new List<uint>();
_slots[prefix] = slots;
_indices[prefix] = new Dictionary<uint, int>();
_freeSlots[prefix] = new Stack<int>();
}
Dictionary<uint, int> 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<uint, int>? 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);
}
/// <summary>
/// The live slot list for one landblock prefix, or null when no key is
/// installed. Callers capture the reference plus <c>Count</c> once and
/// iterate by index, skipping tombstone slots (key 0).
/// </summary>
internal List<uint>? SlotsForPrefix(uint prefix) =>
_slots.TryGetValue(prefix & 0xFFFF0000u, out List<uint>? slots)
? slots
: null;
internal int InstalledKeyCountForPrefix(uint prefix) =>
_indices.TryGetValue(prefix & 0xFFFF0000u, out var indices)
? indices.Count
: 0;
}
/// <summary>
/// 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<uint> ShadowOwnerSlots { get; } = new();
internal Dictionary<uint, int> ShadowOwnerIndices { get; } = new();
internal Stack<int> 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;
}
}
/// <summary>
@ -83,5 +280,18 @@ internal sealed class CollisionWorldStateSlot
return transferred;
}
/// <summary>
/// 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.
/// </summary>
internal void Revoke()
{
_revoked = true;
_current = null;
}
internal CollisionWorldState Capture() => Current;
}

View file

@ -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 <see cref="CacheCellStruct"/>.
/// </summary>
public void RegisterCellStructForTest(uint envCellId, CellPhysics physics)
=> _cellStruct[envCellId] = physics;
=> _collisionWorld.Current.SetCellStruct(envCellId, physics);
/// <summary>
/// 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,
};
});
}
/// <summary>
@ -954,10 +954,33 @@ public sealed class PhysicsDataCache
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
private static void RemovePrefixKeys(
PrefixKeyIndex ledger,
uint prefix,
Func<uint, bool> remove)
{
List<uint>? 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);
}
}
/// <summary>
@ -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<uint> BuildingIds => (IReadOnlyCollection<uint>)_buildings.Keys;
/// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary>
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<uint> _removeFlatEnvCells = new();
private readonly List<uint> _removeBuildings = new();
private readonly UcgCellGraph.LandblockReplacementBuilder _cellGraph;
private IEnumerator<KeyValuePair<uint, CellPhysics>>? _cellEnumerator;
private IEnumerator<KeyValuePair<uint, FlatCellStructureCollisionAsset>>? _flatCellEnumerator;
private IEnumerator<KeyValuePair<uint, FlatEnvCellTopology>>? _flatEnvEnumerator;
private IEnumerator<KeyValuePair<uint, BuildingPhysics>>? _buildingEnumerator;
private List<uint>? _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<T>(
IEnumerator<KeyValuePair<uint, T>> enumerator,
uint prefix,
private CollisionWorldState StagingWorld =>
_staging._collisionWorld.Current;
private CollisionWorldState ActiveWorld =>
_active._collisionWorld.Current;
/// <summary>
/// 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.
/// </summary>
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<T>(
ConcurrentDictionary<uint, T> source,
uint id,
List<KeyValuePair<uint, T>> destination,
HashSet<uint> ids)
{
if (!enumerator.MoveNext())
return false;
KeyValuePair<uint, T> 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<uint, T>(id, value));
ids.Add(id);
}
return true;
}
private static bool CaptureRemovalOne<T>(
IEnumerator<KeyValuePair<uint, T>> enumerator,
uint prefix,
private static void CaptureRemoval<T>(
ConcurrentDictionary<uint, T> source,
uint id,
HashSet<uint> retained,
List<uint> 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();
}
}

View file

@ -249,20 +249,22 @@ public sealed class PhysicsEngine
float WorldOffsetY);
/// <summary>
/// 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 <c>CObjCell::init_objects</c> 0x0052B420 →
/// <c>CPhysicsObj::recalc_cross_cells</c> 0x00515A30).
/// </summary>
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));
}
/// <summary>
/// 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
/// <c>CollisionWorldStateSlot.TransferTo</c> swap. Retail hydrates one
/// cell synchronously and refloods the objects associated with it
/// (<c>CObjCell::init_objects</c> 0x0052B420 →
/// <c>CPhysicsObj::recalc_cross_cells</c> 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.
/// </summary>
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<uint> 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);
/// <summary>
/// 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.
/// </summary>
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<uint> _ownerSlots;
private readonly int _ownerSlotLimit;
private readonly LandblockPhysics? _demotedLandblock;
private readonly CellGraphTerrain? _demotedTerrain;
private IEnumerator<KeyValuePair<uint, CellPhysics>>? _cells;
private IEnumerator<KeyValuePair<uint, FlatCellStructureCollisionAsset>>?
_flatCells;
private IEnumerator<KeyValuePair<uint, FlatEnvCellTopology>>? _flatEnvCells;
private IEnumerator<KeyValuePair<uint, BuildingPhysics>>? _buildings;
private IEnumerator<KeyValuePair<uint, EnvCell>>? _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<T>(
IEnumerator<KeyValuePair<uint, T>> source,
IDictionary<uint, T> 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<T>(
ref IEnumerator<KeyValuePair<uint, T>>? 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);
/// <summary>
/// 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.
/// <see cref="CommitLandblockReplacement"/> drains it in one synchronous
/// update-thread call.
/// </summary>
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<uint, CellPhysics> 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<uint, FlatCellStructureCollisionAsset>
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<uint, FlatEnvCellTopology> 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<uint, BuildingPhysics> 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<uint, EnvCell> 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<T>(
IDictionary<uint, T> destination,
IReadOnlyList<uint> ids)
{
if (_index >= ids.Count)
return false;
destination.Remove(ids[_index++]);
return true;
}
private bool InstallOne<T>(
IDictionary<uint, T> destination,
IReadOnlyList<KeyValuePair<uint, T>> entries)
{
if (_index >= entries.Count)
return false;
KeyValuePair<uint, T> pair = entries[_index++];
destination[pair.Key] = pair.Value;
return true;
}
public void Dispose()
{
}
}
/// <summary>
/// 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 <see cref="Advance"/>. 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
/// <c>CObjCell::init_objects</c> 0x0052B420 →
/// <c>CPhysicsObj::recalc_cross_cells</c> 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.
/// </summary>
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<uint> _suppressedPrefixes = new();
private readonly int _landblockSlotLimit;
private readonly int _ownerSlotLimit;
private IEnumerator<KeyValuePair<uint, CellPhysics>>? _cells;
private IEnumerator<KeyValuePair<uint, FlatCellStructureCollisionAsset>>? _flatCells;
private IEnumerator<KeyValuePair<uint, FlatEnvCellTopology>>? _flatEnvCells;
private IEnumerator<KeyValuePair<uint, BuildingPhysics>>? _buildings;
private IEnumerator<KeyValuePair<uint, EnvCell>>? _envCells;
private IEnumerator<KeyValuePair<uint, CellGraphTerrain>>? _terrain;
private IEnumerator<KeyValuePair<uint, ObjCell>>? _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;
/// <summary>
/// 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.
/// </summary>
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<T>(
IEnumerator<KeyValuePair<uint, T>> source,
IDictionary<uint, T> destination)
{
if (!source.MoveNext())
return false;
KeyValuePair<uint, T> 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<T>(
ref IEnumerator<KeyValuePair<uint, T>>? 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);
}
}

View file

@ -1983,6 +1983,78 @@ public sealed class ShadowObjectRegistry
internal uint GetOwnerSlot(int index) => _ownerSlots[index];
/// <summary>
/// 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 —
/// <c>CObjCell::init_objects</c> (0x0052B420) →
/// <c>CPhysicsObj::recalc_cross_cells</c> (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.
/// </summary>
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);
}
/// <summary>
/// O3 (2026-08-02): retail <c>CObjCell::init_objects</c> 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.
/// </summary>
internal void RefloodPrefixOwnersAfterReplacement(
uint landblockId,
IReadOnlyList<uint> sealedOwnerIds)
{
uint prefix = landblockId & 0xFFFF0000u;
if (!_prefixOwnerSlots.TryGetValue(prefix, out List<uint>? slots))
return;
var applied = new HashSet<uint>(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);
}
}
/// <summary>
/// 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;
}

View file

@ -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);
/// <param name="landblockPrefix">Any id in the cell's landblock; masked to (id &amp; 0xFFFF0000).</param>
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<uint>(_envCells.Keys))
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
RemoveEnvCellPrefixKeys(lb);
}
/// <summary>
/// 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.
/// </summary>
private void RemoveEnvCellPrefixKeys(uint prefix)
{
CollisionWorldState world = _collisionWorld.Current;
List<uint>? 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);
}
}
/// <summary>
@ -113,8 +133,7 @@ public sealed class CellGraph
{
CurrCell = null;
}
foreach (var id in new List<uint>(_envCells.Keys))
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
RemoveEnvCellPrefixKeys(lb);
}
/// <summary>The universal id-&gt;cell resolver (retail CObjCell::GetVisible).</summary>
@ -170,7 +189,10 @@ public sealed class CellGraph
private readonly List<KeyValuePair<uint, EnvCell>> _envCells = new();
private readonly HashSet<uint> _stagingIds = new();
private readonly List<uint> _removeIds = new();
private IEnumerator<KeyValuePair<uint, EnvCell>>? _enumerator;
private List<uint>? _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<uint, EnvCell> 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<uint, EnvCell>(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;
}
}
}

File diff suppressed because it is too large Load diff