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>
411 lines
25 KiB
Markdown
411 lines
25 KiB
Markdown
# 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<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 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.
|