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:
parent
c52ce14a07
commit
71604331cf
13 changed files with 6410 additions and 1894 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,411 @@
|
|||
# O(changed) collision clone — design note
|
||||
|
||||
**Phase:** research + design only. No production edits, nothing staged, no probes left
|
||||
behind. Worktree `C:\Users\erikn\.codex\worktrees\af5e\acdream`, branch
|
||||
`codex/port-claude-agents`, HEAD `c52ce14a`.
|
||||
|
||||
**Problem:** the collision-generation staging clone is O(resident world) per landblock
|
||||
publication, so loading an N-landblock ring costs O(N²). The far ring never converges.
|
||||
C3c made it user-visible (late monster pop-in, extended/stuck portal space, portal-exit
|
||||
pop-in, failing nine-stop soak) but did not cause it.
|
||||
|
||||
---
|
||||
|
||||
## (a) What the one-leaf-per-step invariant actually protects
|
||||
|
||||
### It is a frame-time bound. Nothing else.
|
||||
|
||||
The whole-world copy did not arrive with `6b28ff99`. It arrived one commit earlier, in
|
||||
`be94bc9b` "fix(physics): activate collision generations atomically" (2026-07-31), as a
|
||||
**synchronous** copy performed in a single call at admission:
|
||||
|
||||
```csharp
|
||||
// be94bc9b, PhysicsEngine.CreateCollisionStagingCopy
|
||||
foreach ((uint id, LandblockPhysics landblock) in _landblocks)
|
||||
staging._landblocks[id] = landblock;
|
||||
staging.ShadowObjects.CopyCollisionStateFrom(ShadowObjects, stagingCache);
|
||||
```
|
||||
|
||||
`6b28ff99` "make collision activation starvation-free" replaced that with
|
||||
`CollisionStagingBuilder` (`src/AcDream.Core/Physics/PhysicsEngine.cs:785-941`), which
|
||||
performs the *same* copy chopped into single leaves across frames. The retired AD-6 row
|
||||
states the purpose verbatim
|
||||
(`docs/architecture/retail-divergence-register.md:113`):
|
||||
|
||||
> "Admission captures the active root in O(1); a stable landblock/owner slot suffix
|
||||
> materializes non-target leaves incrementally, **so resident-world size cannot become a
|
||||
> synchronous clone spike**."
|
||||
|
||||
The committed test says the same thing three ways
|
||||
(`tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:921-991`):
|
||||
|
||||
| Assertion | line | What it pins |
|
||||
|---|---|---|
|
||||
| `Assert.InRange(admissionAllocation, 1L, 128L*1024L)` | :973 | admission allocates a constant |
|
||||
| `Assert.Equal(0, prepared.Engine.LandblockCount)` | :974 | admission copies no resident landblock |
|
||||
| `Assert.InRange(step.WorkUnits, 0, 1)` | :983 | **the copy is chopped to one leaf per host step** |
|
||||
| `Assert.True(advances > residentLandblocks)` | :988 | it really walked the resident world |
|
||||
| `Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount)` | :989 | the draft ends up holding the whole world |
|
||||
|
||||
So the invariant protects **hitch avoidance**: a dense resident world must not produce
|
||||
one long synchronous copy inside a single update step. It is a *scheduling* property
|
||||
asserted as a *mechanism*, which is why the batching lever tripped it.
|
||||
|
||||
### What it does NOT protect
|
||||
|
||||
- **Not concurrent-reader isolation.** That is `CollisionWorldStateSlot.TransferTo`'s
|
||||
single `Volatile.Write` (`src/AcDream.Core/Physics/CollisionWorldState.cs:66-84`).
|
||||
And a full threading audit of every writer and reader of `CollisionWorldState` found
|
||||
**no concurrent reader or writer exists**: `GameWindow` runs one Silk.NET loop thread;
|
||||
`UpdateFrameOrchestrator.Tick` runs `_streaming.Tick()` → `DrainAndApply` and then the
|
||||
live/physics/camera phases strictly sequentially on that thread; the only background
|
||||
workers (`LandblockStreamer` worker thread, `EnvCellRenderer` `Parallel.ForEach`,
|
||||
`ObjectMeshManager` `Task.Run`) never touch `PhysicsDataCache` / `CellGraph` /
|
||||
`ShadowObjectRegistry` / `PhysicsEngine` — grep of `LandblockBuildFactory.cs` and
|
||||
`LandblockMesh.cs` for those types returns zero hits. Every one of
|
||||
`BeginCollisionAdmission` (:2040), `PrepareCollisionGeneration` (:2092),
|
||||
`AdvanceCollisionGenerationPreparation` (:2123), `StageCollisionAssets` (:2218),
|
||||
`AdvanceCollisionGenerationSeal` (:2298), `CommitCollisionGeneration` (:2382),
|
||||
`CancelCollisionGeneration` (:2139) passes through
|
||||
`RuntimePhysicsState.EnsureCollisionMutationThread` (:2857-2869). The
|
||||
`ConcurrentDictionary` choices are load-bearing only for *single-threaded*
|
||||
mutate-while-enumerating (`PhysicsDataCache.cs:958-984`, and the seal cursor holding a
|
||||
live enumerator across frames at :1081-1160) — the cross-thread rationale in the
|
||||
`CellGraph.cs:17` and `PhysicsDataCache.cs:14-20` doc comments is **stale after
|
||||
6b28ff99**.
|
||||
- **Not admission fairness.** That is the separate journal/coalescing machinery
|
||||
(`RuntimePhysicsState.cs:475-529`, research doc step 5) — the other half of what
|
||||
"starvation-free" meant. It is orthogonal to the leaf metering and stays.
|
||||
|
||||
### What the pre-6b28ff99 mechanism did
|
||||
|
||||
`be94bc9b`'s commit was a **delta apply**, not a root swap:
|
||||
|
||||
```csharp
|
||||
// be94bc9b, PhysicsEngine.CommitLandblockReplacement — deleted by 6b28ff99
|
||||
DataCache.CommitLandblockReplacement(replacement.DataCache); // O(changed)
|
||||
_landblocks[replacement.LandblockId] = replacement.Landblock;
|
||||
ShadowObjects.CommitLandblockReplacement(replacement.Shadows);
|
||||
```
|
||||
|
||||
`6b28ff99` replaced those three lines with
|
||||
`stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld)`
|
||||
(`PhysicsEngine.cs:304-321`). **That is the change that made the clone load-bearing.**
|
||||
Before it, the clone was a build sandbox; after it, the clone *is* the world that gets
|
||||
published, so every leaf not cloned is a leaf deleted from the world.
|
||||
|
||||
Before `be94bc9b` the client mutated the active maps in place across many frames — the
|
||||
genuinely non-equivalent state the research doc describes ("the active `PhysicsDataCache`,
|
||||
`CellGraph`, `PhysicsEngine`, buildings, static shadows, and retained-object refloods
|
||||
changed at different cursors", `docs/research/2026-07-31-atomic-collision-generation.md:12-16`).
|
||||
**The atomicity requirement is "the multi-frame build must not be observable", not "the
|
||||
whole world must be swapped".** A delta applied inside one synchronous update-thread call
|
||||
satisfies it.
|
||||
|
||||
### The cost is worse than F4 measured
|
||||
|
||||
F4 attributed median 19,736 / p90 32,135 / max 38,021 leaves and median 3.64 ms per
|
||||
publication to the staging clone. The **seal** does the same walk again: the replacement
|
||||
builder holds live enumerators over four `_staging` maps *and* four `_active` maps
|
||||
(`PhysicsDataCache.cs:1081, 1092, 1103, 1114, 1125, 1136, 1147, 1158`) plus two in
|
||||
`CellGraph.cs:184, 203`, using `CapturePrefixOne` / `CaptureRemovalOne` — a full scan of
|
||||
each map to find O(target) keys. Real per-publication cost is therefore roughly **2–3×
|
||||
resident world**, not 1×. **Fixing only the clone leaves O(N²) in the seal.** Any design
|
||||
that does not also scope the removal capture is not a fix.
|
||||
|
||||
---
|
||||
|
||||
## (b) Candidate designs
|
||||
|
||||
### D1 — Structural sharing (persistent/immutable `CollisionWorldState`)
|
||||
|
||||
Replace the ~20 mutable maps with persistent maps (HAMT / `ImmutableDictionary`) so a
|
||||
staging clone shares unchanged subtrees and copies only the changed path.
|
||||
|
||||
- **Blast radius:** every read and write site of every map in `CollisionWorldState`,
|
||||
`PhysicsDataCache`, `CellGraph`, `ShadowObjectRegistry`, `PhysicsEngine`.
|
||||
- **Invariant changes:** none semantically; the root swap survives unchanged, so the
|
||||
atomicity story is untouched.
|
||||
- **Throughput:** admission O(1), clone O(1), commit O(changed · log N). Excellent on the
|
||||
copy axis.
|
||||
- **Why rejected:** it pays for the copy with the *query*. A HAMT probe is several times a
|
||||
`Dictionary` probe and allocates on write; the resolver runs thousands of these per
|
||||
frame at 30 Hz. Slice I's entire thesis is flat, integer-indexed, zero-allocation
|
||||
collision (`docs/plans/2026-07-25-modern-runtime-slice-i.md`; I1 measured 0 B/resolve).
|
||||
D1 optimizes the rare operation at the expense of the hot one and fights the I-series
|
||||
architecture head-on.
|
||||
|
||||
### D1b — Landblock-sliced root (per-prefix immutable slice + small map)
|
||||
|
||||
Regroup the root so each landblock's cells / flat cells / EnvCells / buildings / terrain /
|
||||
outdoor cells / `LandblockPhysics` live in one immutable `LandblockCollisionSlice`, and
|
||||
the root becomes `Dictionary<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.
|
||||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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->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
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue