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

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