feat(physics): S2 chunk 3 - one flood per registration feeds collision rows and render entries

ShadowObjectRegistry computes the CELLARRAY once per registration under
Contract A (cylsphere route from the Setup's collision cylspheres, else the
bbox route over the whole visual part array) and derives BOTH products from
it: the collision rows (_entityToCells/_cells, CShadowObj per cell via
add_shadows_to_cells @0x00514ae0) and the per-part render entries
(AddPartsShadow @0x00517e40). The second, independent collision flood is
gone. A caller that supplies no part array floods from its collision shapes
exactly as before, so every legacy expectation holds byte-for-byte.

The staged SetPosition pipeline now carries the retail part array, cell
array, route, and entries through TryCaptureOwnerState/InstallOwnerState
and publishes them beside the collision cell replacements, honoring the
keep-when-empty rule (SetPositionInternal num_cells gate, pc:283540) for
both products together; two new tests pin a cross-cell move and the
keep-when-empty case. Behavior change, retail-exact: an object with
decorative non-BSP parts now has its collision shapes registered in every
cell those parts reach (pinned by a two-cell fixture); all-BSP objects are
unchanged. Movement paths still take collision cells from the transition
and recompute the retail product separately; chunk 4 unifies them on the
transition's array as retail does. No particle emitter reaches this registry.

Gates (implementer's isolated worktree at identical content): Release
build 0/0; Core 4,961/4,961; App hermetic 6,760/6,760; collision/InstalledDat
fixtures 63/63; Runtime 1,884/1,884; Content 213/213.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-02 23:02:47 +02:00
parent d79058cec4
commit afbd241016
4 changed files with 665 additions and 85 deletions

View file

@ -177,27 +177,35 @@ draws its complete constructed shell. Portal polygons are not GPU mesh clips;
pixel occlusion comes from the ordered walk and depth buffer.
**Retail static render-shadow cell membership (2026-08-31; ownership moved
to `ShadowObjectRegistry` by Campaign OVERHAUL S2 chunk 2).** Indoor statics
are not rendered solely from their authored parent EnvCell. Retail's
`CEnvCell::init_static_objects` creates a `CPhysicsObj` for every static,
then `calc_cross_cells_static` + `CPartArray::AddPartsShadow` places every
visual part in each crossed cell's `shadow_part_list`, including decorative
GfxObjs that have no collision BSP. `ShadowObjectRegistry` owns this product
as a retained per-entity retail CELLARRAY (`TryGetRetailCellArray`) plus
per-cell part entries (`GetRetailPartEntriesInCell`), computed once at the
SAME registration transaction as the collision flood
(`RegisterMultiPart`/`Register`'s optional `partArray:` parameter) — not
rebuilt per frame. `WalkProductionWorldData`'s indoor and outdoor static
sweeps BORROW that array whole per record; they no longer recompute
membership from each MeshRef's prepared visual AABB (the deleted
`ResolveStaticRenderCells` / `ShadowObjectRegistry.ComputeStaticRenderCells`
pair). The retail CELLARRAY remains distinct from `ShadowObjectRegistry`'s
own collision rows (`GetOwnerCells`/`_entityToCells`) — the two retail lists
share transit math but have different membership (collision cutover to the
same CELLARRAY is S2 chunk 3) — until then, a record whose registry entry has
no retail array yet (a streaming-window race between the static-projection
journal and the physics publisher) falls back to its authored parent cell
alone, counted by `WalkProductionWorldData.UnregisteredStaticRenderFallbackCount`.
to `ShadowObjectRegistry` by Campaign OVERHAUL S2 chunk 2; collision cutover
by S2 chunk 3, 2026-09-02).** Indoor statics are not rendered solely from
their authored parent EnvCell. Retail's `CEnvCell::init_static_objects`
creates a `CPhysicsObj` for every static, then `calc_cross_cells_static` +
`CPartArray::AddPartsShadow` places every visual part in each crossed cell's
`shadow_part_list`, including decorative GfxObjs that have no collision BSP —
one CELLARRAY flood feeds BOTH the collision `shadow_object_list` and the
render `shadow_part_list` (Contract B). `ShadowObjectRegistry` now mirrors
this exactly: `RegisterMultiPart`/`Register`'s optional `partArray:`
parameter, when supplied (every production call site since chunk 1b), drives
ONE Contract A flood (`ComputeContractACellArray`) whose result becomes BOTH
`GetOwnerCells`/`_entityToCells`/`_cells` (collision) and the retained
per-entity retail CELLARRAY (`TryGetRetailCellArray`) plus per-cell part
entries (`GetRetailPartEntriesInCell`) — not two independent floods, and not
rebuilt per frame. A caller that supplies no part array (only test/legacy
call sites remain) keeps the pre-chunk-3 collision-only dispatch untouched.
`WalkProductionWorldData`'s indoor and outdoor static sweeps BORROW the
retail array whole per record; they no longer recompute membership from each
MeshRef's prepared visual AABB (the deleted `ResolveStaticRenderCells` /
`ShadowObjectRegistry.ComputeStaticRenderCells` pair). A structural
consequence: an object mixing a colliding BSP part with a decorative
non-BSP part now gets a collision row for its BSP part in every cell the
combined CELLARRAY reaches, including cells the BSP part alone would not
have crossed — membership is decided by the whole-part-array flood, not per
part; the BSP polygon test still decides actual contact at query time. A
record whose registry entry has no retail array yet (a streaming-window race
between the static-projection journal and the physics publisher) falls back
to its authored parent cell alone, counted by
`WalkProductionWorldData.UnregisteredStaticRenderFallbackCount`.
A primitive-only Setup may reuse its already-authored cylsphere collision
cell set; BSP-bearing and pure-visual statics use the all-visual-part box
walk. The Facility Hub stair Setup `0x02000623` is the installed-DAT

