feat(physics): S2 chunk 4 - movement publishes from the transition's cells; children inherit at registration
Movement: CommitSetPosition's RefreshPositionRows/ReplacePositionRows and the staged apply publish the retail render product from the exact cell list collision just used, the transition's cell_array retail feeds add_shadows_to_cells in CPhysicsObj::SetPositionInternal @0x00515330 (pseudo-C 283526-283539); the separate move-path bbox recompute is deleted. calc_cross_cells @0x00515230 stays the distinct full-recompute path (PhysicsShadowCommitAction.Recalculate). Children (Contract B recursion): ShadowObjectRegistry.AttachChild/DetachChild give an attached object the root's current cells as part entries only, republished whenever the root's array changes, detached at withdrawal and cascaded from the root's Deregister; nested attachment resolves to the root with a bounded, cycle-safe chain. EquippedChildRenderController attaches at realization (FromSetupRenderParts over the child's Setup) and detaches at its single removal funnel. WalkProductionWorldData's dynamic sweep reads TryGetRetailCellArray directly; the 64-hop parent-chain walk and its FindParentLocalId plumbing are deleted. CollisionWorldState.Clear now also clears the retail products. Gates (implementer's isolated worktree at identical content): Release build 0/0; Core 4,970/4,970; App hermetic 6,761/6,761; targeted walk/child/live-entity/placement/comparator 166/166; Runtime 1,884/1,884. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
706fc49397
commit
75ea269d35
14 changed files with 1072 additions and 179 deletions
|
|
@ -212,27 +212,58 @@ walk. The Facility Hub stair Setup `0x02000623` is the installed-DAT
|
||||||
regression: its authored parent is `0x8A02015F`, while its visual parts also
|
regression: its authored parent is `0x8A02015F`, while its visual parts also
|
||||||
register in `0x8A02015E` and the adjoining vertical cells.
|
register in `0x8A02015E` and the adjoining vertical cells.
|
||||||
|
|
||||||
|
**Moving multipart objects and equipped children (S2 chunk 4, 2026-09-02).**
|
||||||
Moving multipart objects follow the sibling retail route rather than their
|
Moving multipart objects follow the sibling retail route rather than their
|
||||||
authored/feet cell alone: `CPhysicsObj::add_shadows_to_cells` installs every
|
authored/feet cell alone: `CPhysicsObj::add_shadows_to_cells` installs every
|
||||||
`CPartArray` part in every cell of the object's retained `CELLARRAY`, and
|
`CPartArray` part in every cell of the object's retained `CELLARRAY`, and
|
||||||
`CPhysicsPart::Draw` stamps each part only after that part passes a cell's
|
`CPhysicsPart::Draw` stamps each part only after that part passes a cell's
|
||||||
portal test. `WalkProductionWorldData` therefore buckets live projections by
|
portal test. `WalkProductionWorldData.ResolveDynamicRenderCells` therefore
|
||||||
all `ShadowObjectRegistry.GetOwnerCells` rows, while the walk classifier owns
|
reads `ShadowObjectRegistry.TryGetRetailCellArray` directly for every dynamic
|
||||||
a projection+part drawn-pass stamp. Retail advances that stamp after
|
record — the SAME borrow-not-recompute recipe the static sweeps already use
|
||||||
|
— falling back to `GetOwnerCells` (counted by the shared
|
||||||
|
`UnregisteredStaticRenderFallbackCount`) only when the registry has not
|
||||||
|
registered an array yet, while the walk classifier owns a separate
|
||||||
|
projection+part drawn-pass stamp. Retail advances that stamp after
|
||||||
`LScape::draw` + `FlushAlphaList` and before the interior depth clear
|
`LScape::draw` + `FlushAlphaList` and before the interior depth clear
|
||||||
(`PView::DrawCells` @0x005A4886), so a part may draw once in the landscape
|
(`PView::DrawCells` @0x005A4886), so a part may draw once in the landscape
|
||||||
pass and once again in the post-clear interior-cell pass; acdream re-arms the
|
pass and once again in the post-clear interior-cell pass; acdream re-arms the
|
||||||
classifier at that exact walk boundary. `add_shadows_to_cells` recursively
|
classifier at that exact walk boundary. Never replace the per-part stamp with
|
||||||
passes the root's same `CELLARRAY` to every attached child, so equipped-child render
|
a whole-projection drawn-once gate: at a stair portal the torso may pass in
|
||||||
projections resolve their accepted parent chain to the root's owner cells;
|
the first cell while head/hair fails and must retry through another crossed
|
||||||
they do not fall back to one authored cell. Never replace the per-part stamp
|
|
||||||
with a whole-projection drawn-once gate: at a stair portal the torso may pass
|
|
||||||
in the first cell while head/hair fails and must retry through another crossed
|
|
||||||
cell. One binding exception is the local player: retail
|
cell. One binding exception is the local player: retail
|
||||||
`RenderDeviceD3D::DrawMeshInternal` bypasses `Get/SetDrawnThisFrame` when
|
`RenderDeviceD3D::DrawMeshInternal` bypasses `Get/SetDrawnThisFrame` when
|
||||||
`CPhysicsPart::IsPartOfPlayerObj` is true, repainting the player's parts at
|
`CPhysicsPart::IsPartOfPlayerObj` is true, repainting the player's parts at
|
||||||
every crossed-cell turn so later wall/depth ordering remains correct.
|
every crossed-cell turn so later wall/depth ordering remains correct.
|
||||||
|
|
||||||
|
Retail's move-path product is the SAME `add_shadows_to_cells` call the
|
||||||
|
registration path uses, not a second independent flood:
|
||||||
|
`CPhysicsObj::SetPositionInternal` (0x00515330) takes the transition's own
|
||||||
|
`cell_array` on a successful move and calls
|
||||||
|
`remove_shadows_from_cells`/`add_shadows_to_cells` with it directly — the
|
||||||
|
`calc_cross_cells_static` cylsphere/bbox dispatch never re-runs on a move.
|
||||||
|
`ShadowObjectRegistry.ReplacePositionRows` mirrors this exactly:
|
||||||
|
`PublishRetailProductFromExactCells` publishes the retail render product from
|
||||||
|
the SAME exact cell list collision just republished into, replacing the
|
||||||
|
independent Contract A recompute an earlier chunk ran on every move; retail's
|
||||||
|
`num_cells > 0` keep-when-empty gate covers both products identically, so a
|
||||||
|
move with no resolvable transition array leaves both untouched.
|
||||||
|
|
||||||
|
`add_shadows_to_cells` also recursively passes the root's same `CELLARRAY` to
|
||||||
|
every object in `children` (Contract B); acdream's attached projections
|
||||||
|
(equipped weapons/shields/ammunition) own no independent collision shapes, so
|
||||||
|
`ShadowObjectRegistry.AttachChild`/`DetachChild` publish PART ENTRIES only —
|
||||||
|
never a collision row — into every cell of the resolved root's current retail
|
||||||
|
CELLARRAY, re-publishing automatically whenever that array changes
|
||||||
|
(registration, move, staged apply, `ReplaceMultiPartPayload`).
|
||||||
|
`EquippedChildRenderController.TryRealize` calls `AttachChild` at the exact
|
||||||
|
moment an attached projection's `WorldEntity` is registered against its
|
||||||
|
accepted parent; `CommitProjectionRemoval` (the withdrawal/unparent/teardown
|
||||||
|
funnel) calls `DetachChild`. A nested attachment (a child of a child)
|
||||||
|
resolves to the ultimate root by walking the existing attach chain at the
|
||||||
|
registry, bounded and cycle-safe — `WalkProductionWorldData` no longer walks
|
||||||
|
an accepted-parent chain of its own; it reads the child's own id straight off
|
||||||
|
`TryGetRetailCellArray`, identically to any other dynamic record.
|
||||||
|
|
||||||
**Slice I3 prepared collision extension (2026-07-25).** At its introduction,
|
**Slice I3 prepared collision extension (2026-07-25).** At its introduction,
|
||||||
the package remained format 1 and retained mesh type values 1–3; bake-tool 4
|
the package remained format 1 and retained mesh type values 1–3; bake-tool 4
|
||||||
appended typed GfxObj,
|
appended typed GfxObj,
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,10 @@ One transaction per object (`CPhysicsObj::calc_cross_cells_static`
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **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) |
|
| **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` |
|
| **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 4 (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. **RESOLVED (chunk 4):** `CommitSetPosition`'s `None`/`Preserve`/`Replace` actions (`RefreshPositionRows`/`ReplacePositionRows`) now publish the retail render product from the SAME exact collision cell list the move just used (`PublishRetailProductFromExactCells`) — the independent Contract A recompute chunk 1/3 ran on every move (`RecomputeRetailCellArrayIfPresent`) is deleted. Mirrors retail `SetPositionInternal`'s `add_shadows_to_cells(this, &arg2->cell_array)` (pc:283536-283537), which consumes the transition's own array for both products in one call; the `num_cells > 0` keep-when-empty gate (pc:283540) now covers both products identically | `_entityToCells` + `_cells`, and (when a part array is retained) `_retailCellArrays`/`_retailPartEntriesByCell`, both from the SAME exact cell list on every move | spawn and every accepted move/appearance change | physics broadphase; `WalkProductionWorldData` dynamic render lookup (now a direct `TryGetRetailCellArray` read, see below) |
|
||||||
| **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 |
|
| **Chunk 4 (2026-09-02).** The staged `ShadowObjectRegistry.TryPrepareSetPosition`/`TryApplySetPosition` pipeline | `PreparedShadowOwnerState` carries `RetailPartArray`/`RetailCellIds`/`RetailRoute`/`RetailRows`; `TryCaptureOwnerState`/`InstallOwnerState` seed them so the staging registry's own `CommitSetPosition` call inherits chunk 4's one-array-two-products move path for free; `PrepareRetailPartEntryReplacements` diffs the staged retail rows the same way `PrepareCellReplacements` diffs collision rows; `TryApplySetPosition` additionally calls `RepublishAttachedChildren` on the LIVE registry after publishing, since the staging registry carries none of the live attach-chain state | `_retailPartEntriesByCell` publishes alongside `_cells` on `TryApplySetPosition`; every attached child of the moved root republishes too | 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` |
|
| **Chunk 4 (2026-09-02).** `ShadowObjectRegistry.AttachChild`/`DetachChild` (`ShadowObjectRegistry.cs`) — retail's `add_shadows_to_cells` child-inheritance recursion (Contract B) | `EquippedChildRenderController.TryRealize` calls `AttachChild(childEntityId, rootEntityId, childPartArray)` at the exact moment the attached projection's `WorldEntity` is registered against its accepted parent, using `ShadowShapeBuilder.FromSetupRenderParts` over the SAME `PhysicsDataCache` resolvers `LiveEntityCollisionBuilder` uses; `CommitProjectionRemoval` (the single withdrawal/unparent/teardown funnel) calls `DetachChild`. A nested attachment (child of a child) resolves to the ultimate root by walking the existing chain, bounded and cycle-safe | new `_childParent`/`_parentChildren`/`_childPartArrays` dictionaries (own `CollisionWorldState` fields, reset with everything else); PART ENTRIES only for the child — never a collision row, since acdream's attached projections own no independent collision shapes | attach/detach, and every time the root's array republishes (registration, move, staged apply, `ReplaceMultiPartPayload`) | `WalkProductionWorldData.ResolveDynamicRenderCells` (a plain `TryGetRetailCellArray` read on the CHILD's own id — no parent walk) |
|
||||||
|
| **RESOLVED (chunk 4).** `WalkProductionWorldData.ResolveDynamicRenderCells` (`WalkProductionWorldData.cs`) | `ShadowObjectRegistry.TryGetRetailCellArray` directly, for every dynamic record including equipped children (their array is published at attach time by the registry, above); unregistered fallback is `GetOwnerCells`, counted by the SAME `UnregisteredStaticRenderFallbackCount` the static buckets use — the render-side 64-hop parent-chain walk and its `FindParentLocalId` plumbing into this class are deleted | 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) |
|
| `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) |
|
| `DirectionalShadowCasterFrame` (`DirectionalShadowCasterFrame.cs:229-233`) | flat outdoor indices, no cell filter | caster list | per frame | shadow pass (S5) |
|
||||||
| Topology: `EnvCellLandblockBuildBuilder.BuildVisibilityCell` vs `WalkCellFactory.FromParsed` vs `FlatEnvCellTopology` | same DAT / pak | three distinct portal graphs (`LoadedCell`, `WalkCell`, flat asset) | per landblock build | render, walk, `CellTransit` |
|
| Topology: `EnvCellLandblockBuildBuilder.BuildVisibilityCell` vs `WalkCellFactory.FromParsed` vs `FlatEnvCellTopology` | same DAT / pak | three distinct portal graphs (`LoadedCell`, `WalkCell`, flat asset) | per landblock build | render, walk, `CellTransit` |
|
||||||
|
|
@ -54,8 +55,12 @@ Findings that shape the chunks:
|
||||||
registration that carries a real part array.
|
registration that carries a real part array.
|
||||||
- Render membership is whole-object per cell; retail's is every part per
|
- Render membership is whole-object per cell; retail's is every part per
|
||||||
cell (identity retained per part, with cell frame and clip planes).
|
cell (identity retained per part, with cell frame and clip planes).
|
||||||
- Children are inherited only on the render side, at bucket time; retail
|
- **RESOLVED (chunk 4).** Children were inherited only on the render side, at
|
||||||
inherits at registration.
|
bucket time (a 64-hop parent-chain walk); retail inherits at registration
|
||||||
|
(`add_shadows_to_cells`'s recursion through `children`). Now
|
||||||
|
`ShadowObjectRegistry.AttachChild` owns the inheritance at the registry, at
|
||||||
|
attach time, and re-propagates on every root array change; the render-side
|
||||||
|
walk is deleted.
|
||||||
- `b3b7d922` removed the indoor-seed stab-list prune inside the shared
|
- `b3b7d922` removed the indoor-seed stab-list prune inside the shared
|
||||||
`BuildShadowCellSetFromParts`, affecting every caller. Retail has no such
|
`BuildShadowCellSetFromParts`, affecting every caller. Retail has no such
|
||||||
prune in the bbox route (Contract A), so the removal is the retail
|
prune in the bbox route (Contract A), so the removal is the retail
|
||||||
|
|
@ -106,9 +111,11 @@ gates: Core physics suites, the placement/collision replay fixtures, and the
|
||||||
connected nine-stop route; `CellTransit`'s prune removal is confirmed
|
connected nine-stop route; `CellTransit`'s prune removal is confirmed
|
||||||
against Contract A.
|
against Contract A.
|
||||||
|
|
||||||
**Chunk 4 — dynamics and children.** `add_shadows_to_cells` recursion:
|
**Chunk 4 (landed 2026-09-02) — dynamics and children.** `add_shadows_to_cells`
|
||||||
children register with the root's CELLARRAY at registration and movement;
|
recursion: children register with the root's CELLARRAY at registration and
|
||||||
particles own-cell only; the render-side parent-chain walk is deleted.
|
movement; particles own-cell only (still no production path reaches this
|
||||||
|
registry — chunk 3's finding stands, deferred); the render-side parent-chain
|
||||||
|
walk is deleted. See §7.
|
||||||
|
|
||||||
**Chunk 5 — consumer cutover and deletion.** Remaining `WalkProductionWorldData`
|
**Chunk 5 — consumer cutover and deletion.** Remaining `WalkProductionWorldData`
|
||||||
buckets become borrowed views; particle owner-cell union reads the same;
|
buckets become borrowed views; particle owner-cell union reads the same;
|
||||||
|
|
@ -328,3 +335,147 @@ branch to implement in this registry today; the retail
|
||||||
`add_particle_shadow_to_cell` own-cell-only rule remains chunk 4 (or later)
|
`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
|
territory, to be implemented if/when particle emitters are ever routed
|
||||||
through this registry.
|
through this registry.
|
||||||
|
|
||||||
|
## 7. Chunk 4 evidence (2026-09-02)
|
||||||
|
|
||||||
|
**Move-path truth confirmed in the pseudo-C (item A).** `CPhysicsObj::SetPositionInternal`
|
||||||
|
`0x00515330` (`docs/research/named-retail/acclient_2013_pseudo_c.txt` lines
|
||||||
|
283399-283543) proves the movement product is retail's SAME
|
||||||
|
`add_shadows_to_cells` call the registration path uses — not a second,
|
||||||
|
independently-flooded array:
|
||||||
|
|
||||||
|
```text
|
||||||
|
if (this->cell != 0)
|
||||||
|
{
|
||||||
|
if ((this->state & 0x10000) != 0)
|
||||||
|
{
|
||||||
|
CPhysicsObj::calc_cross_cells(this); // full recompute (pc:283530)
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (arg2->cell_array.num_cells > 0) // keep-when-empty gate (pc:283534/283540)
|
||||||
|
{
|
||||||
|
CPhysicsObj::remove_shadows_from_cells(this);
|
||||||
|
CPhysicsObj::add_shadows_to_cells(this, &arg2->cell_array); // pc:283536-283537
|
||||||
|
}
|
||||||
|
// else: neither product is touched — both stay exactly as they were.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`arg2` is the `CTransition` the sphere-path walk already produced; its
|
||||||
|
`cell_array` is the SAME array retail's collision system computed during the
|
||||||
|
move — not Contract A's `calc_cross_cells_static` dispatch (that only runs at
|
||||||
|
registration, or via the `state & 0x10000` full-recompute branch, which
|
||||||
|
acdream already maps to `PhysicsShadowCommitAction.Recalculate` →
|
||||||
|
`UpdatePosition`, unaffected by this chunk). `CPhysicsObj::calc_cross_cells`
|
||||||
|
`0x00515230` (lines 283332-283395) confirms the full-recompute branch is a
|
||||||
|
DIFFERENT primitive (its own cylsphere/bbox dispatch, ending in the same
|
||||||
|
`remove_shadows_from_cells` + `add_shadows_to_cells` pair) — chunk 4 leaves it
|
||||||
|
untouched.
|
||||||
|
|
||||||
|
**Chunk 4 changes, exactly as the pseudo-C requires:**
|
||||||
|
|
||||||
|
- `ShadowObjectRegistry.ReplacePositionRows` now calls the new
|
||||||
|
`PublishRetailProductFromExactCells(entityId, exactCells)` — the SAME
|
||||||
|
`exactCells` list collision just republished into — instead of
|
||||||
|
`RecomputeRetailCellArrayIfPresent`'s independent Contract A flood.
|
||||||
|
`PublishRetailProductFromExactCells` is a thin wrapper over the existing
|
||||||
|
`PublishRetailCellArray`, reusing the entity's LAST recorded dispatch route
|
||||||
|
(registration never re-runs on a move, matching the pseudo-C: no
|
||||||
|
`calc_cross_cells_static` call anywhere in `SetPositionInternal`'s
|
||||||
|
non-full-recompute branch).
|
||||||
|
- `ShadowObjectRegistry.RefreshPositionRows`'s "nothing retained at all"
|
||||||
|
branch (both `_entityToCells` and `_suspendedEntityCells` empty) no longer
|
||||||
|
calls anything — true keep-when-empty, matching the pseudo-C's `else`
|
||||||
|
(neither product touched).
|
||||||
|
- `RecomputeRetailCellArrayIfPresent` and the now-unreachable
|
||||||
|
`RecomputeRetailCellArray` are deleted outright (S2 plan rule "no
|
||||||
|
competing production owner").
|
||||||
|
- **Scope-decision reversal from chunk 3's §6 open question:** chunk 3 left
|
||||||
|
the move path as two legitimately-different computations (transition
|
||||||
|
cross-cells vs. Contract A's part-array flood) and flagged it for chunk 4's
|
||||||
|
retail review. The pseudo-C resolves it: retail's OWN move path never runs
|
||||||
|
Contract A at all; it reuses the transition's array for both products. The
|
||||||
|
"two computations" framing was itself the deviation from retail — chunk 4
|
||||||
|
removes it, not preserves it.
|
||||||
|
- **Test consequence:** `ShadowObjectRegistryRetailCellArrayTests.CommitSetPosition_WithRetainedPartArray_RecomputesRetailCellArray`
|
||||||
|
encoded exactly the two-source behavior this chunk removes (a `.None`
|
||||||
|
commit whose retail array independently flooded to the entity's NEW world
|
||||||
|
position while collision stayed at the OLD retained cell). It is replaced
|
||||||
|
by `CommitSetPosition_NoneAction_PublishesRetailProductFromTheRetainedCells`
|
||||||
|
(retail array == `GetOwnerCells`, both at the retained cell) plus two new
|
||||||
|
fixtures covering `.Replace` (both move to the transition's cross cells)
|
||||||
|
and the direct-path keep-when-empty case. The new invariant, pinned across
|
||||||
|
every `CommitSetPosition` variant and the staged apply: **retail array ==
|
||||||
|
`GetOwnerCells` after every move.**
|
||||||
|
|
||||||
|
**Children (item B/C), landed exactly as designed.** `ShadowObjectRegistry`
|
||||||
|
gains `AttachChild(childEntityId, rootEntityId, childPartArray)` /
|
||||||
|
`DetachChild(childEntityId)`, backed by three new `CollisionWorldState`
|
||||||
|
dictionaries (`ShadowChildParent`, `ShadowParentChildren`,
|
||||||
|
`ShadowChildPartArrays` — reset by `AttachCollisionWorld`'s state swap and by
|
||||||
|
`Clear()` exactly like every other per-entity table). `AttachChild` publishes
|
||||||
|
PART ENTRIES only (never a collision row — acdream's attached projections own
|
||||||
|
no independent collision shapes) into every cell of the root's CURRENT
|
||||||
|
`_retailCellArrays` entry; a nested attachment (child of a child) resolves to
|
||||||
|
the ultimate root by walking the existing `_childParent` chain, bounded at 64
|
||||||
|
hops and rejecting a cycle before any state mutates. `RepublishAttachedChildren`
|
||||||
|
is called from every site that publishes a root's retail cell array —
|
||||||
|
`PublishRetailCellArray` (covers registration and, via
|
||||||
|
`PublishRetailProductFromExactCells`, movement), `TryApplySetPosition`
|
||||||
|
(staged apply, since the staging registry that computed the prepared commit
|
||||||
|
carries none of the live registry's attach-chain state), and
|
||||||
|
`ReplaceMultiPartPayload` (defensive completeness; the root's cell SET never
|
||||||
|
actually changes there, so this is a structural no-op in practice) — and
|
||||||
|
recurses depth-first through `_parentChildren` in attach order, matching
|
||||||
|
`add_shadows_to_cells`'s exact recursion through `children`. `Deregister`
|
||||||
|
cascades a genuine teardown through every attached child (guarded so the
|
||||||
|
INTERNAL "clear then re-register" idiom `Register`/`RegisterMultiPart` use
|
||||||
|
ahead of their own flood — `DeregisterCore(..., publishMutation: false)` —
|
||||||
|
never detaches children it should be re-publishing to instead).
|
||||||
|
|
||||||
|
**App wiring (item C).** `EquippedChildRenderController.TryRealize` — the one
|
||||||
|
seam that both `OnCreateParentAccepted` and `OnParentEvent` funnel through to
|
||||||
|
materialize an attached projection's `WorldEntity` — calls `AttachChild`
|
||||||
|
right after `_attachedByChild[childKey] = attached`, using a new
|
||||||
|
`BuildChildRenderParts` helper: `ShadowShapeBuilder.FromSetupRenderParts`
|
||||||
|
over the child's `Setup` + its post-`AnimPartChanged` `template` (already
|
||||||
|
built by `BuildPartTemplate` for the pose composition) + the controller's own
|
||||||
|
new `PhysicsDataCache` field — the SAME resolver pair
|
||||||
|
`LiveEntityCollisionBuilder.Build` uses, wired from the SAME composition
|
||||||
|
point (`d.PhysicsDataCache`, `LivePresentationComposition.cs`) that already
|
||||||
|
supplies `LiveEntityCollisionBuilder`. `CommitProjectionRemoval` — the single
|
||||||
|
funnel every withdrawal/unparent/teardown path in the file commits removal
|
||||||
|
through — calls `DetachChild(child.Entity.Id)`. No Core→App dependency: both
|
||||||
|
`ShadowObjectRegistry` and `PhysicsDataCache` are Core types the App layer
|
||||||
|
already depends on; the controller (App) references Core, never the reverse.
|
||||||
|
|
||||||
|
`WalkProductionWorldData.ResolveDynamicRenderCells` is now a direct
|
||||||
|
`TryGetRetailCellArray` read (the same recipe `ResolveOutdoorStaticRenderCells`
|
||||||
|
already used for statics) for EVERY dynamic record, children included — no
|
||||||
|
special-casing on `RenderCasterIdentityKind.EquippedChild` any more, since the
|
||||||
|
registry now answers correctly for a child on its own id. The render-side
|
||||||
|
64-hop parent-chain walk, its `_findParentLocalId` field, and the
|
||||||
|
`findParentLocalId` constructor parameter threaded through
|
||||||
|
`WalkProductionWorldData` → `RetailPViewRenderer` → `FrameRootComposition`
|
||||||
|
are deleted; `EquippedChildRenderController.FindParentLocalId` itself is
|
||||||
|
UNCHANGED and keeps serving its other caller (`EntityEffectController`'s pose
|
||||||
|
composition, `LivePresentationComposition.cs`). An entity with no retail
|
||||||
|
array yet falls back to `GetOwnerCells`, counted by the SAME
|
||||||
|
`UnregisteredStaticRenderFallbackCount` the static buckets already use (item
|
||||||
|
C's explicit requirement — one fallback counter, not a second one for
|
||||||
|
dynamics).
|
||||||
|
|
||||||
|
**Particles (item D).** Unchanged from chunk 3's finding: no production code
|
||||||
|
path sets the particle-emitter state bit or routes an emitter through
|
||||||
|
`ShadowObjectRegistry` at all (`AcDream.App/Rendering/ParticleRenderer.cs`
|
||||||
|
owns emitter placement independently via a GPU mesh-reference table). Left as
|
||||||
|
a documented chunk-5-or-later item; this chunk invents no path for it.
|
||||||
|
|
||||||
|
**Automated evidence.** Core suite `Lane!=Timing&Lane!=Linux`: 4,970/4,970 (was
|
||||||
|
4,961; +9 new `ShadowObjectRegistryRetailCellArrayTests` fixtures — 2 replacing
|
||||||
|
the retargeted move-path test, 7 covering `AttachChild`/`DetachChild`,
|
||||||
|
nesting, and cycle rejection). App suite (hermetic lanes): 6,761/6,761 (was
|
||||||
|
6,760; net +1 — the old `ResolveDynamicRenderCells_EquippedDescendantInheritsRootCellArray`
|
||||||
|
parent-chain-walk test replaced by two direct-registry tests). Runtime suite:
|
||||||
|
1,884/1,884 (unchanged — chunk 4 touched no Runtime source). Full solution
|
||||||
|
Release build: 0 warnings, 0 errors.
|
||||||
|
|
|
||||||
|
|
@ -507,8 +507,7 @@ internal sealed class FrameRootCompositionPhase
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"The retail frame walk requires the landscape registry."),
|
"The retail frame walk requires the landscape registry."),
|
||||||
d.CellVisibility,
|
d.CellVisibility,
|
||||||
d.PhysicsEngine.ShadowObjects,
|
d.PhysicsEngine.ShadowObjects),
|
||||||
live.EquippedChildren.FindParentLocalId),
|
|
||||||
retailPViewPassExecutor,
|
retailPViewPassExecutor,
|
||||||
retailPViewPassExecutor),
|
retailPViewPassExecutor),
|
||||||
retailPViewCells,
|
retailPViewCells,
|
||||||
|
|
|
||||||
|
|
@ -622,7 +622,9 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
childRecord,
|
childRecord,
|
||||||
positionVersion,
|
positionVersion,
|
||||||
projectionVersion,
|
projectionVersion,
|
||||||
d.PlayerIdentity.ServerGuid)),
|
d.PlayerIdentity.ServerGuid),
|
||||||
|
d.PhysicsEngine.ShadowObjects,
|
||||||
|
d.PhysicsDataCache),
|
||||||
static value => value.Dispose());
|
static value => value.Dispose());
|
||||||
Fault(LivePresentationCompositionPoint.CorePresentationCreated);
|
Fault(LivePresentationCompositionPoint.CorePresentationCreated);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,28 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
_withdrawProjection;
|
_withdrawProjection;
|
||||||
private readonly EntityEffectPoseRegistry _poses;
|
private readonly EntityEffectPoseRegistry _poses;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign OVERHAUL S2 chunk 4: the SAME canonical shadow registry
|
||||||
|
/// <c>LiveEntityCollisionBuilder</c> publishes into — retail's
|
||||||
|
/// <c>add_shadows_to_cells</c> child-inheritance recursion (Contract B)
|
||||||
|
/// needs an attached child's visual part array registered against its
|
||||||
|
/// accepted parent's CURRENT retail CELLARRAY. Attached projections
|
||||||
|
/// (equipped weapons/shields/ammunition) never carry independent
|
||||||
|
/// collision shapes, so this owner ONLY calls
|
||||||
|
/// <see cref="ShadowObjectRegistry.AttachChild"/>/<see cref="ShadowObjectRegistry.DetachChild"/>
|
||||||
|
/// — never Register/RegisterMultiPart.
|
||||||
|
/// </summary>
|
||||||
|
private readonly ShadowObjectRegistry _shadows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign OVERHAUL S2 chunk 4: the SAME <c>PhysicsDataCache</c> resolver
|
||||||
|
/// pair <see cref="AcDream.Runtime.Physics.LiveEntityCollisionBuilder"/>
|
||||||
|
/// uses for <c>ShadowShapeBuilder.FromSetupRenderParts</c> — a static and
|
||||||
|
/// a live Setup sharing a GfxObj id can never disagree about its render
|
||||||
|
/// geometry.
|
||||||
|
/// </summary>
|
||||||
|
private readonly PhysicsDataCache _physicsData;
|
||||||
|
|
||||||
private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
|
private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
|
||||||
|
|
||||||
/// <summary>Raised after the attached projection is fully registered.</summary>
|
/// <summary>Raised after the attached projection is fully registered.</summary>
|
||||||
|
|
@ -98,7 +120,9 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
EntityEffectPoseRegistry poses,
|
EntityEffectPoseRegistry poses,
|
||||||
Func<ParentEvent.Parsed, bool> acceptParent,
|
Func<ParentEvent.Parsed, bool> acceptParent,
|
||||||
Func<LiveEntityRecord, ulong, ulong, ExactProjectionWithdrawalOutcome>
|
Func<LiveEntityRecord, ulong, ulong, ExactProjectionWithdrawalOutcome>
|
||||||
withdrawProjection)
|
withdrawProjection,
|
||||||
|
ShadowObjectRegistry shadows,
|
||||||
|
PhysicsDataCache physicsData)
|
||||||
{
|
{
|
||||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||||
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
||||||
|
|
@ -108,6 +132,8 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
|
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
|
||||||
_withdrawProjection = withdrawProjection
|
_withdrawProjection = withdrawProjection
|
||||||
?? throw new ArgumentNullException(nameof(withdrawProjection));
|
?? throw new ArgumentNullException(nameof(withdrawProjection));
|
||||||
|
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
|
||||||
|
_physicsData = physicsData ?? throw new ArgumentNullException(nameof(physicsData));
|
||||||
_parentOfAttached = static child => child.ParentRecord.ProjectionKey;
|
_parentOfAttached = static child => child.ParentRecord.ProjectionKey;
|
||||||
_tickAttached = TickChild;
|
_tickAttached = TickChild;
|
||||||
_reconcileAttached = ReconcileChild;
|
_reconcileAttached = ReconcileChild;
|
||||||
|
|
@ -659,6 +685,15 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
CaptureParentPresentation(attached, parentEntity);
|
CaptureParentPresentation(attached, parentEntity);
|
||||||
RuntimeEntityKey childKey = RequireProjectionKey(childRecord);
|
RuntimeEntityKey childKey = RequireProjectionKey(childRecord);
|
||||||
_attachedByChild[childKey] = attached;
|
_attachedByChild[childKey] = attached;
|
||||||
|
// Campaign OVERHAUL S2 chunk 4: register the retail render-shadow
|
||||||
|
// inheritance (Contract B) at the exact moment the attached
|
||||||
|
// projection's WorldEntity is registered against its accepted
|
||||||
|
// parent — retail's add_shadows_to_cells child recursion, ported at
|
||||||
|
// the registry rather than reconstructed at render/bucket time.
|
||||||
|
_shadows.AttachChild(
|
||||||
|
entity.Id,
|
||||||
|
parentEntity.Id,
|
||||||
|
BuildChildRenderParts(childSetup, template, scale));
|
||||||
_pendingUnparentByChild.Remove(childKey);
|
_pendingUnparentByChild.Remove(childKey);
|
||||||
if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0)
|
if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0)
|
||||||
{
|
{
|
||||||
|
|
@ -1147,6 +1182,38 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
return available;
|
return available;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign OVERHAUL S2 chunk 4: retail's per-part render membership
|
||||||
|
/// (<c>CPartArray::AddPartsShadow</c> 0x00517e40 walks EVERY visual
|
||||||
|
/// part, not just the BSP-only collision dispatch) for one attached
|
||||||
|
/// child, via <see cref="ShadowShapeBuilder.FromSetupRenderParts"/> — the
|
||||||
|
/// SAME builder and the SAME <see cref="_physicsData"/> resolver pair
|
||||||
|
/// <c>LiveEntityCollisionBuilder.Build</c> uses for an ordinary live
|
||||||
|
/// Setup, so a static, a live entity, and an attached child sharing a
|
||||||
|
/// GfxObj id can never disagree about its render geometry.
|
||||||
|
/// <paramref name="template"/>'s per-part GfxObj identity (post-
|
||||||
|
/// AnimPartChanged, <see cref="BuildPartTemplate"/>) supplies
|
||||||
|
/// <c>effectivePartGfxObjIds</c>; the child's own attach-composed pose
|
||||||
|
/// carries no per-part local-position meaning for THIS product — retail
|
||||||
|
/// never re-floods to place a child, it only inherits the root's
|
||||||
|
/// CELLARRAY (Contract B) — so no pose override is threaded here,
|
||||||
|
/// matching <c>FromSetupRenderParts</c>'s own placement-frame fallback.
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyList<ShadowShape> BuildChildRenderParts(
|
||||||
|
Setup setup, IReadOnlyList<MeshRef> template, float scale)
|
||||||
|
{
|
||||||
|
var effectiveGfxObjIds = new uint[template.Count];
|
||||||
|
for (int i = 0; i < template.Count; i++)
|
||||||
|
effectiveGfxObjIds[i] = template[i].GfxObjId;
|
||||||
|
return ShadowShapeBuilder.FromSetupRenderParts(
|
||||||
|
setup,
|
||||||
|
scale,
|
||||||
|
effectiveGfxObjIds,
|
||||||
|
partPoseOverride: null,
|
||||||
|
_physicsData.GetGfxObj,
|
||||||
|
_physicsData.GetVisualBounds);
|
||||||
|
}
|
||||||
|
|
||||||
private static PaletteOverride? BuildPaletteOverride(WorldSession.EntitySpawn spawn)
|
private static PaletteOverride? BuildPaletteOverride(WorldSession.EntitySpawn spawn)
|
||||||
{
|
{
|
||||||
if (spawn.SubPalettes is not { Count: > 0 } subPalettes)
|
if (spawn.SubPalettes is not { Count: > 0 } subPalettes)
|
||||||
|
|
@ -1388,6 +1455,12 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
_attachedByChild.Remove(key);
|
_attachedByChild.Remove(key);
|
||||||
|
// Campaign OVERHAUL S2 chunk 4: this is the single funnel every
|
||||||
|
// withdrawal/unparent/teardown path (WithdrawAttachedProjection,
|
||||||
|
// AdvanceUnparentTransition, TearDownRecordProjections, the pending-
|
||||||
|
// subtree captures) commits through — the exact mirror of
|
||||||
|
// AttachChild above.
|
||||||
|
_shadows.DetachChild(child.Entity.Id);
|
||||||
ProjectionRemoved?.Invoke(child.ChildRecord);
|
ProjectionRemoved?.Invoke(child.ChildRecord);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,7 @@ internal sealed class RetailPViewRenderer
|
||||||
Walk.WalkBuildingRegistry walkBuildings,
|
Walk.WalkBuildingRegistry walkBuildings,
|
||||||
Walk.WalkLandscapeAssembler walkLandscape,
|
Walk.WalkLandscapeAssembler walkLandscape,
|
||||||
CellVisibility walkCellRegistry,
|
CellVisibility walkCellRegistry,
|
||||||
ShadowObjectRegistry shadows,
|
ShadowObjectRegistry shadows)
|
||||||
Func<uint, uint?>? findParentLocalId = null)
|
|
||||||
{
|
{
|
||||||
_renderSceneShadow = renderSceneShadow
|
_renderSceneShadow = renderSceneShadow
|
||||||
?? throw new ArgumentNullException(nameof(renderSceneShadow));
|
?? throw new ArgumentNullException(nameof(renderSceneShadow));
|
||||||
|
|
@ -91,8 +90,7 @@ internal sealed class RetailPViewRenderer
|
||||||
?? throw new ArgumentNullException(nameof(walkCellRegistry));
|
?? throw new ArgumentNullException(nameof(walkCellRegistry));
|
||||||
_walkWorldData = new Walk.WalkProductionWorldData(
|
_walkWorldData = new Walk.WalkProductionWorldData(
|
||||||
_walkBuildings,
|
_walkBuildings,
|
||||||
shadows ?? throw new ArgumentNullException(nameof(shadows)),
|
shadows ?? throw new ArgumentNullException(nameof(shadows)));
|
||||||
findParentLocalId);
|
|
||||||
_walkClearInteriorDepthAction = ClearWalkInteriorDepth;
|
_walkClearInteriorDepthAction = ClearWalkInteriorDepth;
|
||||||
_walkDrawExitSealsAction = DrawWalkExitSeals;
|
_walkDrawExitSealsAction = DrawWalkExitSeals;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
||||||
{
|
{
|
||||||
private readonly WalkBuildingRegistry _buildings;
|
private readonly WalkBuildingRegistry _buildings;
|
||||||
private readonly ShadowObjectRegistry _shadows;
|
private readonly ShadowObjectRegistry _shadows;
|
||||||
private readonly Func<uint, uint?> _findParentLocalId;
|
|
||||||
private RenderSceneQuery _scene;
|
private RenderSceneQuery _scene;
|
||||||
private uint _tupleLandblockId;
|
private uint _tupleLandblockId;
|
||||||
private int _renderCenterLbX;
|
private int _renderCenterLbX;
|
||||||
|
|
@ -107,17 +106,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
||||||
|
|
||||||
internal WalkProductionWorldData(
|
internal WalkProductionWorldData(
|
||||||
WalkBuildingRegistry buildings,
|
WalkBuildingRegistry buildings,
|
||||||
ShadowObjectRegistry shadows,
|
ShadowObjectRegistry shadows)
|
||||||
Func<uint, uint?>? findParentLocalId = null)
|
|
||||||
{
|
{
|
||||||
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
|
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
|
||||||
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
|
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
|
||||||
_findParentLocalId = findParentLocalId ?? NoParentLocalId;
|
|
||||||
_tryGetRetailCellArray = TryGetRetailCellArrayForEntity;
|
_tryGetRetailCellArray = TryGetRetailCellArrayForEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static uint? NoParentLocalId(uint _) => null;
|
|
||||||
|
|
||||||
private (bool Found, IReadOnlyList<uint> Cells) TryGetRetailCellArrayForEntity(uint entityId)
|
private (bool Found, IReadOnlyList<uint> Cells) TryGetRetailCellArrayForEntity(uint entityId)
|
||||||
{
|
{
|
||||||
bool found = _shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> cells);
|
bool found = _shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> cells);
|
||||||
|
|
@ -264,8 +259,11 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
||||||
ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i];
|
ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i];
|
||||||
IReadOnlyList<uint> renderCells = ResolveDynamicRenderCells(
|
IReadOnlyList<uint> renderCells = ResolveDynamicRenderCells(
|
||||||
in record,
|
in record,
|
||||||
|
_tryGetRetailCellArray,
|
||||||
_shadows.GetOwnerCells,
|
_shadows.GetOwnerCells,
|
||||||
_findParentLocalId);
|
out bool usedFallback);
|
||||||
|
if (usedFallback)
|
||||||
|
UnregisteredStaticRenderFallbackCount++;
|
||||||
BucketDynamicRecord(
|
BucketDynamicRecord(
|
||||||
in record,
|
in record,
|
||||||
renderCells,
|
renderCells,
|
||||||
|
|
@ -277,48 +275,39 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail <c>CPhysicsObj::add_shadows_to_cells</c> recursively passes the
|
/// Campaign OVERHAUL S2 chunk 4: one dynamic record's render-cell
|
||||||
/// root object's same CELLARRAY to every child in its CHILDLIST. Attached
|
/// membership, borrowed from <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>
|
||||||
/// projections deliberately own no independent collision rows in acdream,
|
/// — the SAME direct read <see cref="ResolveOutdoorStaticRenderCells"/>
|
||||||
/// so walk their accepted parent chain until the root's retained array is
|
/// uses. An equipped child's retail array is now published by
|
||||||
/// found. Ordinary dynamics continue to consume only their own rows.
|
/// <see cref="ShadowObjectRegistry.AttachChild"/> at the registry (retail
|
||||||
|
/// Contract B's <c>add_shadows_to_cells</c> child-inheritance recursion),
|
||||||
|
/// so this no longer needs its own render-side parent-chain walk — the
|
||||||
|
/// registry already resolved a nested attachment to its ultimate root.
|
||||||
|
/// When the registry has no retail array yet for this entity (the same
|
||||||
|
/// streaming-window race <see cref="ResolveIndoorStaticRenderCells"/>
|
||||||
|
/// documents), the fallback is today's collision-flood
|
||||||
|
/// <see cref="ShadowObjectRegistry.GetOwnerCells"/> answer, counted the
|
||||||
|
/// same way as the static fallbacks via <paramref name="usedFallback"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static IReadOnlyList<uint> ResolveDynamicRenderCells(
|
internal static IReadOnlyList<uint> ResolveDynamicRenderCells(
|
||||||
in RenderProjectionRecord record,
|
in RenderProjectionRecord record,
|
||||||
|
Func<uint, (bool Found, IReadOnlyList<uint> Cells)> tryGetRetailCellArray,
|
||||||
Func<uint, IReadOnlyList<uint>> getOwnerCells,
|
Func<uint, IReadOnlyList<uint>> getOwnerCells,
|
||||||
Func<uint, uint?> findParentLocalId)
|
out bool usedFallback)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
|
||||||
ArgumentNullException.ThrowIfNull(getOwnerCells);
|
ArgumentNullException.ThrowIfNull(getOwnerCells);
|
||||||
ArgumentNullException.ThrowIfNull(findParentLocalId);
|
|
||||||
|
|
||||||
uint current = record.Source.LocalEntityId;
|
(bool found, IReadOnlyList<uint> cells) =
|
||||||
IReadOnlyList<uint> cells = getOwnerCells(current);
|
tryGetRetailCellArray(record.Source.LocalEntityId);
|
||||||
if (cells.Count > 0
|
if (found)
|
||||||
|| record.EntityPayload.CasterIdentity
|
|
||||||
!= RenderCasterIdentityKind.EquippedChild)
|
|
||||||
{
|
{
|
||||||
|
usedFallback = false;
|
||||||
return cells;
|
return cells;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParentAttachmentState rejects cycles. Keep a hard bound here so a
|
usedFallback = true;
|
||||||
// corrupted diagnostic callback still cannot stall a render frame.
|
return getOwnerCells(record.Source.LocalEntityId);
|
||||||
for (int depth = 0; depth < 64; depth++)
|
|
||||||
{
|
|
||||||
uint? parent = findParentLocalId(current);
|
|
||||||
if (parent is not { } parentId
|
|
||||||
|| parentId == 0u
|
|
||||||
|| parentId == current)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
current = parentId;
|
|
||||||
cells = getOwnerCells(current);
|
|
||||||
if (cells.Count > 0)
|
|
||||||
return cells;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.Empty<uint>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,17 @@ internal sealed class CollisionWorldState
|
||||||
ShadowEntityRetailCellArrayRoutes { get; } = new();
|
ShadowEntityRetailCellArrayRoutes { get; } = new();
|
||||||
internal Dictionary<uint, List<RetailPartEntry>> RetailPartEntriesByCell { get; } = new();
|
internal Dictionary<uint, List<RetailPartEntry>> RetailPartEntriesByCell { get; } = new();
|
||||||
|
|
||||||
|
// ── Campaign OVERHAUL S2 chunk 4 ────────────────────────────────────────
|
||||||
|
// Retail's add_shadows_to_cells (0x00514ae0) child-inheritance recursion
|
||||||
|
// (oh1-construction-landscape-contract.md Contract B): an attached
|
||||||
|
// projection (equipped weapon/shield/ammunition) owns no independent
|
||||||
|
// collision shapes and inherits its root's retail CELLARRAY instead of
|
||||||
|
// flooding its own. See ShadowObjectRegistry.AttachChild/DetachChild.
|
||||||
|
internal Dictionary<uint, uint> ShadowChildParent { get; } = new();
|
||||||
|
internal Dictionary<uint, List<uint>> ShadowParentChildren { get; } = new();
|
||||||
|
internal Dictionary<uint, IReadOnlyList<ShadowShape>>
|
||||||
|
ShadowChildPartArrays { get; } = new();
|
||||||
|
|
||||||
// ── O1 per-prefix installed-key ledgers ────────────────────────────────
|
// ── O1 per-prefix installed-key ledgers ────────────────────────────────
|
||||||
// Every mutation of the five landblock-scoped world maps goes through the
|
// Every mutation of the five landblock-scoped world maps goes through the
|
||||||
// typed helpers below so these ledgers stay exact. The seal's landblock-
|
// typed helpers below so these ledgers stay exact. The seal's landblock-
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,27 @@ public sealed class ShadowObjectRegistry
|
||||||
private Dictionary<uint, List<RetailPartEntry>> _retailPartEntriesByCell =>
|
private Dictionary<uint, List<RetailPartEntry>> _retailPartEntriesByCell =>
|
||||||
_collisionWorld.Current.RetailPartEntriesByCell;
|
_collisionWorld.Current.RetailPartEntriesByCell;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign OVERHAUL S2 chunk 4: immediate parent (attachment point) for
|
||||||
|
/// every entity attached via <see cref="AttachChild"/> — retail's
|
||||||
|
/// <c>add_shadows_to_cells</c> child-inheritance recursion (Contract B).
|
||||||
|
/// A nested attachment's ultimate root is resolved by walking this map;
|
||||||
|
/// see <see cref="ResolveAttachRoot"/>.
|
||||||
|
/// </summary>
|
||||||
|
private Dictionary<uint, uint> _childParent =>
|
||||||
|
_collisionWorld.Current.ShadowChildParent;
|
||||||
|
|
||||||
|
/// <summary>Direct children of one attachment point, in attach (child-list)
|
||||||
|
/// order — the reverse index of <see cref="_childParent"/>.</summary>
|
||||||
|
private Dictionary<uint, List<uint>> _parentChildren =>
|
||||||
|
_collisionWorld.Current.ShadowParentChildren;
|
||||||
|
|
||||||
|
/// <summary>Each attached child's OWN visual part array (its equipped
|
||||||
|
/// item's Setup parts), published into the root's cells by
|
||||||
|
/// <see cref="PublishChildEntries"/> — never flooded independently.</summary>
|
||||||
|
private Dictionary<uint, IReadOnlyList<ShadowShape>> _childPartArrays =>
|
||||||
|
_collisionWorld.Current.ShadowChildPartArrays;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// BR-7: per-entity registration arguments, kept so a registration can be
|
/// BR-7: per-entity registration arguments, kept so a registration can be
|
||||||
/// RE-RUN when more cells hydrate. Retail's equivalent is
|
/// RE-RUN when more cells hydrate. Retail's equivalent is
|
||||||
|
|
@ -400,61 +421,6 @@ public sealed class ShadowObjectRegistry
|
||||||
private PhysicsDataCache? _fallback;
|
private PhysicsDataCache? _fallback;
|
||||||
private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache;
|
private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retail's exact <c>CObjCell::find_cell_list</c>/<c>find_bbox_cell_list</c>
|
|
||||||
/// CELLARRAY (Contract A, re-verified 2026-09-01 through the live Ghidra
|
|
||||||
/// bridge at <c>127.0.0.1:8081</c> against <c>patchmem.gpr</c>) for one
|
|
||||||
/// entity's WHOLE part array, plus the sibling
|
|
||||||
/// <c>CPartArray::AddPartsShadow</c> (0x00517e40) per-cell part-entry
|
|
||||||
/// product, published into <see cref="_retailPartEntriesByCell"/>.
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// A CHUNK-1 SIDE PRODUCT ONLY (Campaign OVERHAUL S2 chunk 1,
|
|
||||||
/// `docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md`
|
|
||||||
/// §3): it never writes <see cref="_entityToCells"/> or
|
|
||||||
/// <see cref="_cells"/>, and nothing in production reads it yet.
|
|
||||||
/// </para>
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// Route — <c>CPhysicsObj::calc_cross_cells_static</c> 0x00515160's
|
|
||||||
/// branch table:
|
|
||||||
/// </para>
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>cylsphere (<c>CObjCell::find_cell_list</c> 0x0052b9f0, over the
|
|
||||||
/// authored CylSpheres, reusing <see cref="BuildFloodSpheres"/> — "today's
|
|
||||||
/// cylinder registration") when <c>(state & 0x10000) == 0</c> AND the
|
|
||||||
/// object's authored COLLISION dispatch (<paramref name="collisionShapes"/>,
|
|
||||||
/// retail <c>CPartArray::GetNumCylsphere() != 0</c>: the Setup's
|
|
||||||
/// CylSpheres) carries at least one
|
|
||||||
/// <see cref="ShadowCollisionType.Cylinder"/> shape. The visual part array
|
|
||||||
/// never decides the route: retail's cylspheres are Setup collision data,
|
|
||||||
/// not parts;</item>
|
|
||||||
/// <item>otherwise the bbox route (<c>CPhysicsObj::find_bbox_cell_list</c>
|
|
||||||
/// 0x00510fc0 via <see cref="CellTransit.BuildShadowCellSetFromParts"/>) —
|
|
||||||
/// every other case, including a BSP-bearing object (state bit set) and a
|
|
||||||
/// part array with no authored CylSpheres at all.</item>
|
|
||||||
/// </list>
|
|
||||||
/// </summary>
|
|
||||||
private void RecomputeRetailCellArray(
|
|
||||||
uint entityId,
|
|
||||||
uint seedCellId,
|
|
||||||
Vector3 entityWorldPos,
|
|
||||||
Quaternion entityWorldRot,
|
|
||||||
uint state,
|
|
||||||
IReadOnlyList<ShadowShape> collisionShapes,
|
|
||||||
IReadOnlyList<ShadowShape> partArray,
|
|
||||||
bool isStatic)
|
|
||||||
{
|
|
||||||
ClearRetailCellArray(entityId);
|
|
||||||
if (partArray.Count == 0 || seedCellId == 0u)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_entityRetailPartArrays[entityId] = partArray;
|
|
||||||
(IReadOnlyList<uint> cellArray, RetailCellArrayRoute route) = ComputeContractACellArray(
|
|
||||||
seedCellId, entityWorldPos, entityWorldRot, state, collisionShapes, partArray, isStatic);
|
|
||||||
PublishRetailCellArray(entityId, cellArray, route, partArray);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Campaign OVERHAUL S2 chunk 3: retail's <c>calc_cross_cells_static</c>
|
/// Campaign OVERHAUL S2 chunk 3: retail's <c>calc_cross_cells_static</c>
|
||||||
/// (0x00515160) dispatch + flood, PURE — no registry state is read or
|
/// (0x00515160) dispatch + flood, PURE — no registry state is read or
|
||||||
|
|
@ -523,6 +489,16 @@ public sealed class ShadowObjectRegistry
|
||||||
/// <c>remove_shadows_from_cells</c> symmetry for this side product. The
|
/// <c>remove_shadows_from_cells</c> symmetry for this side product. The
|
||||||
/// route is recorded even when <paramref name="cellArray"/> is empty —
|
/// route is recorded even when <paramref name="cellArray"/> is empty —
|
||||||
/// the dispatch decision was still made.
|
/// the dispatch decision was still made.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Campaign OVERHAUL S2 chunk 4: this is retail's ONE place a
|
||||||
|
/// <c>CPhysicsObj</c>'s CELLARRAY changes, so it is also the one place
|
||||||
|
/// that re-runs <see cref="RepublishAttachedChildren"/> — Contract B's
|
||||||
|
/// "after the root, add_shadows_to_cells recursively passes the same
|
||||||
|
/// CELLARRAY to every object in children". Harmless no-op on a scratch
|
||||||
|
/// staging registry (<see cref="TryPrepareSetPosition"/>), which never
|
||||||
|
/// carries any attach relationships of its own.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void PublishRetailCellArray(
|
private void PublishRetailCellArray(
|
||||||
uint entityId,
|
uint entityId,
|
||||||
|
|
@ -537,29 +513,43 @@ public sealed class ShadowObjectRegistry
|
||||||
}
|
}
|
||||||
_retailCellArrayRoutes[entityId] = route;
|
_retailCellArrayRoutes[entityId] = route;
|
||||||
if (cellArray.Count == 0)
|
if (cellArray.Count == 0)
|
||||||
|
{
|
||||||
|
RepublishAttachedChildren(entityId);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var orderedCells = new List<uint>(cellArray.Count);
|
var orderedCells = new List<uint>(cellArray.Count);
|
||||||
for (int i = 0; i < cellArray.Count; i++)
|
for (int i = 0; i < cellArray.Count; i++)
|
||||||
orderedCells.Add(cellArray[i]);
|
orderedCells.Add(cellArray[i]);
|
||||||
_retailCellArrays[entityId] = orderedCells;
|
_retailCellArrays[entityId] = orderedCells;
|
||||||
PublishRetailPartEntries(entityId, orderedCells, partArray);
|
PublishRetailPartEntries(entityId, orderedCells, partArray);
|
||||||
|
RepublishAttachedChildren(entityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recomputes <see cref="RecomputeRetailCellArray"/> at a new position
|
/// Campaign OVERHAUL S2 chunk 4: publishes the retail render product from
|
||||||
/// when — and only when — a retail part array is currently retained for
|
/// the SAME exact cell list the move-path collision republish
|
||||||
/// <paramref name="entityId"/>. No-op for every entity registered
|
/// (<see cref="ReplacePositionRows"/>) just used — no second,
|
||||||
/// without one (item E of the S2 chunk-1 contract: existing callers stay
|
/// independently-flooded cell array. Retail's
|
||||||
/// byte-for-byte unaffected).
|
/// <c>CPhysicsObj::SetPositionInternal</c> (pc:283399) does exactly this
|
||||||
|
/// on a successful transition: <c>add_shadows_to_cells(this,
|
||||||
|
/// &arg2->cell_array)</c> (pc:283536-283537) consumes the
|
||||||
|
/// TRANSITION's own array for BOTH the collision <c>CShadowObj</c> list
|
||||||
|
/// and the render <c>shadow_part_list</c> in one call — there is no
|
||||||
|
/// separate bbox/cylsphere dispatch on the move path
|
||||||
|
/// (<c>calc_cross_cells_static</c> only runs at registration, or at a
|
||||||
|
/// full recompute gated by the <c>HAS_PHYSICS_BSP_PS</c> state bit,
|
||||||
|
/// pc:283528, which acdream maps to <see cref="PhysicsShadowCommitAction.Recalculate"/>
|
||||||
|
/// → <see cref="UpdatePosition"/>, unaffected by this method).
|
||||||
|
/// A no-op (existing rows retained, matching retail's <c>num_cells >
|
||||||
|
/// 0</c> gate, pc:283540) when no retail part array is retained for this
|
||||||
|
/// entity — item E of the S2 chunk-1 contract: a caller that never
|
||||||
|
/// supplied a part array stays byte-for-byte unaffected on every move
|
||||||
|
/// too.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void RecomputeRetailCellArrayIfPresent(
|
private void PublishRetailProductFromExactCells(
|
||||||
uint entityId,
|
uint entityId,
|
||||||
uint seedCellId,
|
IReadOnlyList<uint> exactCells)
|
||||||
Vector3 worldPosition,
|
|
||||||
Quaternion worldRotation,
|
|
||||||
uint state,
|
|
||||||
bool isStatic)
|
|
||||||
{
|
{
|
||||||
if (!_entityRetailPartArrays.TryGetValue(
|
if (!_entityRetailPartArrays.TryGetValue(
|
||||||
entityId,
|
entityId,
|
||||||
|
|
@ -568,23 +558,23 @@ public sealed class ShadowObjectRegistry
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The retained multipart collision dispatch decides the route; a
|
// The route is retail's calc_cross_cells_static DISPATCH decision
|
||||||
// single-shape registration retains none, which can only be a
|
// (cylsphere vs bbox); the move path never re-runs that dispatch, so
|
||||||
// non-cylsphere object here (cylinder singles never move).
|
// the route recorded at the last actual dispatch (registration, or a
|
||||||
IReadOnlyList<ShadowShape> collisionShapes =
|
// Recalculate-driven full recompute) still describes it exactly.
|
||||||
_entityShapes.TryGetValue(entityId, out var retainedShapes)
|
RetailCellArrayRoute route = _retailCellArrayRoutes.TryGetValue(
|
||||||
? retainedShapes
|
entityId,
|
||||||
: Array.Empty<ShadowShape>();
|
out RetailCellArrayRoute existingRoute)
|
||||||
RecomputeRetailCellArray(
|
? existingRoute
|
||||||
entityId, seedCellId, worldPosition, worldRotation, state,
|
: RetailCellArrayRoute.None;
|
||||||
collisionShapes, partArray, isStatic);
|
PublishRetailCellArray(entityId, exactCells, route, partArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes every retained retail-cell-array product for
|
/// Removes every retained retail-cell-array product for
|
||||||
/// <paramref name="entityId"/>: the part array, the route, the ordered
|
/// <paramref name="entityId"/>: the part array, the route, the ordered
|
||||||
/// CELLARRAY, and every per-cell <see cref="RetailPartEntry"/> row it
|
/// CELLARRAY, and every per-cell <see cref="RetailPartEntry"/> row it
|
||||||
/// published. The inverse of <see cref="RecomputeRetailCellArray"/>,
|
/// published. The inverse of <see cref="PublishRetailCellArray"/>,
|
||||||
/// mirroring retail's <c>remove_shadows_from_cells</c> (0x00511230)
|
/// mirroring retail's <c>remove_shadows_from_cells</c> (0x00511230)
|
||||||
/// symmetry for this side product.
|
/// symmetry for this side product.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -601,10 +591,12 @@ public sealed class ShadowObjectRegistry
|
||||||
|
|
||||||
/// <summary>Removes every <see cref="RetailPartEntry"/> owned by
|
/// <summary>Removes every <see cref="RetailPartEntry"/> owned by
|
||||||
/// <paramref name="entityId"/> from each of <paramref name="cellIds"/>,
|
/// <paramref name="entityId"/> from each of <paramref name="cellIds"/>,
|
||||||
/// reclaiming a cell's list once it is left empty.</summary>
|
/// reclaiming a cell's list once it is left empty. Accepts
|
||||||
|
/// <see cref="IReadOnlyList{T}"/> so an attached child can share the
|
||||||
|
/// root's own cell list (chunk 4) without a defensive copy.</summary>
|
||||||
private void RemoveRetailPartEntriesFromCells(
|
private void RemoveRetailPartEntriesFromCells(
|
||||||
uint entityId,
|
uint entityId,
|
||||||
List<uint> cellIds)
|
IReadOnlyList<uint> cellIds)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < cellIds.Count; i++)
|
for (int i = 0; i < cellIds.Count; i++)
|
||||||
{
|
{
|
||||||
|
|
@ -629,7 +621,7 @@ public sealed class ShadowObjectRegistry
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void PublishRetailPartEntries(
|
private void PublishRetailPartEntries(
|
||||||
uint entityId,
|
uint entityId,
|
||||||
List<uint> orderedCells,
|
IReadOnlyList<uint> orderedCells,
|
||||||
IReadOnlyList<ShadowShape> partArray)
|
IReadOnlyList<ShadowShape> partArray)
|
||||||
{
|
{
|
||||||
bool clipPlanesRequired = orderedCells.Count > 1;
|
bool clipPlanesRequired = orderedCells.Count > 1;
|
||||||
|
|
@ -657,7 +649,9 @@ public sealed class ShadowObjectRegistry
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The retail CELLARRAY retained for <paramref name="entityId"/> — see
|
/// The retail CELLARRAY retained for <paramref name="entityId"/> — see
|
||||||
/// <see cref="RecomputeRetailCellArray"/>. Returns <see langword="false"/>
|
/// <see cref="ComputeContractACellArray"/> (registration) and
|
||||||
|
/// <see cref="PublishRetailProductFromExactCells"/> (movement). Returns
|
||||||
|
/// <see langword="false"/>
|
||||||
/// when no retail part array was ever supplied for this entity (every
|
/// when no retail part array was ever supplied for this entity (every
|
||||||
/// existing caller that omits the new trailing <c>partArray</c> parameter
|
/// existing caller that omits the new trailing <c>partArray</c> parameter
|
||||||
/// on <see cref="Register"/>/<see cref="RegisterMultiPart"/>) or when the
|
/// on <see cref="Register"/>/<see cref="RegisterMultiPart"/>) or when the
|
||||||
|
|
@ -698,6 +692,208 @@ public sealed class ShadowObjectRegistry
|
||||||
? route
|
? route
|
||||||
: RetailCellArrayRoute.None;
|
: RetailCellArrayRoute.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hard bound on an attach chain's depth — mirrors the render-side
|
||||||
|
/// parent-chain walk this API replaces (Campaign OVERHAUL S2 chunk 4).
|
||||||
|
/// A corrupted or cyclic caller cannot stall a registration.
|
||||||
|
/// </summary>
|
||||||
|
private const int MaxAttachChainDepth = 64;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign OVERHAUL S2 chunk 4: retail's <c>CPhysicsObj::add_shadows_to_cells</c>
|
||||||
|
/// (0x00514ae0) child-inheritance recursion — "after the root,
|
||||||
|
/// add_shadows_to_cells recursively passes the same CELLARRAY to every
|
||||||
|
/// object in <c>children</c>, in child-list order"
|
||||||
|
/// (<c>oh1-construction-landscape-contract.md</c> Contract B).
|
||||||
|
/// acdream's attached projections (equipped weapons/shields/ammunition)
|
||||||
|
/// own no independent collision shapes, so this publishes PART ENTRIES
|
||||||
|
/// into every cell of <paramref name="rootEntityId"/>'s CURRENT retail
|
||||||
|
/// CELLARRAY — never a collision row. <paramref name="rootEntityId"/> is
|
||||||
|
/// the entity <paramref name="childEntityId"/> attaches TO; it may
|
||||||
|
/// itself already be an attached child, in which case the ULTIMATE root
|
||||||
|
/// is resolved by walking the existing attach chain (nested attachment).
|
||||||
|
/// Re-published automatically whenever the root's array changes
|
||||||
|
/// (registration, move, staged apply, <see cref="ReplaceMultiPartPayload"/>)
|
||||||
|
/// via <see cref="RepublishAttachedChildren"/>. A cycle or a chain deeper
|
||||||
|
/// than <see cref="MaxAttachChainDepth"/> is rejected — returns
|
||||||
|
/// <see langword="false"/> without mutating any state. Re-attaching an
|
||||||
|
/// already-attached child (a reparent) detaches it from its previous
|
||||||
|
/// parent first.
|
||||||
|
/// </summary>
|
||||||
|
public bool AttachChild(
|
||||||
|
uint childEntityId,
|
||||||
|
uint rootEntityId,
|
||||||
|
IReadOnlyList<ShadowShape> childPartArray)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(childPartArray);
|
||||||
|
if (!TryValidateAttach(childEntityId, rootEntityId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (_childParent.ContainsKey(childEntityId))
|
||||||
|
DetachChildCore(childEntityId, removeFromParentList: true);
|
||||||
|
|
||||||
|
_childParent[childEntityId] = rootEntityId;
|
||||||
|
if (!_parentChildren.TryGetValue(rootEntityId, out List<uint>? siblings))
|
||||||
|
{
|
||||||
|
siblings = new List<uint>();
|
||||||
|
_parentChildren[rootEntityId] = siblings;
|
||||||
|
}
|
||||||
|
siblings.Add(childEntityId);
|
||||||
|
_childPartArrays[childEntityId] = childPartArray;
|
||||||
|
|
||||||
|
PublishChildEntries(childEntityId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign OVERHAUL S2 chunk 4: the inverse of <see cref="AttachChild"/>
|
||||||
|
/// — retail's <c>remove_shadows_from_cells</c> (0x00511230) symmetry for
|
||||||
|
/// one attached child. Also detaches every descendant of
|
||||||
|
/// <paramref name="childEntityId"/> (a nested child of a child), matching
|
||||||
|
/// retail's recursion through <c>children</c> on removal too. No-op
|
||||||
|
/// (returns <see langword="false"/>) when <paramref name="childEntityId"/>
|
||||||
|
/// is not currently attached.
|
||||||
|
/// </summary>
|
||||||
|
public bool DetachChild(uint childEntityId) =>
|
||||||
|
DetachChildCore(childEntityId, removeFromParentList: true);
|
||||||
|
|
||||||
|
private bool DetachChildCore(uint childEntityId, bool removeFromParentList)
|
||||||
|
{
|
||||||
|
if (!_childParent.TryGetValue(childEntityId, out uint parentId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (_parentChildren.TryGetValue(childEntityId, out List<uint>? grandchildren)
|
||||||
|
&& grandchildren.Count > 0)
|
||||||
|
{
|
||||||
|
uint[] toDetach = grandchildren.ToArray();
|
||||||
|
for (int i = 0; i < toDetach.Length; i++)
|
||||||
|
DetachChildCore(toDetach[i], removeFromParentList: false);
|
||||||
|
_parentChildren.Remove(childEntityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_retailCellArrays.TryGetValue(childEntityId, out List<uint>? cells))
|
||||||
|
{
|
||||||
|
RemoveRetailPartEntriesFromCells(childEntityId, cells);
|
||||||
|
_retailCellArrays.Remove(childEntityId);
|
||||||
|
}
|
||||||
|
_childPartArrays.Remove(childEntityId);
|
||||||
|
_childParent.Remove(childEntityId);
|
||||||
|
|
||||||
|
if (removeFromParentList
|
||||||
|
&& _parentChildren.TryGetValue(parentId, out List<uint>? siblings))
|
||||||
|
{
|
||||||
|
siblings.Remove(childEntityId);
|
||||||
|
if (siblings.Count == 0)
|
||||||
|
_parentChildren.Remove(parentId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates an <see cref="AttachChild"/> call before any state
|
||||||
|
/// mutation: rejects a direct self-attach and any attach that would form
|
||||||
|
/// a cycle (<paramref name="childEntityId"/> already an ancestor of
|
||||||
|
/// <paramref name="parentId"/> through the existing chain), and bounds
|
||||||
|
/// the walk at <see cref="MaxAttachChainDepth"/>.
|
||||||
|
/// </summary>
|
||||||
|
private bool TryValidateAttach(uint childEntityId, uint parentId)
|
||||||
|
{
|
||||||
|
if (childEntityId == parentId)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
uint current = parentId;
|
||||||
|
for (int depth = 0; depth < MaxAttachChainDepth; depth++)
|
||||||
|
{
|
||||||
|
if (!_childParent.TryGetValue(current, out uint next))
|
||||||
|
return true; // reached the current chain's root — no cycle
|
||||||
|
if (next == childEntityId)
|
||||||
|
return false; // would form a cycle
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
return false; // chain too deep — defensive
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks <see cref="_childParent"/> from <paramref name="entityId"/> up
|
||||||
|
/// to its ultimate root (an id with no recorded parent), bounded at
|
||||||
|
/// <see cref="MaxAttachChainDepth"/>. Safe to call post-attach because
|
||||||
|
/// <see cref="TryValidateAttach"/> already proved the chain acyclic.
|
||||||
|
/// </summary>
|
||||||
|
private uint ResolveAttachRoot(uint entityId)
|
||||||
|
{
|
||||||
|
uint current = entityId;
|
||||||
|
for (int depth = 0; depth < MaxAttachChainDepth; depth++)
|
||||||
|
{
|
||||||
|
if (!_childParent.TryGetValue(current, out uint parent))
|
||||||
|
return current;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (Re)publishes <paramref name="childEntityId"/>'s retail part entries
|
||||||
|
/// from its resolved root's CURRENT retail cell array — never an
|
||||||
|
/// independent flood. Clears the child's own previously published
|
||||||
|
/// entries first. When the root has no published array (not yet
|
||||||
|
/// registered, or its own array collapsed to empty), the child's
|
||||||
|
/// entries are cleared too and nothing is republished — the same
|
||||||
|
/// keep-when-empty symmetry <see cref="PublishRetailCellArray"/> applies
|
||||||
|
/// to the root itself.
|
||||||
|
/// </summary>
|
||||||
|
private void PublishChildEntries(uint childEntityId)
|
||||||
|
{
|
||||||
|
if (_retailCellArrays.TryGetValue(childEntityId, out List<uint>? previousCells))
|
||||||
|
{
|
||||||
|
RemoveRetailPartEntriesFromCells(childEntityId, previousCells);
|
||||||
|
_retailCellArrays.Remove(childEntityId);
|
||||||
|
}
|
||||||
|
if (!_childPartArrays.TryGetValue(childEntityId, out IReadOnlyList<ShadowShape>? partArray))
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint root = ResolveAttachRoot(childEntityId);
|
||||||
|
if (root == childEntityId
|
||||||
|
|| !_retailCellArrays.TryGetValue(root, out List<uint>? rootCells)
|
||||||
|
|| rootCells.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share the root's own list reference — PublishRetailCellArray never
|
||||||
|
// mutates a published List<uint> in place, always replaces it
|
||||||
|
// wholesale, so this stays valid until the NEXT publish (which
|
||||||
|
// republishes this child too, see RepublishAttachedChildren).
|
||||||
|
_retailCellArrays[childEntityId] = rootCells;
|
||||||
|
PublishRetailPartEntries(childEntityId, rootCells, partArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-publishes every attached child of <paramref name="rootId"/> — DIRECT
|
||||||
|
/// children first (in child-list/attach order), each recursively followed
|
||||||
|
/// by its OWN attached children, matching retail's
|
||||||
|
/// <c>add_shadows_to_cells</c> depth-first recursion through
|
||||||
|
/// <c>children</c> exactly. Called from every place a root's retail cell
|
||||||
|
/// array is (re)published: <see cref="PublishRetailCellArray"/> (covers
|
||||||
|
/// registration and, via <see cref="PublishRetailProductFromExactCells"/>,
|
||||||
|
/// movement), <see cref="TryApplySetPosition"/> (staged apply), and
|
||||||
|
/// <see cref="ReplaceMultiPartPayload"/>. A no-op leaf call when
|
||||||
|
/// <paramref name="rootId"/> has no attached children.
|
||||||
|
/// </summary>
|
||||||
|
private void RepublishAttachedChildren(uint rootId)
|
||||||
|
{
|
||||||
|
if (!_parentChildren.TryGetValue(rootId, out List<uint>? children)
|
||||||
|
|| children.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < children.Count; i++)
|
||||||
|
{
|
||||||
|
uint childId = children[i];
|
||||||
|
PublishChildEntries(childId);
|
||||||
|
RepublishAttachedChildren(childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Register a single-shape entity. <paramref name="seedCellId"/> is the
|
/// Register a single-shape entity. <paramref name="seedCellId"/> is the
|
||||||
/// entity's <c>m_position.objcell_id</c> — the flood seed. Pass 0 to
|
/// entity's <c>m_position.objcell_id</c> — the flood seed. Pass 0 to
|
||||||
|
|
@ -1029,6 +1225,13 @@ public sealed class ShadowObjectRegistry
|
||||||
{
|
{
|
||||||
RemoveRetailPartEntriesFromCells(entityId, retailCells);
|
RemoveRetailPartEntriesFromCells(entityId, retailCells);
|
||||||
PublishRetailPartEntries(entityId, retailCells, partArray);
|
PublishRetailPartEntries(entityId, retailCells, partArray);
|
||||||
|
// Campaign OVERHAUL S2 chunk 4: the root's own cell SET is
|
||||||
|
// unchanged here (SetPart never re-floods), but item B's
|
||||||
|
// contract lists this call among the root-array-touching
|
||||||
|
// sites a child must stay consistent with — cheap and
|
||||||
|
// harmless since the child's cells never actually change
|
||||||
|
// when the root's do not.
|
||||||
|
RepublishAttachedChildren(entityId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1675,6 +1878,13 @@ public sealed class ShadowObjectRegistry
|
||||||
_ownerVersions[prepared.EntityId] = prepared.FinalOwnerVersion;
|
_ownerVersions[prepared.EntityId] = prepared.FinalOwnerVersion;
|
||||||
_lastAppliedSetPositionCommitId = prepared.CommitId;
|
_lastAppliedSetPositionCommitId = prepared.CommitId;
|
||||||
_pendingSetPositionDispatches.Add(prepared.CommitId);
|
_pendingSetPositionDispatches.Add(prepared.CommitId);
|
||||||
|
// Campaign OVERHAUL S2 chunk 4: the staging registry that computed
|
||||||
|
// `prepared` carries none of THIS (live) registry's attach
|
||||||
|
// relationships — RepublishAttachedChildren was a no-op there. Now
|
||||||
|
// that the root's new retail cell array is live, propagate it to
|
||||||
|
// every attached child (item B: "staged apply" is one of the
|
||||||
|
// triggers).
|
||||||
|
RepublishAttachedChildren(prepared.EntityId);
|
||||||
receipt = new SetPositionShadowCommitReceipt(
|
receipt = new SetPositionShadowCommitReceipt(
|
||||||
prepared.CommitId,
|
prepared.CommitId,
|
||||||
prepared.EntityId,
|
prepared.EntityId,
|
||||||
|
|
@ -2060,15 +2270,18 @@ public sealed class ShadowObjectRegistry
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Campaign OVERHAUL S2 chunk 4: retail's keep-when-empty gate
|
||||||
|
// (pc:283540) reached its terminal case — no retained collision
|
||||||
|
// cells at all to republish from — so BOTH products stay exactly as
|
||||||
|
// they were. No independent retail-array recompute here any more;
|
||||||
|
// that was the "two floods disagreeing over the same input"
|
||||||
|
// duplication chunk 3's evidence (§6) flagged and chunk 4 removes.
|
||||||
_entityReg[entityId] = registration with
|
_entityReg[entityId] = registration with
|
||||||
{
|
{
|
||||||
SeedCellId = seedCellId,
|
SeedCellId = seedCellId,
|
||||||
EntityWorldPos = worldPosition,
|
EntityWorldPos = worldPosition,
|
||||||
EntityWorldRot = worldRotation,
|
EntityWorldRot = worldRotation,
|
||||||
};
|
};
|
||||||
RecomputeRetailCellArrayIfPresent(
|
|
||||||
entityId, seedCellId, worldPosition, worldRotation,
|
|
||||||
registration.State, registration.IsStatic);
|
|
||||||
BumpOwnerVersion(entityId);
|
BumpOwnerVersion(entityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2171,9 +2384,14 @@ public sealed class ShadowObjectRegistry
|
||||||
if (withdrawn.Count == 0)
|
if (withdrawn.Count == 0)
|
||||||
_withdrawnPrefixesByOwner.Remove(entityId);
|
_withdrawnPrefixesByOwner.Remove(entityId);
|
||||||
}
|
}
|
||||||
RecomputeRetailCellArrayIfPresent(
|
// Campaign OVERHAUL S2 chunk 4: one array, two products. The retail
|
||||||
entityId, seedCellId, worldPosition, worldRotation,
|
// render product is published from the SAME exact cell list collision
|
||||||
registration.State, registration.IsStatic);
|
// just used (`exactCells`) — not a second, independently-flooded
|
||||||
|
// array — mirroring retail's SetPositionInternal
|
||||||
|
// add_shadows_to_cells(this, &arg2->cell_array) call (pc:283537),
|
||||||
|
// which consumes the transition's own array for both products at
|
||||||
|
// once.
|
||||||
|
PublishRetailProductFromExactCells(entityId, exactCells);
|
||||||
BumpOwnerVersion(entityId);
|
BumpOwnerVersion(entityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2470,9 +2688,30 @@ public sealed class ShadowObjectRegistry
|
||||||
BumpOwnerVersion(entityId);
|
BumpOwnerVersion(entityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Remove an entity from all cells it was registered in.</summary>
|
/// <summary>
|
||||||
|
/// Remove an entity from all cells it was registered in. Campaign
|
||||||
|
/// OVERHAUL S2 chunk 4: mirrors retail's <c>remove_shadows_from_cells</c>
|
||||||
|
/// (0x00511230) full symmetry — a genuine teardown (as opposed to the
|
||||||
|
/// internal "clear then re-register" idiom <see cref="Register"/>/
|
||||||
|
/// <see cref="RegisterMultiPart"/> use ahead of their own flood, which
|
||||||
|
/// must NOT detach children) cascades through every attached child
|
||||||
|
/// (Contract B: "remove_shadows_from_cells... recurses through
|
||||||
|
/// children"), and detaches this entity itself if it was ITSELF an
|
||||||
|
/// attached child.
|
||||||
|
/// </summary>
|
||||||
public void Deregister(uint entityId)
|
public void Deregister(uint entityId)
|
||||||
=> DeregisterCore(entityId, publishMutation: true);
|
{
|
||||||
|
if (_parentChildren.TryGetValue(entityId, out List<uint>? children)
|
||||||
|
&& children.Count > 0)
|
||||||
|
{
|
||||||
|
uint[] toDetach = children.ToArray();
|
||||||
|
for (int i = 0; i < toDetach.Length; i++)
|
||||||
|
DetachChildCore(toDetach[i], removeFromParentList: false);
|
||||||
|
_parentChildren.Remove(entityId);
|
||||||
|
}
|
||||||
|
DetachChildCore(entityId, removeFromParentList: true);
|
||||||
|
DeregisterCore(entityId, publishMutation: true);
|
||||||
|
}
|
||||||
|
|
||||||
private void DeregisterCore(uint entityId, bool publishMutation)
|
private void DeregisterCore(uint entityId, bool publishMutation)
|
||||||
{
|
{
|
||||||
|
|
@ -3147,8 +3386,8 @@ public sealed class ShadowObjectRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Campaign OVERHAUL S2 chunk 3: seed the retail render product too —
|
// Campaign OVERHAUL S2 chunk 3: seed the retail render product too —
|
||||||
// without this, a staging registry's own internal Contract-A
|
// without this, a staging registry's own internal move-path publish
|
||||||
// recompute (RecomputeRetailCellArrayIfPresent, reached through
|
// (PublishRetailProductFromExactCells, reached through
|
||||||
// CommitSetPosition) finds nothing retained and silently no-ops,
|
// CommitSetPosition) finds nothing retained and silently no-ops,
|
||||||
// dropping the retail product for every entity that moves through
|
// dropping the retail product for every entity that moves through
|
||||||
// the staged TryPrepareSetPosition/TryApplySetPosition pipeline.
|
// the staged TryPrepareSetPosition/TryApplySetPosition pipeline.
|
||||||
|
|
@ -3428,6 +3667,17 @@ public sealed class ShadowObjectRegistry
|
||||||
_prefixScratch.Clear();
|
_prefixScratch.Clear();
|
||||||
_removedPrefixScratch.Clear();
|
_removedPrefixScratch.Clear();
|
||||||
_pendingSetPositionDispatches.Clear();
|
_pendingSetPositionDispatches.Clear();
|
||||||
|
// The retail render product (chunk 1/3) and the attach-chain state
|
||||||
|
// (chunk 4) were missing from this reset — a stale entry would
|
||||||
|
// otherwise survive a terminal Clear() into whatever the SAME
|
||||||
|
// registry instance is used for next.
|
||||||
|
_entityRetailPartArrays.Clear();
|
||||||
|
_retailCellArrays.Clear();
|
||||||
|
_retailCellArrayRoutes.Clear();
|
||||||
|
_retailPartEntriesByCell.Clear();
|
||||||
|
_childParent.Clear();
|
||||||
|
_parentChildren.Clear();
|
||||||
|
_childPartArrays.Clear();
|
||||||
_fallback = null;
|
_fallback = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1388,7 +1388,9 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
||||||
static (_, _, _) =>
|
static (_, _, _) =>
|
||||||
new ExactProjectionWithdrawalOutcome(
|
new ExactProjectionWithdrawalOutcome(
|
||||||
ExactProjectionWithdrawalDisposition.Superseded,
|
ExactProjectionWithdrawalDisposition.Superseded,
|
||||||
null)),
|
null),
|
||||||
|
Shadows,
|
||||||
|
new PhysicsDataCache()),
|
||||||
new ProjectileController(Runtime),
|
new ProjectileController(Runtime),
|
||||||
animatedEntities,
|
animatedEntities,
|
||||||
new RemoteMovementObservationTracker(),
|
new RemoteMovementObservationTracker(),
|
||||||
|
|
|
||||||
|
|
@ -653,7 +653,9 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
|
||||||
static (_, _, _) =>
|
static (_, _, _) =>
|
||||||
new ExactProjectionWithdrawalOutcome(
|
new ExactProjectionWithdrawalOutcome(
|
||||||
ExactProjectionWithdrawalDisposition.Superseded,
|
ExactProjectionWithdrawalDisposition.Superseded,
|
||||||
null)),
|
null),
|
||||||
|
Shadows,
|
||||||
|
new PhysicsDataCache()),
|
||||||
new ProjectileController(Runtime),
|
new ProjectileController(Runtime),
|
||||||
animatedEntities,
|
animatedEntities,
|
||||||
new RemoteMovementObservationTracker(),
|
new RemoteMovementObservationTracker(),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using AcDream.Content;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Enums;
|
using DatReaderWriter.Enums;
|
||||||
|
|
@ -1767,7 +1768,9 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
||||||
Live,
|
Live,
|
||||||
Poses,
|
Poses,
|
||||||
update => Live.TryApplyParent(update, out _),
|
update => Live.TryApplyParent(update, out _),
|
||||||
withdraw);
|
withdraw,
|
||||||
|
new ShadowObjectRegistry(),
|
||||||
|
new PhysicsDataCache());
|
||||||
_lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle(
|
_lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle(
|
||||||
Controller.OnLogicalTeardown));
|
Controller.OnLogicalTeardown));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -231,16 +231,56 @@ public sealed class WalkProductionWorldDataTests
|
||||||
Assert.Empty(outdoor);
|
Assert.Empty(outdoor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Campaign OVERHAUL S2 chunk 4: ResolveDynamicRenderCells is now a direct
|
||||||
|
// TryGetRetailCellArray read for EVERY dynamic record, children included
|
||||||
|
// — ShadowObjectRegistry.AttachChild owns Contract B's child-inheritance
|
||||||
|
// recursion at the registry, so the render-side parent-chain walk this
|
||||||
|
// test used to exercise no longer exists.
|
||||||
|
|
||||||
|
private static ShadowShape ChildBsp(uint gfxObjId) =>
|
||||||
|
ShadowShape.Bsp(
|
||||||
|
gfxObjId,
|
||||||
|
Vector3.Zero,
|
||||||
|
Quaternion.Identity,
|
||||||
|
scale: 1f,
|
||||||
|
localGeometry: ShadowPartGeometry.Create(
|
||||||
|
new FlatCollisionSphere(Vector3.Zero, 1f), null));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveDynamicRenderCells_EquippedDescendantInheritsRootCellArray()
|
public void ResolveDynamicRenderCells_AttachedChildReadsTheRegistrysRetailCellArrayDirectly()
|
||||||
{
|
{
|
||||||
|
var shadows = new ShadowObjectRegistry();
|
||||||
|
const uint rootId = 0x5000000Au;
|
||||||
|
const uint childId = 0x800045EEu;
|
||||||
|
IReadOnlyList<ShadowShape> rootParts = new[] { ChildBsp(0x02000001u) };
|
||||||
|
|
||||||
|
shadows.RegisterMultiPart(
|
||||||
|
rootId,
|
||||||
|
new Vector3(12f, 12f, 50f),
|
||||||
|
Quaternion.Identity,
|
||||||
|
rootParts,
|
||||||
|
state: 0u,
|
||||||
|
flags: EntityCollisionFlags.None,
|
||||||
|
worldOffsetX: 0f,
|
||||||
|
worldOffsetY: 0f,
|
||||||
|
landblockId: 0xF4180000u,
|
||||||
|
seedCellId: 0xF4180104u,
|
||||||
|
isStatic: false,
|
||||||
|
partArray: rootParts);
|
||||||
|
Assert.True(shadows.TryGetRetailCellArray(rootId, out IReadOnlyList<uint> rootCells));
|
||||||
|
Assert.NotEmpty(rootCells); // the fixture must actually exercise a flood
|
||||||
|
|
||||||
|
// No render-side parent walk any more: Contract B's inheritance is
|
||||||
|
// owned by the registry at attach time.
|
||||||
|
Assert.True(shadows.AttachChild(childId, rootId, new[] { ChildBsp(0x02000002u) }));
|
||||||
|
|
||||||
RenderProjectionRecord wand = Record(
|
RenderProjectionRecord wand = Record(
|
||||||
id: 0x800045EEu,
|
id: childId,
|
||||||
position: Vector3.Zero) with
|
position: Vector3.Zero) with
|
||||||
{
|
{
|
||||||
Source = new RenderSourceMetadata() with
|
Source = new RenderSourceMetadata() with
|
||||||
{
|
{
|
||||||
LocalEntityId = 0x800045EEu,
|
LocalEntityId = childId,
|
||||||
ParentCellId = 0xF4180104u,
|
ParentCellId = 0xF4180104u,
|
||||||
},
|
},
|
||||||
EntityPayload = new RenderEntityPayload() with
|
EntityPayload = new RenderEntityPayload() with
|
||||||
|
|
@ -248,27 +288,66 @@ public sealed class WalkProductionWorldDataTests
|
||||||
CasterIdentity = RenderCasterIdentityKind.EquippedChild,
|
CasterIdentity = RenderCasterIdentityKind.EquippedChild,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
var owners = new Dictionary<uint, IReadOnlyList<uint>>
|
|
||||||
{
|
|
||||||
[0x5000000Au] = [0xF4180106u, 0xF4180104u],
|
|
||||||
};
|
|
||||||
var parents = new Dictionary<uint, uint>
|
|
||||||
{
|
|
||||||
[0x800045EEu] = 0x80000461u,
|
|
||||||
[0x80000461u] = 0x5000000Au,
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<uint> cells =
|
IReadOnlyList<uint> cells =
|
||||||
WalkProductionWorldData.ResolveDynamicRenderCells(
|
WalkProductionWorldData.ResolveDynamicRenderCells(
|
||||||
in wand,
|
in wand,
|
||||||
id => owners.TryGetValue(id, out IReadOnlyList<uint>? value)
|
id => shadows.TryGetRetailCellArray(id, out IReadOnlyList<uint> c)
|
||||||
? value
|
? (true, c)
|
||||||
: Array.Empty<uint>(),
|
: (false, Array.Empty<uint>()),
|
||||||
id => parents.TryGetValue(id, out uint value)
|
shadows.GetOwnerCells,
|
||||||
? value
|
out bool usedFallback);
|
||||||
: null);
|
|
||||||
|
|
||||||
Assert.Equal([0xF4180106u, 0xF4180104u], cells);
|
Assert.False(usedFallback);
|
||||||
|
Assert.Equal(rootCells, cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveDynamicRenderCells_UnregisteredEntityFallsBackToOwnerCells()
|
||||||
|
{
|
||||||
|
var shadows = new ShadowObjectRegistry();
|
||||||
|
const uint entityId = 0x00099Fu;
|
||||||
|
IReadOnlyList<ShadowShape> shapes = new[] { ChildBsp(0x02000003u) };
|
||||||
|
shadows.RegisterMultiPart(
|
||||||
|
entityId,
|
||||||
|
new Vector3(12f, 12f, 50f),
|
||||||
|
Quaternion.Identity,
|
||||||
|
shapes,
|
||||||
|
state: 0u,
|
||||||
|
flags: EntityCollisionFlags.None,
|
||||||
|
worldOffsetX: 0f,
|
||||||
|
worldOffsetY: 0f,
|
||||||
|
landblockId: 0xF4180000u,
|
||||||
|
seedCellId: 0xF4180104u,
|
||||||
|
isStatic: false);
|
||||||
|
// No partArray supplied above -> no retail cell array registered for
|
||||||
|
// this entity (item E of the S2 chunk-1 contract), so the ordinary
|
||||||
|
// collision-flood answer is the conservative fallback.
|
||||||
|
Assert.False(shadows.TryGetRetailCellArray(entityId, out _));
|
||||||
|
Assert.NotEmpty(shadows.GetOwnerCells(entityId));
|
||||||
|
|
||||||
|
RenderProjectionRecord record = Record(
|
||||||
|
id: entityId,
|
||||||
|
position: Vector3.Zero) with
|
||||||
|
{
|
||||||
|
Source = new RenderSourceMetadata() with
|
||||||
|
{
|
||||||
|
LocalEntityId = entityId,
|
||||||
|
ParentCellId = 0xF4180104u,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<uint> cells =
|
||||||
|
WalkProductionWorldData.ResolveDynamicRenderCells(
|
||||||
|
in record,
|
||||||
|
id => shadows.TryGetRetailCellArray(id, out IReadOnlyList<uint> c)
|
||||||
|
? (true, c)
|
||||||
|
: (false, Array.Empty<uint>()),
|
||||||
|
shadows.GetOwnerCells,
|
||||||
|
out bool usedFallback);
|
||||||
|
|
||||||
|
Assert.True(usedFallback);
|
||||||
|
Assert.Equal(shadows.GetOwnerCells(entityId), cells);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -294,8 +294,17 @@ public class ShadowObjectRegistryRetailCellArrayTests
|
||||||
e => e.EntityId == entityId);
|
e => e.EntityId == entityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Campaign OVERHAUL S2 chunk 4: "one array, two products" on the move
|
||||||
|
// path too. Retail's SetPositionInternal (pc:283399) republishes BOTH
|
||||||
|
// the collision CShadowObj list and the retail render product from the
|
||||||
|
// SAME transition cell_array (pc:283536-283537) — never a second,
|
||||||
|
// independently-flooded array. The retail array must therefore equal
|
||||||
|
// GetOwnerCells after every CommitSetPosition variant.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CommitSetPosition_WithRetainedPartArray_RecomputesRetailCellArray()
|
public void CommitSetPosition_NoneAction_PublishesRetailProductFromTheRetainedCells()
|
||||||
{
|
{
|
||||||
var reg = new ShadowObjectRegistry();
|
var reg = new ShadowObjectRegistry();
|
||||||
const uint entityId = 0x16u;
|
const uint entityId = 0x16u;
|
||||||
|
|
@ -307,7 +316,13 @@ public class ShadowObjectRegistryRetailCellArrayTests
|
||||||
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
|
||||||
Assert.Equal(new[] { CellA }, before);
|
Assert.Equal(new[] { CellA }, before);
|
||||||
|
|
||||||
var moved = new Vector3(42f, 12f, 50f); // -> CellB
|
// A .None commit carries no transition cell_array of its own (it
|
||||||
|
// maps to retail's "cell did not change" branch, e.g. ForceIntoCell
|
||||||
|
// with changedCell == false) — CommitSetPosition's None action
|
||||||
|
// republishes at the RETAINED cells (CellA), not a fresh flood from
|
||||||
|
// the new world position, even though (42,12,50) would geometrically
|
||||||
|
// resolve to CellB. Both products must agree either way.
|
||||||
|
var moved = new Vector3(42f, 12f, 50f);
|
||||||
reg.CommitSetPosition(
|
reg.CommitSetPosition(
|
||||||
entityId,
|
entityId,
|
||||||
moved,
|
moved,
|
||||||
|
|
@ -318,8 +333,72 @@ public class ShadowObjectRegistryRetailCellArrayTests
|
||||||
action: PhysicsShadowCommitAction.None,
|
action: PhysicsShadowCommitAction.None,
|
||||||
crossCellIds: ImmutableArray<uint>.Empty);
|
crossCellIds: ImmutableArray<uint>.Empty);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
||||||
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
||||||
Assert.Equal(new[] { CellB }, after);
|
Assert.Equal(reg.GetOwnerCells(entityId), after);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CommitSetPosition_ReplaceAction_PublishesRetailProductFromTheTransitionCellArray()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint entityId = 0x18u;
|
||||||
|
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x6101u) };
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||||
|
isStatic: false, partArray: parts);
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
|
||||||
|
Assert.Equal(new[] { CellA }, before);
|
||||||
|
|
||||||
|
// .Replace supplies the transition's own crossCellIds — retail's
|
||||||
|
// arg2->cell_array. Both products move to it directly; there is no
|
||||||
|
// second, independently-computed cell array to disagree with it.
|
||||||
|
var moved = new Vector3(42f, 12f, 50f);
|
||||||
|
reg.CommitSetPosition(
|
||||||
|
entityId,
|
||||||
|
moved,
|
||||||
|
Quaternion.Identity,
|
||||||
|
seedCellId: CellB,
|
||||||
|
worldOffsetX: OffX,
|
||||||
|
worldOffsetY: OffY,
|
||||||
|
action: PhysicsShadowCommitAction.Replace,
|
||||||
|
crossCellIds: [CellB]);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { CellB }, reg.GetOwnerCells(entityId));
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
||||||
|
Assert.Equal(reg.GetOwnerCells(entityId), after);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CommitSetPosition_KeepWhenEmpty_LeavesBothProductsUntouched()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint entityId = 0x19u;
|
||||||
|
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x6201u) };
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||||
|
isStatic: false, partArray: parts);
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
|
||||||
|
Assert.Equal(new[] { CellA }, before);
|
||||||
|
|
||||||
|
// Retail's num_cells > 0 gate (pc:283534/pc:283540): an EMPTY
|
||||||
|
// transition cell_array leaves the previous shadows exactly as they
|
||||||
|
// were for BOTH products — no republish of either.
|
||||||
|
reg.CommitSetPosition(
|
||||||
|
entityId,
|
||||||
|
Pos,
|
||||||
|
Quaternion.Identity,
|
||||||
|
seedCellId: CellA,
|
||||||
|
worldOffsetX: OffX,
|
||||||
|
worldOffsetY: OffY,
|
||||||
|
action: PhysicsShadowCommitAction.Preserve,
|
||||||
|
crossCellIds: ImmutableArray<uint>.Empty);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
||||||
|
Assert.Equal(new[] { CellA }, after);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
@ -564,4 +643,228 @@ public class ShadowObjectRegistryRetailCellArrayTests
|
||||||
Assert.Single(partEntries);
|
Assert.Single(partEntries);
|
||||||
Assert.Equal(0x8101u, partEntries[0].GfxObjId);
|
Assert.Equal(0x8101u, partEntries[0].GfxObjId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Campaign OVERHAUL S2 chunk 4: AttachChild/DetachChild — retail's
|
||||||
|
// add_shadows_to_cells child-inheritance recursion (Contract B). An
|
||||||
|
// attached projection (equipped weapon/shield/ammunition) owns no
|
||||||
|
// independent collision shapes; it inherits the root's CELLARRAY and
|
||||||
|
// gets PART ENTRIES only, never a collision row.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static IReadOnlyList<ShadowShape> ChildParts(params uint[] gfxObjIds)
|
||||||
|
{
|
||||||
|
var shapes = new ShadowShape[gfxObjIds.Length];
|
||||||
|
for (int i = 0; i < gfxObjIds.Length; i++)
|
||||||
|
shapes[i] = Bsp(gfxObjIds[i]);
|
||||||
|
return shapes;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttachChild_GivesChildTheRootsArrayAndEntries()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint rootId = 0x40u;
|
||||||
|
const uint childId = 0x41u;
|
||||||
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9001u) };
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||||
|
partArray: rootParts);
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells));
|
||||||
|
Assert.Equal(new[] { CellA }, rootCells);
|
||||||
|
|
||||||
|
IReadOnlyList<ShadowShape> childParts = ChildParts(0xC001u, 0xC002u);
|
||||||
|
Assert.True(reg.AttachChild(childId, rootId, childParts));
|
||||||
|
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
|
||||||
|
Assert.Equal(rootCells, childCells);
|
||||||
|
|
||||||
|
var entries = reg.GetRetailPartEntriesInCell(CellA)
|
||||||
|
.Where(e => e.EntityId == childId)
|
||||||
|
.OrderBy(e => e.PartIndex)
|
||||||
|
.ToList();
|
||||||
|
Assert.Equal(2, entries.Count);
|
||||||
|
Assert.Equal(0xC001u, entries[0].GfxObjId);
|
||||||
|
Assert.Equal(0xC002u, entries[1].GfxObjId);
|
||||||
|
|
||||||
|
// Never a collision row — acdream's attached projections own none.
|
||||||
|
Assert.Empty(reg.GetOwnerCells(childId));
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
reg.GetObjectsInCell(CellA),
|
||||||
|
e => e.EntityId == childId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttachChild_RootMove_RepublishesTheChild()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint rootId = 0x42u;
|
||||||
|
const uint childId = 0x43u;
|
||||||
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9101u) };
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||||
|
isStatic: false, partArray: rootParts);
|
||||||
|
Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC101u)));
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(childId, out var before));
|
||||||
|
Assert.Equal(new[] { CellA }, before);
|
||||||
|
|
||||||
|
var moved = new Vector3(42f, 12f, 50f); // -> CellB
|
||||||
|
reg.CommitSetPosition(
|
||||||
|
rootId,
|
||||||
|
moved,
|
||||||
|
Quaternion.Identity,
|
||||||
|
seedCellId: CellB,
|
||||||
|
worldOffsetX: OffX,
|
||||||
|
worldOffsetY: OffY,
|
||||||
|
action: PhysicsShadowCommitAction.Replace,
|
||||||
|
crossCellIds: [CellB]);
|
||||||
|
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootAfter));
|
||||||
|
Assert.Equal(new[] { CellB }, rootAfter);
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(childId, out var childAfter));
|
||||||
|
Assert.Equal(rootAfter, childAfter);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
reg.GetRetailPartEntriesInCell(CellA),
|
||||||
|
e => e.EntityId == childId);
|
||||||
|
var movedEntries = reg.GetRetailPartEntriesInCell(CellB)
|
||||||
|
.Where(e => e.EntityId == childId).ToList();
|
||||||
|
Assert.Single(movedEntries);
|
||||||
|
Assert.Equal(0xC101u, movedEntries[0].GfxObjId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetachChild_ClearsTheChildsProducts()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint rootId = 0x44u;
|
||||||
|
const uint childId = 0x45u;
|
||||||
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9201u) };
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||||
|
partArray: rootParts);
|
||||||
|
Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC201u)));
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(childId, out _));
|
||||||
|
|
||||||
|
Assert.True(reg.DetachChild(childId));
|
||||||
|
|
||||||
|
Assert.False(reg.TryGetRetailCellArray(childId, out var afterCells));
|
||||||
|
Assert.Empty(afterCells);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
reg.GetRetailPartEntriesInCell(CellA),
|
||||||
|
e => e.EntityId == childId);
|
||||||
|
// The root itself is untouched.
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells));
|
||||||
|
Assert.Equal(new[] { CellA }, rootCells);
|
||||||
|
|
||||||
|
// A second detach is a no-op, not an error.
|
||||||
|
Assert.False(reg.DetachChild(childId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deregister_RootCascadesDetachOfAttachedChildren()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint rootId = 0x46u;
|
||||||
|
const uint childId = 0x47u;
|
||||||
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9301u) };
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||||
|
partArray: rootParts);
|
||||||
|
Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC301u)));
|
||||||
|
|
||||||
|
reg.Deregister(rootId);
|
||||||
|
|
||||||
|
Assert.False(reg.TryGetRetailCellArray(rootId, out _));
|
||||||
|
Assert.False(reg.TryGetRetailCellArray(childId, out var childCells));
|
||||||
|
Assert.Empty(childCells);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
reg.GetRetailPartEntriesInCell(CellA),
|
||||||
|
e => e.EntityId == childId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttachChild_NestedChildOfAChild_ResolvesToTheRoot()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint rootId = 0x48u;
|
||||||
|
const uint directChildId = 0x49u;
|
||||||
|
const uint grandchildId = 0x4Au;
|
||||||
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9401u) };
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||||
|
partArray: rootParts);
|
||||||
|
Assert.True(reg.AttachChild(directChildId, rootId, ChildParts(0xC401u)));
|
||||||
|
// Attach the grandchild to the DIRECT CHILD, not the root — nested
|
||||||
|
// attachment must still resolve to the ultimate root's array.
|
||||||
|
Assert.True(reg.AttachChild(grandchildId, directChildId, ChildParts(0xC402u)));
|
||||||
|
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells));
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(grandchildId, out var grandchildCells));
|
||||||
|
Assert.Equal(rootCells, grandchildCells);
|
||||||
|
|
||||||
|
var entries = reg.GetRetailPartEntriesInCell(CellA)
|
||||||
|
.Where(e => e.EntityId == grandchildId).ToList();
|
||||||
|
Assert.Single(entries);
|
||||||
|
Assert.Equal(0xC402u, entries[0].GfxObjId);
|
||||||
|
|
||||||
|
// Moving the root republishes BOTH the direct child and the
|
||||||
|
// grandchild, in child-list order.
|
||||||
|
var moved = new Vector3(42f, 12f, 50f);
|
||||||
|
reg.CommitSetPosition(
|
||||||
|
rootId, moved, Quaternion.Identity, seedCellId: CellB,
|
||||||
|
worldOffsetX: OffX, worldOffsetY: OffY,
|
||||||
|
action: PhysicsShadowCommitAction.Replace, crossCellIds: [CellB]);
|
||||||
|
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(grandchildId, out var grandchildAfter));
|
||||||
|
Assert.Equal(new[] { CellB }, grandchildAfter);
|
||||||
|
|
||||||
|
// Detaching the direct child cascades to the grandchild too.
|
||||||
|
Assert.True(reg.DetachChild(directChildId));
|
||||||
|
Assert.False(reg.TryGetRetailCellArray(grandchildId, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttachChild_SelfAttachRejected()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint entityId = 0x4Bu;
|
||||||
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9501u) };
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, rootParts,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||||
|
partArray: rootParts);
|
||||||
|
|
||||||
|
Assert.False(reg.AttachChild(entityId, entityId, ChildParts(0xC501u)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttachChild_CycleRejected_RootUnaffected()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint rootId = 0x4Cu;
|
||||||
|
const uint childId = 0x4Du;
|
||||||
|
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9601u) };
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||||
|
partArray: rootParts);
|
||||||
|
Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC601u)));
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCellsBefore));
|
||||||
|
|
||||||
|
// Attaching the root TO its own child would form a cycle.
|
||||||
|
Assert.False(reg.AttachChild(rootId, childId, ChildParts(0xC602u)));
|
||||||
|
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCellsAfter));
|
||||||
|
Assert.Equal(rootCellsBefore, rootCellsAfter);
|
||||||
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(rootId));
|
||||||
|
// The child's own attachment is unaffected by the rejected call.
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
|
||||||
|
Assert.Equal(rootCellsAfter, childCells);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue