From 75ea269d3518b96da113a2f97077612e5bb28d2b Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 2 Sep 2026 23:37:34 +0200 Subject: [PATCH] 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 --- docs/architecture/worldbuilder-inventory.md | 49 +- .../s2-membership-ownership-map.md | 167 ++++++- .../Composition/FrameRootComposition.cs | 3 +- .../LivePresentationComposition.cs | 4 +- .../EquippedChildRenderController.cs | 75 ++- .../Rendering/RetailPViewRenderer.cs | 6 +- .../Rendering/Walk/WalkProductionWorldData.cs | 65 ++- .../Physics/CollisionWorldState.cs | 11 + .../Physics/ShadowObjectRegistry.cs | 432 ++++++++++++++---- ...ityNetworkOnPositionCollapseMatrixTests.cs | 4 +- ...yNetworkRemoteTeleportPresentationTests.cs | 4 +- .../EquippedChildProjectionWithdrawalTests.cs | 5 +- .../Walk/WalkProductionWorldDataTests.cs | 117 ++++- ...hadowObjectRegistryRetailCellArrayTests.cs | 309 ++++++++++++- 14 files changed, 1072 insertions(+), 179 deletions(-) diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index f0db23e0..8e36eb46 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -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 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 authored/feet cell alone: `CPhysicsObj::add_shadows_to_cells` installs every `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 -portal test. `WalkProductionWorldData` therefore buckets live projections by -all `ShadowObjectRegistry.GetOwnerCells` rows, while the walk classifier owns -a projection+part drawn-pass stamp. Retail advances that stamp after +portal test. `WalkProductionWorldData.ResolveDynamicRenderCells` therefore +reads `ShadowObjectRegistry.TryGetRetailCellArray` directly for every dynamic +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 (`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 -classifier at that exact walk boundary. `add_shadows_to_cells` recursively -passes the root's same `CELLARRAY` to every attached child, so equipped-child render -projections resolve their accepted parent chain to the root's owner cells; -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 +classifier at that exact walk boundary. 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 `RenderDeviceD3D::DrawMeshInternal` bypasses `Get/SetDrawnThisFrame` when `CPhysicsPart::IsPartOfPlayerObj` is true, repainting the player's parts at 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, the package remained format 1 and retained mesh type values 1–3; bake-tool 4 appended typed GfxObj, diff --git a/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md b/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md index f2f6f23d..536fa011 100644 --- a/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md +++ b/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md @@ -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) | | **DELETED (chunk 2).** Was `WalkProductionWorldData.ResolveStaticRenderCells` → `ShadowObjectRegistry.ComputeStaticRenderCells`. Now: `WalkProductionWorldData.ResolveIndoorStaticRenderCells`/`ResolveOutdoorStaticRenderCells` → `ShadowObjectRegistry.TryGetRetailCellArray` (`WalkProductionWorldData.cs`, `ShadowObjectRegistry.cs:634-663`) | none (borrowed) | chunk 1's retained per-entity retail CELLARRAY, now the SAME array chunk 3 also uses for collision | every `BeginFrame` sweep of `RenderSceneIndex.IndoorCellStatic`/`OutdoorStatic` (a borrow, not a recompute) | `WalkFrameDriver.GetCellStatics`; `RetailPViewRenderer` particle owner union; unregistered fallback (indoor: authored `ParentCellId`; outdoor: `GetOwnerCells`) counted by `WalkProductionWorldData.UnregisteredStaticRenderFallbackCount` | -| **Chunk 3 (2026-09-02).** `LiveEntityCollisionBuilder.Register` / `ShadowObjectRegistry.UpdatePosition` / `CommitSetPosition` (`LiveEntityCollisionBuilder.cs:149-280`, `ShadowObjectRegistry.cs`) | `ShadowShapeBuilder.FromSetup` (collision dispatch) + `FromSetupRenderParts` (chunk 1b's part array) → the same ONE Contract A flood as above at registration; movement (`UpdatePosition`/`CommitSetPosition`'s `Recalculate` action) forwards to the same registration entry points, so it inherits the fix. `CommitSetPosition`'s `None`/`Preserve`/`Replace` actions (`RefreshPositionRows`/`ReplacePositionRows`) are UNCHANGED — they consume an externally-decided collision cell list and separately recompute the retail product via the same Contract A primitive on every move (chunk 1's design, not a second registration flood) | `_entityToCells` + `_cells`, and (when a part array is retained) `_retailCellArrays`/`_retailPartEntriesByCell` | spawn and every accepted move/appearance change | physics broadphase; `WalkProductionWorldData` dynamic render lookup | -| **Chunk 3 (2026-09-02).** The staged `ShadowObjectRegistry.TryPrepareSetPosition`/`TryApplySetPosition` pipeline | `PreparedShadowOwnerState` now also carries `RetailPartArray`/`RetailCellIds`/`RetailRoute`/`RetailRows`; `TryCaptureOwnerState`/`InstallOwnerState` seed them so the staging registry's own `RecomputeRetailCellArrayIfPresent` call (reached through `CommitSetPosition`) no longer silently no-ops; `PrepareRetailPartEntryReplacements` diffs the staged retail rows the same way `PrepareCellReplacements` diffs collision rows | `_retailPartEntriesByCell` publishes alongside `_cells` on `TryApplySetPosition` | every live entity moving through the transactional SetPosition publication tail (Runtime) | same as direct `CommitSetPosition`, now consistent with it | -| `WalkProductionWorldData.ResolveDynamicRenderCells` (`WalkProductionWorldData.cs:247-283`) | `GetOwnerCells`; for an `EquippedChild` with no cells, a 64-hop parent-chain walk | whole-record buckets | every `BeginFrame` | `WalkFrameDriver.GetCellDynamics` | +| **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 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 | +| **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) | | `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` | @@ -54,8 +55,12 @@ Findings that shape the chunks: registration that carries a real part array. - Render membership is whole-object per cell; retail's is every part per cell (identity retained per part, with cell frame and clip planes). -- Children are inherited only on the render side, at bucket time; retail - inherits at registration. +- **RESOLVED (chunk 4).** Children were inherited only on the render side, at + 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 `BuildShadowCellSetFromParts`, affecting every caller. Retail has no such 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 against Contract A. -**Chunk 4 — dynamics and children.** `add_shadows_to_cells` recursion: -children register with the root's CELLARRAY at registration and movement; -particles own-cell only; the render-side parent-chain walk is deleted. +**Chunk 4 (landed 2026-09-02) — dynamics and children.** `add_shadows_to_cells` +recursion: children register with the root's CELLARRAY at registration and +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` 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) territory, to be implemented if/when particle emitters are ever routed 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. diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 28c3199e..a1a2d5e2 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -507,8 +507,7 @@ internal sealed class FrameRootCompositionPhase ?? throw new InvalidOperationException( "The retail frame walk requires the landscape registry."), d.CellVisibility, - d.PhysicsEngine.ShadowObjects, - live.EquippedChildren.FindParentLocalId), + d.PhysicsEngine.ShadowObjects), retailPViewPassExecutor, retailPViewPassExecutor), retailPViewCells, diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 3ba05a6a..ea1ad600 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -622,7 +622,9 @@ internal sealed class LivePresentationCompositionPhase childRecord, positionVersion, projectionVersion, - d.PlayerIdentity.ServerGuid)), + d.PlayerIdentity.ServerGuid), + d.PhysicsEngine.ShadowObjects, + d.PhysicsDataCache), static value => value.Dispose()); Fault(LivePresentationCompositionPoint.CorePresentationCreated); diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index b8924217..546295ea 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -34,6 +34,28 @@ public sealed class EquippedChildRenderController : IDisposable _withdrawProjection; private readonly EntityEffectPoseRegistry _poses; + /// + /// Campaign OVERHAUL S2 chunk 4: the SAME canonical shadow registry + /// LiveEntityCollisionBuilder publishes into — retail's + /// add_shadows_to_cells 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 + /// / + /// — never Register/RegisterMultiPart. + /// + private readonly ShadowObjectRegistry _shadows; + + /// + /// Campaign OVERHAUL S2 chunk 4: the SAME PhysicsDataCache resolver + /// pair + /// uses for ShadowShapeBuilder.FromSetupRenderParts — a static and + /// a live Setup sharing a GfxObj id can never disagree about its render + /// geometry. + /// + private readonly PhysicsDataCache _physicsData; + private ParentAttachmentState Relations => _liveEntities.ParentAttachments; /// Raised after the attached projection is fully registered. @@ -98,7 +120,9 @@ public sealed class EquippedChildRenderController : IDisposable EntityEffectPoseRegistry poses, Func acceptParent, Func - withdrawProjection) + withdrawProjection, + ShadowObjectRegistry shadows, + PhysicsDataCache physicsData) { _dats = dats ?? throw new ArgumentNullException(nameof(dats)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); @@ -108,6 +132,8 @@ public sealed class EquippedChildRenderController : IDisposable _acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent)); _withdrawProjection = 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; _tickAttached = TickChild; _reconcileAttached = ReconcileChild; @@ -659,6 +685,15 @@ public sealed class EquippedChildRenderController : IDisposable CaptureParentPresentation(attached, parentEntity); RuntimeEntityKey childKey = RequireProjectionKey(childRecord); _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); if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0) { @@ -1147,6 +1182,38 @@ public sealed class EquippedChildRenderController : IDisposable return available; } + /// + /// Campaign OVERHAUL S2 chunk 4: retail's per-part render membership + /// (CPartArray::AddPartsShadow 0x00517e40 walks EVERY visual + /// part, not just the BSP-only collision dispatch) for one attached + /// child, via — the + /// SAME builder and the SAME resolver pair + /// LiveEntityCollisionBuilder.Build 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. + /// 's per-part GfxObj identity (post- + /// AnimPartChanged, ) supplies + /// effectivePartGfxObjIds; 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 FromSetupRenderParts's own placement-frame fallback. + /// + private IReadOnlyList BuildChildRenderParts( + Setup setup, IReadOnlyList 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) { if (spawn.SubPalettes is not { Count: > 0 } subPalettes) @@ -1388,6 +1455,12 @@ public sealed class EquippedChildRenderController : IDisposable } _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); return true; } diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index d8ceb098..7ac6580f 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -78,8 +78,7 @@ internal sealed class RetailPViewRenderer Walk.WalkBuildingRegistry walkBuildings, Walk.WalkLandscapeAssembler walkLandscape, CellVisibility walkCellRegistry, - ShadowObjectRegistry shadows, - Func? findParentLocalId = null) + ShadowObjectRegistry shadows) { _renderSceneShadow = renderSceneShadow ?? throw new ArgumentNullException(nameof(renderSceneShadow)); @@ -91,8 +90,7 @@ internal sealed class RetailPViewRenderer ?? throw new ArgumentNullException(nameof(walkCellRegistry)); _walkWorldData = new Walk.WalkProductionWorldData( _walkBuildings, - shadows ?? throw new ArgumentNullException(nameof(shadows)), - findParentLocalId); + shadows ?? throw new ArgumentNullException(nameof(shadows))); _walkClearInteriorDepthAction = ClearWalkInteriorDepth; _walkDrawExitSealsAction = DrawWalkExitSeals; } diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs index cad00897..de43a31d 100644 --- a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs +++ b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs @@ -78,7 +78,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData { private readonly WalkBuildingRegistry _buildings; private readonly ShadowObjectRegistry _shadows; - private readonly Func _findParentLocalId; private RenderSceneQuery _scene; private uint _tupleLandblockId; private int _renderCenterLbX; @@ -107,17 +106,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData internal WalkProductionWorldData( WalkBuildingRegistry buildings, - ShadowObjectRegistry shadows, - Func? findParentLocalId = null) + ShadowObjectRegistry shadows) { _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); - _findParentLocalId = findParentLocalId ?? NoParentLocalId; _tryGetRetailCellArray = TryGetRetailCellArrayForEntity; } - private static uint? NoParentLocalId(uint _) => null; - private (bool Found, IReadOnlyList Cells) TryGetRetailCellArrayForEntity(uint entityId) { bool found = _shadows.TryGetRetailCellArray(entityId, out IReadOnlyList cells); @@ -264,8 +259,11 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i]; IReadOnlyList renderCells = ResolveDynamicRenderCells( in record, + _tryGetRetailCellArray, _shadows.GetOwnerCells, - _findParentLocalId); + out bool usedFallback); + if (usedFallback) + UnregisteredStaticRenderFallbackCount++; BucketDynamicRecord( in record, renderCells, @@ -277,48 +275,39 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData } /// - /// Retail CPhysicsObj::add_shadows_to_cells recursively passes the - /// root object's same CELLARRAY to every child in its CHILDLIST. Attached - /// projections deliberately own no independent collision rows in acdream, - /// so walk their accepted parent chain until the root's retained array is - /// found. Ordinary dynamics continue to consume only their own rows. + /// Campaign OVERHAUL S2 chunk 4: one dynamic record's render-cell + /// membership, borrowed from + /// — the SAME direct read + /// uses. An equipped child's retail array is now published by + /// at the registry (retail + /// Contract B's add_shadows_to_cells 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 + /// documents), the fallback is today's collision-flood + /// answer, counted the + /// same way as the static fallbacks via . /// internal static IReadOnlyList ResolveDynamicRenderCells( in RenderProjectionRecord record, + Func Cells)> tryGetRetailCellArray, Func> getOwnerCells, - Func findParentLocalId) + out bool usedFallback) { + ArgumentNullException.ThrowIfNull(tryGetRetailCellArray); ArgumentNullException.ThrowIfNull(getOwnerCells); - ArgumentNullException.ThrowIfNull(findParentLocalId); - uint current = record.Source.LocalEntityId; - IReadOnlyList cells = getOwnerCells(current); - if (cells.Count > 0 - || record.EntityPayload.CasterIdentity - != RenderCasterIdentityKind.EquippedChild) + (bool found, IReadOnlyList cells) = + tryGetRetailCellArray(record.Source.LocalEntityId); + if (found) { + usedFallback = false; return cells; } - // ParentAttachmentState rejects cycles. Keep a hard bound here so a - // corrupted diagnostic callback still cannot stall a render frame. - 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(); + usedFallback = true; + return getOwnerCells(record.Source.LocalEntityId); } /// diff --git a/src/AcDream.Core/Physics/CollisionWorldState.cs b/src/AcDream.Core/Physics/CollisionWorldState.cs index 6dac2a2a..84c30c5b 100644 --- a/src/AcDream.Core/Physics/CollisionWorldState.cs +++ b/src/AcDream.Core/Physics/CollisionWorldState.cs @@ -127,6 +127,17 @@ internal sealed class CollisionWorldState ShadowEntityRetailCellArrayRoutes { get; } = new(); internal Dictionary> 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 ShadowChildParent { get; } = new(); + internal Dictionary> ShadowParentChildren { get; } = new(); + internal Dictionary> + ShadowChildPartArrays { get; } = new(); + // ── O1 per-prefix installed-key ledgers ──────────────────────────────── // Every mutation of the five landblock-scoped world maps goes through the // typed helpers below so these ledgers stay exact. The seal's landblock- diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 59a2564f..9557f137 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -89,6 +89,27 @@ public sealed class ShadowObjectRegistry private Dictionary> _retailPartEntriesByCell => _collisionWorld.Current.RetailPartEntriesByCell; + /// + /// Campaign OVERHAUL S2 chunk 4: immediate parent (attachment point) for + /// every entity attached via — retail's + /// add_shadows_to_cells child-inheritance recursion (Contract B). + /// A nested attachment's ultimate root is resolved by walking this map; + /// see . + /// + private Dictionary _childParent => + _collisionWorld.Current.ShadowChildParent; + + /// Direct children of one attachment point, in attach (child-list) + /// order — the reverse index of . + private Dictionary> _parentChildren => + _collisionWorld.Current.ShadowParentChildren; + + /// Each attached child's OWN visual part array (its equipped + /// item's Setup parts), published into the root's cells by + /// — never flooded independently. + private Dictionary> _childPartArrays => + _collisionWorld.Current.ShadowChildPartArrays; + /// /// BR-7: per-entity registration arguments, kept so a registration can be /// RE-RUN when more cells hydrate. Retail's equivalent is @@ -400,61 +421,6 @@ public sealed class ShadowObjectRegistry private PhysicsDataCache? _fallback; private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache; - /// - /// Retail's exact CObjCell::find_cell_list/find_bbox_cell_list - /// CELLARRAY (Contract A, re-verified 2026-09-01 through the live Ghidra - /// bridge at 127.0.0.1:8081 against patchmem.gpr) for one - /// entity's WHOLE part array, plus the sibling - /// CPartArray::AddPartsShadow (0x00517e40) per-cell part-entry - /// product, published into . - /// - /// - /// 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 or - /// , and nothing in production reads it yet. - /// - /// - /// - /// Route — CPhysicsObj::calc_cross_cells_static 0x00515160's - /// branch table: - /// - /// - /// cylsphere (CObjCell::find_cell_list 0x0052b9f0, over the - /// authored CylSpheres, reusing — "today's - /// cylinder registration") when (state & 0x10000) == 0 AND the - /// object's authored COLLISION dispatch (, - /// retail CPartArray::GetNumCylsphere() != 0: the Setup's - /// CylSpheres) carries at least one - /// shape. The visual part array - /// never decides the route: retail's cylspheres are Setup collision data, - /// not parts; - /// otherwise the bbox route (CPhysicsObj::find_bbox_cell_list - /// 0x00510fc0 via ) — - /// every other case, including a BSP-bearing object (state bit set) and a - /// part array with no authored CylSpheres at all. - /// - /// - private void RecomputeRetailCellArray( - uint entityId, - uint seedCellId, - Vector3 entityWorldPos, - Quaternion entityWorldRot, - uint state, - IReadOnlyList collisionShapes, - IReadOnlyList partArray, - bool isStatic) - { - ClearRetailCellArray(entityId); - if (partArray.Count == 0 || seedCellId == 0u) - return; - - _entityRetailPartArrays[entityId] = partArray; - (IReadOnlyList cellArray, RetailCellArrayRoute route) = ComputeContractACellArray( - seedCellId, entityWorldPos, entityWorldRot, state, collisionShapes, partArray, isStatic); - PublishRetailCellArray(entityId, cellArray, route, partArray); - } - /// /// Campaign OVERHAUL S2 chunk 3: retail's calc_cross_cells_static /// (0x00515160) dispatch + flood, PURE — no registry state is read or @@ -523,6 +489,16 @@ public sealed class ShadowObjectRegistry /// remove_shadows_from_cells symmetry for this side product. The /// route is recorded even when is empty — /// the dispatch decision was still made. + /// + /// + /// Campaign OVERHAUL S2 chunk 4: this is retail's ONE place a + /// CPhysicsObj's CELLARRAY changes, so it is also the one place + /// that re-runs — 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 (), which never + /// carries any attach relationships of its own. + /// /// private void PublishRetailCellArray( uint entityId, @@ -537,29 +513,43 @@ public sealed class ShadowObjectRegistry } _retailCellArrayRoutes[entityId] = route; if (cellArray.Count == 0) + { + RepublishAttachedChildren(entityId); return; + } var orderedCells = new List(cellArray.Count); for (int i = 0; i < cellArray.Count; i++) orderedCells.Add(cellArray[i]); _retailCellArrays[entityId] = orderedCells; PublishRetailPartEntries(entityId, orderedCells, partArray); + RepublishAttachedChildren(entityId); } /// - /// Recomputes at a new position - /// when — and only when — a retail part array is currently retained for - /// . No-op for every entity registered - /// without one (item E of the S2 chunk-1 contract: existing callers stay - /// byte-for-byte unaffected). + /// Campaign OVERHAUL S2 chunk 4: publishes the retail render product from + /// the SAME exact cell list the move-path collision republish + /// () just used — no second, + /// independently-flooded cell array. Retail's + /// CPhysicsObj::SetPositionInternal (pc:283399) does exactly this + /// on a successful transition: add_shadows_to_cells(this, + /// &arg2->cell_array) (pc:283536-283537) consumes the + /// TRANSITION's own array for BOTH the collision CShadowObj list + /// and the render shadow_part_list in one call — there is no + /// separate bbox/cylsphere dispatch on the move path + /// (calc_cross_cells_static only runs at registration, or at a + /// full recompute gated by the HAS_PHYSICS_BSP_PS state bit, + /// pc:283528, which acdream maps to + /// → , unaffected by this method). + /// A no-op (existing rows retained, matching retail's num_cells > + /// 0 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. /// - private void RecomputeRetailCellArrayIfPresent( + private void PublishRetailProductFromExactCells( uint entityId, - uint seedCellId, - Vector3 worldPosition, - Quaternion worldRotation, - uint state, - bool isStatic) + IReadOnlyList exactCells) { if (!_entityRetailPartArrays.TryGetValue( entityId, @@ -568,23 +558,23 @@ public sealed class ShadowObjectRegistry { return; } - // The retained multipart collision dispatch decides the route; a - // single-shape registration retains none, which can only be a - // non-cylsphere object here (cylinder singles never move). - IReadOnlyList collisionShapes = - _entityShapes.TryGetValue(entityId, out var retainedShapes) - ? retainedShapes - : Array.Empty(); - RecomputeRetailCellArray( - entityId, seedCellId, worldPosition, worldRotation, state, - collisionShapes, partArray, isStatic); + // The route is retail's calc_cross_cells_static DISPATCH decision + // (cylsphere vs bbox); the move path never re-runs that dispatch, so + // the route recorded at the last actual dispatch (registration, or a + // Recalculate-driven full recompute) still describes it exactly. + RetailCellArrayRoute route = _retailCellArrayRoutes.TryGetValue( + entityId, + out RetailCellArrayRoute existingRoute) + ? existingRoute + : RetailCellArrayRoute.None; + PublishRetailCellArray(entityId, exactCells, route, partArray); } /// /// Removes every retained retail-cell-array product for /// : the part array, the route, the ordered /// CELLARRAY, and every per-cell row it - /// published. The inverse of , + /// published. The inverse of , /// mirroring retail's remove_shadows_from_cells (0x00511230) /// symmetry for this side product. /// @@ -601,10 +591,12 @@ public sealed class ShadowObjectRegistry /// Removes every owned by /// from each of , - /// reclaiming a cell's list once it is left empty. + /// reclaiming a cell's list once it is left empty. Accepts + /// so an attached child can share the + /// root's own cell list (chunk 4) without a defensive copy. private void RemoveRetailPartEntriesFromCells( uint entityId, - List cellIds) + IReadOnlyList cellIds) { for (int i = 0; i < cellIds.Count; i++) { @@ -629,7 +621,7 @@ public sealed class ShadowObjectRegistry /// private void PublishRetailPartEntries( uint entityId, - List orderedCells, + IReadOnlyList orderedCells, IReadOnlyList partArray) { bool clipPlanesRequired = orderedCells.Count > 1; @@ -657,7 +649,9 @@ public sealed class ShadowObjectRegistry /// /// The retail CELLARRAY retained for — see - /// . Returns + /// (registration) and + /// (movement). Returns + /// /// when no retail part array was ever supplied for this entity (every /// existing caller that omits the new trailing partArray parameter /// on /) or when the @@ -698,6 +692,208 @@ public sealed class ShadowObjectRegistry ? route : RetailCellArrayRoute.None; + /// + /// 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. + /// + private const int MaxAttachChainDepth = 64; + + /// + /// Campaign OVERHAUL S2 chunk 4: retail's CPhysicsObj::add_shadows_to_cells + /// (0x00514ae0) child-inheritance recursion — "after the root, + /// add_shadows_to_cells recursively passes the same CELLARRAY to every + /// object in children, in child-list order" + /// (oh1-construction-landscape-contract.md Contract B). + /// acdream's attached projections (equipped weapons/shields/ammunition) + /// own no independent collision shapes, so this publishes PART ENTRIES + /// into every cell of 's CURRENT retail + /// CELLARRAY — never a collision row. is + /// the entity 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, ) + /// via . A cycle or a chain deeper + /// than is rejected — returns + /// without mutating any state. Re-attaching an + /// already-attached child (a reparent) detaches it from its previous + /// parent first. + /// + public bool AttachChild( + uint childEntityId, + uint rootEntityId, + IReadOnlyList 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? siblings)) + { + siblings = new List(); + _parentChildren[rootEntityId] = siblings; + } + siblings.Add(childEntityId); + _childPartArrays[childEntityId] = childPartArray; + + PublishChildEntries(childEntityId); + return true; + } + + /// + /// Campaign OVERHAUL S2 chunk 4: the inverse of + /// — retail's remove_shadows_from_cells (0x00511230) symmetry for + /// one attached child. Also detaches every descendant of + /// (a nested child of a child), matching + /// retail's recursion through children on removal too. No-op + /// (returns ) when + /// is not currently attached. + /// + 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? 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? cells)) + { + RemoveRetailPartEntriesFromCells(childEntityId, cells); + _retailCellArrays.Remove(childEntityId); + } + _childPartArrays.Remove(childEntityId); + _childParent.Remove(childEntityId); + + if (removeFromParentList + && _parentChildren.TryGetValue(parentId, out List? siblings)) + { + siblings.Remove(childEntityId); + if (siblings.Count == 0) + _parentChildren.Remove(parentId); + } + return true; + } + + /// + /// Validates an call before any state + /// mutation: rejects a direct self-attach and any attach that would form + /// a cycle ( already an ancestor of + /// through the existing chain), and bounds + /// the walk at . + /// + 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 + } + + /// + /// Walks from up + /// to its ultimate root (an id with no recorded parent), bounded at + /// . Safe to call post-attach because + /// already proved the chain acyclic. + /// + 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; + } + + /// + /// (Re)publishes '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 applies + /// to the root itself. + /// + private void PublishChildEntries(uint childEntityId) + { + if (_retailCellArrays.TryGetValue(childEntityId, out List? previousCells)) + { + RemoveRetailPartEntriesFromCells(childEntityId, previousCells); + _retailCellArrays.Remove(childEntityId); + } + if (!_childPartArrays.TryGetValue(childEntityId, out IReadOnlyList? partArray)) + return; + + uint root = ResolveAttachRoot(childEntityId); + if (root == childEntityId + || !_retailCellArrays.TryGetValue(root, out List? rootCells) + || rootCells.Count == 0) + { + return; + } + + // Share the root's own list reference — PublishRetailCellArray never + // mutates a published List 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); + } + + /// + /// Re-publishes every attached child of — DIRECT + /// children first (in child-list/attach order), each recursively followed + /// by its OWN attached children, matching retail's + /// add_shadows_to_cells depth-first recursion through + /// children exactly. Called from every place a root's retail cell + /// array is (re)published: (covers + /// registration and, via , + /// movement), (staged apply), and + /// . A no-op leaf call when + /// has no attached children. + /// + private void RepublishAttachedChildren(uint rootId) + { + if (!_parentChildren.TryGetValue(rootId, out List? children) + || children.Count == 0) + { + return; + } + for (int i = 0; i < children.Count; i++) + { + uint childId = children[i]; + PublishChildEntries(childId); + RepublishAttachedChildren(childId); + } + } + /// /// Register a single-shape entity. is the /// entity's m_position.objcell_id — the flood seed. Pass 0 to @@ -1029,6 +1225,13 @@ public sealed class ShadowObjectRegistry { RemoveRetailPartEntriesFromCells(entityId, retailCells); 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; _lastAppliedSetPositionCommitId = 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( prepared.CommitId, prepared.EntityId, @@ -2060,15 +2270,18 @@ public sealed class ShadowObjectRegistry 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 { SeedCellId = seedCellId, EntityWorldPos = worldPosition, EntityWorldRot = worldRotation, }; - RecomputeRetailCellArrayIfPresent( - entityId, seedCellId, worldPosition, worldRotation, - registration.State, registration.IsStatic); BumpOwnerVersion(entityId); } @@ -2171,9 +2384,14 @@ public sealed class ShadowObjectRegistry if (withdrawn.Count == 0) _withdrawnPrefixesByOwner.Remove(entityId); } - RecomputeRetailCellArrayIfPresent( - entityId, seedCellId, worldPosition, worldRotation, - registration.State, registration.IsStatic); + // Campaign OVERHAUL S2 chunk 4: one array, two products. The retail + // render product is published from the SAME exact cell list collision + // 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); } @@ -2470,9 +2688,30 @@ public sealed class ShadowObjectRegistry BumpOwnerVersion(entityId); } - /// Remove an entity from all cells it was registered in. + /// + /// Remove an entity from all cells it was registered in. Campaign + /// OVERHAUL S2 chunk 4: mirrors retail's remove_shadows_from_cells + /// (0x00511230) full symmetry — a genuine teardown (as opposed to the + /// internal "clear then re-register" idiom / + /// 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. + /// public void Deregister(uint entityId) - => DeregisterCore(entityId, publishMutation: true); + { + if (_parentChildren.TryGetValue(entityId, out List? 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) { @@ -3147,8 +3386,8 @@ public sealed class ShadowObjectRegistry } // Campaign OVERHAUL S2 chunk 3: seed the retail render product too — - // without this, a staging registry's own internal Contract-A - // recompute (RecomputeRetailCellArrayIfPresent, reached through + // without this, a staging registry's own internal move-path publish + // (PublishRetailProductFromExactCells, reached through // CommitSetPosition) finds nothing retained and silently no-ops, // dropping the retail product for every entity that moves through // the staged TryPrepareSetPosition/TryApplySetPosition pipeline. @@ -3428,6 +3667,17 @@ public sealed class ShadowObjectRegistry _prefixScratch.Clear(); _removedPrefixScratch.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; } diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs index 5afcc329..50aa86f4 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs @@ -1388,7 +1388,9 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests static (_, _, _) => new ExactProjectionWithdrawalOutcome( ExactProjectionWithdrawalDisposition.Superseded, - null)), + null), + Shadows, + new PhysicsDataCache()), new ProjectileController(Runtime), animatedEntities, new RemoteMovementObservationTracker(), diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs index 3ff9ba57..1b114f6e 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs @@ -653,7 +653,9 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests static (_, _, _) => new ExactProjectionWithdrawalOutcome( ExactProjectionWithdrawalDisposition.Superseded, - null)), + null), + Shadows, + new PhysicsDataCache()), new ProjectileController(Runtime), animatedEntities, new RemoteMovementObservationTracker(), diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs index 451f1b92..a1ec1403 100644 --- a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs @@ -9,6 +9,7 @@ using AcDream.Content; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; using AcDream.Core.World; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; @@ -1767,7 +1768,9 @@ public sealed class EquippedChildProjectionWithdrawalTests Live, Poses, update => Live.TryApplyParent(update, out _), - withdraw); + withdraw, + new ShadowObjectRegistry(), + new PhysicsDataCache()); _lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle( Controller.OnLogicalTeardown)); } diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs index c06aa61e..f155d92a 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs @@ -231,16 +231,56 @@ public sealed class WalkProductionWorldDataTests 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] - public void ResolveDynamicRenderCells_EquippedDescendantInheritsRootCellArray() + public void ResolveDynamicRenderCells_AttachedChildReadsTheRegistrysRetailCellArrayDirectly() { + var shadows = new ShadowObjectRegistry(); + const uint rootId = 0x5000000Au; + const uint childId = 0x800045EEu; + IReadOnlyList 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 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( - id: 0x800045EEu, + id: childId, position: Vector3.Zero) with { Source = new RenderSourceMetadata() with { - LocalEntityId = 0x800045EEu, + LocalEntityId = childId, ParentCellId = 0xF4180104u, }, EntityPayload = new RenderEntityPayload() with @@ -248,27 +288,66 @@ public sealed class WalkProductionWorldDataTests CasterIdentity = RenderCasterIdentityKind.EquippedChild, }, }; - var owners = new Dictionary> - { - [0x5000000Au] = [0xF4180106u, 0xF4180104u], - }; - var parents = new Dictionary - { - [0x800045EEu] = 0x80000461u, - [0x80000461u] = 0x5000000Au, - }; IReadOnlyList cells = WalkProductionWorldData.ResolveDynamicRenderCells( in wand, - id => owners.TryGetValue(id, out IReadOnlyList? value) - ? value - : Array.Empty(), - id => parents.TryGetValue(id, out uint value) - ? value - : null); + id => shadows.TryGetRetailCellArray(id, out IReadOnlyList c) + ? (true, c) + : (false, Array.Empty()), + shadows.GetOwnerCells, + out bool usedFallback); - 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 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 cells = + WalkProductionWorldData.ResolveDynamicRenderCells( + in record, + id => shadows.TryGetRetailCellArray(id, out IReadOnlyList c) + ? (true, c) + : (false, Array.Empty()), + shadows.GetOwnerCells, + out bool usedFallback); + + Assert.True(usedFallback); + Assert.Equal(shadows.GetOwnerCells(entityId), cells); } // ----------------------------------------------------------------- diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs index c2858c95..5a8640e3 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs @@ -294,8 +294,17 @@ public class ShadowObjectRegistryRetailCellArrayTests 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] - public void CommitSetPosition_WithRetainedPartArray_RecomputesRetailCellArray() + public void CommitSetPosition_NoneAction_PublishesRetailProductFromTheRetainedCells() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x16u; @@ -307,7 +316,13 @@ public class ShadowObjectRegistryRetailCellArrayTests Assert.True(reg.TryGetRetailCellArray(entityId, out var 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( entityId, moved, @@ -318,8 +333,72 @@ public class ShadowObjectRegistryRetailCellArrayTests action: PhysicsShadowCommitAction.None, crossCellIds: ImmutableArray.Empty); + Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); 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 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 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.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.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 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 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 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 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 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 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 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 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 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); + } }