View file

@ -36,9 +36,10 @@ One transaction per object (`CPhysicsObj::calc_cross_cells_static`
| Owner | Inputs | Output | Runs | Consumers |
|---|---|---|---|---|
| `LandblockPhysicsPublisher.PublishStaticEntity``ShadowObjectRegistry.RegisterMultiPart` (`LandblockPhysicsPublisher.cs:930-1079`, `ShadowObjectRegistry.cs:493-584`) | BSP parts only (`ShadowShapeBuilder.FromLandblockBspParts`) or Setup cyl/sphere fallback; `CellTransit.BuildShadowCellSetFromParts` | `_entityToCells[id]` (ordered, the CELLARRAY analog) + `_cells[cell]` `ShadowEntry` rows | once per static at publication | physics broadphase `TransitionTypes.FindObjCollisionsInCell` (`TransitionTypes.cs:3755-3779`); outdoor static render lookup reads it verbatim |
| **DELETED (chunk 2).** Was `WalkProductionWorldData.ResolveStaticRenderCells``ShadowObjectRegistry.ComputeStaticRenderCells`. Now: `WalkProductionWorldData.ResolveIndoorStaticRenderCells`/`ResolveOutdoorStaticRenderCells``ShadowObjectRegistry.TryGetRetailCellArray` (`WalkProductionWorldData.cs`, `ShadowObjectRegistry.cs:634-663`) | none (borrowed) | chunk 1's retained per-entity retail CELLARRAY, computed once at registration | every `BeginFrame` sweep of `RenderSceneIndex.IndoorCellStatic`/`OutdoorStatic` (a borrow, not a recompute) | `WalkFrameDriver.GetCellStatics`; `RetailPViewRenderer` particle owner union; unregistered fallback (indoor: authored `ParentCellId`; outdoor: `GetOwnerCells`) counted by `WalkProductionWorldData.UnregisteredStaticRenderFallbackCount` |
| `LiveEntityCollisionBuilder.Register` / `ShadowObjectRegistry.UpdatePosition` / `CommitSetPosition` (`LiveEntityCollisionBuilder.cs:149-280`, `ShadowObjectRegistry.cs:881-982`) | `ShadowShapeBuilder.FromSetup` (BSP parts, else cylspheres, else spheres) | `_entityToCells` + `_cells` | spawn and every accepted move/appearance change | physics broadphase; dynamic render lookup reads it verbatim |
| **Chunk 3 (2026-09-02).** `LandblockPhysicsPublisher.PublishStaticEntity``ShadowObjectRegistry.RegisterMultiPart` (`LandblockPhysicsPublisher.cs:930-1079`, `ShadowObjectRegistry.cs`, `RegisterMultiPart`/`Register`/`ComputeContractACellArray`/`PublishRetailCellArray`) | the WHOLE visual part array (`ShadowShapeBuilder.FromStaticRenderParts`, chunk 1b) drives ONE Contract A flood (cylsphere route from the collision shapes' cylinders, else bbox route over the part array); a caller with no part array (every unit-test fixture, `partArray ?? shapes`) keeps the pre-chunk-3 legacy dispatch byte-for-byte | `_entityToCells[id]`/`_cells[cell]` (collision) AND `_retailCellArrays`/`_retailPartEntriesByCell` (render) from the SAME array — the second (redundant) flood chunk 1 left running is deleted | once per static at publication | physics broadphase `TransitionTypes.FindObjCollisionsInCell` (`TransitionTypes.cs:3755-3779`); `WalkProductionWorldData` (below) |
| **DELETED (chunk 2).** Was `WalkProductionWorldData.ResolveStaticRenderCells``ShadowObjectRegistry.ComputeStaticRenderCells`. Now: `WalkProductionWorldData.ResolveIndoorStaticRenderCells`/`ResolveOutdoorStaticRenderCells``ShadowObjectRegistry.TryGetRetailCellArray` (`WalkProductionWorldData.cs`, `ShadowObjectRegistry.cs:634-663`) | none (borrowed) | chunk 1's retained per-entity retail CELLARRAY, now the SAME array chunk 3 also uses for collision | every `BeginFrame` sweep of `RenderSceneIndex.IndoorCellStatic`/`OutdoorStatic` (a borrow, not a recompute) | `WalkFrameDriver.GetCellStatics`; `RetailPViewRenderer` particle owner union; unregistered fallback (indoor: authored `ParentCellId`; outdoor: `GetOwnerCells`) counted by `WalkProductionWorldData.UnregisteredStaticRenderFallbackCount` |
| **Chunk 3 (2026-09-02).** `LiveEntityCollisionBuilder.Register` / `ShadowObjectRegistry.UpdatePosition` / `CommitSetPosition` (`LiveEntityCollisionBuilder.cs:149-280`, `ShadowObjectRegistry.cs`) | `ShadowShapeBuilder.FromSetup` (collision dispatch) + `FromSetupRenderParts` (chunk 1b's part array) → the same ONE Contract A flood as above at registration; movement (`UpdatePosition`/`CommitSetPosition`'s `Recalculate` action) forwards to the same registration entry points, so it inherits the fix. `CommitSetPosition`'s `None`/`Preserve`/`Replace` actions (`RefreshPositionRows`/`ReplacePositionRows`) are UNCHANGED — they consume an externally-decided collision cell list and separately recompute the retail product via the same Contract A primitive on every move (chunk 1's design, not a second registration flood) | `_entityToCells` + `_cells`, and (when a part array is retained) `_retailCellArrays`/`_retailPartEntriesByCell` | spawn and every accepted move/appearance change | physics broadphase; `WalkProductionWorldData` dynamic render lookup |
| **Chunk 3 (2026-09-02).** The staged `ShadowObjectRegistry.TryPrepareSetPosition`/`TryApplySetPosition` pipeline | `PreparedShadowOwnerState` now also carries `RetailPartArray`/`RetailCellIds`/`RetailRoute`/`RetailRows`; `TryCaptureOwnerState`/`InstallOwnerState` seed them so the staging registry's own `RecomputeRetailCellArrayIfPresent` call (reached through `CommitSetPosition`) no longer silently no-ops; `PrepareRetailPartEntryReplacements` diffs the staged retail rows the same way `PrepareCellReplacements` diffs collision rows | `_retailPartEntriesByCell` publishes alongside `_cells` on `TryApplySetPosition` | every live entity moving through the transactional SetPosition publication tail (Runtime) | same as direct `CommitSetPosition`, now consistent with it |
| `WalkProductionWorldData.ResolveDynamicRenderCells` (`WalkProductionWorldData.cs:247-283`) | `GetOwnerCells`; for an `EquippedChild` with no cells, a 64-hop parent-chain walk | whole-record buckets | every `BeginFrame` | `WalkFrameDriver.GetCellDynamics` |
| `LightManager.SelectForCell` (`LightManager.cs:594-650`) | flat `_all` list, sphere overlap | per-cell light subset | per cell drawn | lighting (S5 owns; retail selects from the current EnvCell + stab list) |
| `DirectionalShadowCasterFrame` (`DirectionalShadowCasterFrame.cs:229-233`) | flat outdoor indices, no cell filter | caster list | per frame | shadow pass (S5) |
@ -49,7 +50,8 @@ Findings that shape the chunks:
- The two static floods are the SAME `CellTransit` walk with DIFFERENT part
inputs (BSP-only vs all visual parts). Retail uses all parts once. The
render-side flood already carries retail's inputs; the collision side does
not.
not. **RESOLVED (chunk 3):** one Contract A flood now drives both, for every
registration that carries a real part array.
- Render membership is whole-object per cell; retail's is every part per
cell (identity retained per part, with cell frame and clip planes).
- Children are inherited only on the render side, at bucket time; retail
@ -237,3 +239,92 @@ from its centered position. The scan was corrected to key on
a different Neftet-landblock GfxObj (`0x010046EB`) genuinely near the block's
Y=192 edge, producing 2 of 6 retail cells in the neighboring landblock
`0x8765`.
## 6. Chunk 3 evidence (2026-09-02)
Chunk 3 deletes the second flood chunk 1 left running beside the collision
one. `RegisterMultiPart`/`Register` now compute the CELLARRAY exactly once
via the shared `ComputeContractACellArray` primitive (Contract A: cylsphere
route from the collision shapes' cylinders, else the bbox route over the
part array) whenever a caller supplies a real part array — production always
does, since chunk 1b threaded `partArray:` into every `RegisterMultiPart`
call site. `_entityToCells`/`_cells` (collision, built from the actual
collision shapes) and `_retailCellArrays`/`_retailPartEntriesByCell` (render,
built from the part array) both read off that one array via
`PublishRetailCellArray`. A caller with no part array (every existing unit
test, and any future legacy caller) keeps the exact pre-chunk-3 dispatch —
`partArray ?? shapes` degrades to the old hasBsp-branch flood over `shapes`
alone — so no existing expectation for such a caller changed; the full
`Lane!=Timing&Lane!=Linux` Core suite (4,957 tests before chunk 3, 4,961
after adding chunk-3 coverage) needed zero test-expectation edits.
**Behavior change, exactly as predicted in §5:** an object whose part array
mixes a colliding BSP part with a decorative non-BSP-colliding visual part
now gets a `ShadowEntry` collision row for its BSP part in EVERY cell the
CELLARRAY reaches — including cells the BSP part does not geometrically
occupy, because membership (which cells hold a row at all) is decided by the
whole-array flood (Contract A), not per part. The BSP polygon test still
decides actual contact at query time (`TransitionTypes.FindObjCollisionsInCell`
never changed); only the candidate set widens. Objects whose part array is
entirely BSP-bearing are UNCHANGED (chunk 1 evidence: the bbox route over the
collision shapes and the bbox route over the identical part array agree
exactly, so old-collision and new-collision compute the same set either way)
`tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs`
still reports retail == collision for all five installed-DAT fixtures, now as
a structural guarantee (both read the same array) rather than an incidental
agreement. The synthetic pin for the mixed case is
`ShadowObjectRegistryRetailCellArrayTests.RegisterMultiPart_DecorativePartArrayEntry_WidensCollisionRowsToo`.
**`b3b7d922` prune removal, re-verified:** the bbox route
(`CellTransit.BuildShadowCellSetFromParts`) still carries no post-flood prune
of outside cells, matching Contract A's bbox branch exactly (`§1` above: "the
outdoor expansion... no later rule that removes the outside cells it just
added"). Chunk 3 does not reintroduce one; the collision cut over inherits
the same unpruned array the render side already used.
**The staged pipeline (item B, the chunk-1 flagged gap):** `TryPrepareSetPosition`
built a scratch `staging` registry via `InstallOwnerState`, then ran the
CommitSetPosition suffix on it — but `InstallOwnerState` never seeded
`_entityRetailPartArrays`/`_retailCellArrays`/`_retailCellArrayRoutes`/
`_retailPartEntriesByCell`, so staging's own internal
`RecomputeRetailCellArrayIfPresent` call (reached through
`RefreshPositionRows`/`ReplacePositionRows`) found nothing retained and
no-opped every time — the retail product silently never traveled through a
live-entity SetPosition commit. `PreparedShadowOwnerState` now also carries
`RetailPartArray`/`RetailCellIds`/`RetailRoute`/`RetailRows`;
`TryCaptureOwnerState` captures them from the live registry,
`InstallOwnerState` seeds them into staging, and a new
`PrepareRetailPartEntryReplacements` (parallel to `PrepareCellReplacements`)
diffs the staged retail rows against the live `_retailPartEntriesByCell` so
`TryApplySetPosition` can publish them alongside the collision cell
replacements it already applies. `LandblockReplacementBuilder`,
`MirrorOwnerFrom`, and `RefreshRetainedOwnerFrom` all route through the same
`TryCaptureOwnerState`/`InstallOwnerState` pair, so they inherit the fix for
free without their own changes. Pinned by
`ShadowObjectRegistryRetailCellArrayTests.StagedSetPosition_WithRetainedPartArray_MovesBothProductsTogether`
and `..._KeepWhenEmpty_PreservesBothProductsTogether`.
**Scope decision — movement (`RefreshPositionRows`/`ReplacePositionRows`) is
NOT unified with collision's cell list.** Item A's title is "one flood per
REGISTRATION"; `ReplacePositionRows` receives an externally-decided collision
cell list from the Transition/physics engine (a different subsystem, not
`ComputeContractACellArray`) and, unchanged since chunk 1, separately
recomputes the retail product via `RecomputeRetailCellArrayIfPresent` on
every move. This is not the "two floods disagreeing over the same input"
problem chunk 3 targets (registration, where BOTH products used to flood the
SAME `shapes`/`partArray` independently) — the transition-computed cross-cell
list and Contract A's part-array flood are legitimately different
computations over different inputs, and collapsing them into one would be an
architecture change beyond this chunk's contract. Flagged here as an open
question for the chunk 3 retail review, not resolved unilaterally.
**Particles (item D):** no production code path sets the `PARTICLE_EMITTER_PS`
state bit (`0x1000`) when calling `Register`/`RegisterMultiPart` today, and
particle emitters do not register with `ShadowObjectRegistry` at all —
`AcDream.App/Rendering/ParticleRenderer.cs` owns their placement independently
(`_meshReferences.Register(emitter.Handle, gfxObjId)`, a GPU mesh-reference
table, not a shadow-cell registration). There is no `(state & 0x1000) != 0`
branch to implement in this registry today; the retail
`add_particle_shadow_to_cell` own-cell-only rule remains chunk 4 (or later)
territory, to be implemented if/when particle emitters are ever routed
through this registry.