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:
parent
d79058cec4
commit
afbd241016
4 changed files with 665 additions and 85 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue