From c94a1a407e1f7f7bd61019d8edd5104b87d80aff Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 3 Sep 2026 01:01:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Campaign=20OVERHAUL=20S2=20chun?= =?UTF-8?q?k=205=20+=20closeout=20=E2=80=94=20registry=20is=20the=20only?= =?UTF-8?q?=20render=20membership=20owner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 5 (consumer cutover): WalkProductionWorldData's per-cell views are borrowed from ShadowObjectRegistry.GetRetailPartEntriesInCell and resolved through RenderSceneQuery.TryGetByLocalEntityId; every render-side sweep, bucket, parent-cell and root-position fallback is deleted (AD-116 for the one-frame registry→scene window, counted in UnregisteredRenderMembershipCount). A live entity with visual parts but no collision geometry registers render-only (LiveEntityCollisionBuilder computes the part array before the empty-shapes gate). Closeout fixes found while landing it: - RefloodOwnerForLandblock forwards the retained part array — a reflood is retail's recalc_cross_cells over the SAME CPartArray; without it every owner touched by a landblock replacement commit lost its render membership. - Non-colliding DAT statics register render-only from BOTH publishers (LandblockPhysicsPublisher.PublishStaticEntity, LandblockPhysicsContentBuilder.RegisterRenderOnlyStatic). The G2 self-gate pixel diff caught them vanishing (Facility Hub wall panels): retail floods every object regardless of collision (CEnvCell::init_static_objects 0x0052c350, add_shadows_to_cells 0x00514ae0). - S2 dual review fix batch (arch + retail lens, lead-verified): Suspend clears the retail product (remove_shadows_from_cells 0x00511230 is one transaction); AttachChild/DetachChild advance the mutation revision so a prepared SetPosition cannot clobber a child's rows; an attached child never floods on its own re-registration; RemoveLandblock and the non-rooted RetireOwnerFromLandblock prune retail rows (render-only statics end with their landblock); a render-only owner's no-cell-array commit republishes at its destination cell (AD-117); an empty non-null part array is treated as null; per-move closures/LINQ replaced by index loops; EnvCell shells stay out of the scene's LocalEntityId index (payload-less records); the index predicate compares the id; the dead per-cell scene indices are deleted. Register: AD-116 (chunk 5), AD-117 (four residual Contract A/B readings). Evidence: s2-membership-ownership-map.md §8 (chunk 5) and §9 (closeout). Gates (Release): Core 4,984/4,984; Content 214/214; Runtime 1,884/1,884; App hermetic lane 6,760/6,760; App InstalledDat lane 217 pass / 1 skip / 2 pre-existing #383 layout-fixture failures; App Windows lane 1/1. Co-Authored-By: Claude Fable 5.1 --- docs/architecture/acdream-architecture.md | 24 +- .../retail-divergence-register.md | 4 +- docs/architecture/worldbuilder-inventory.md | 76 +- docs/launch-options.md | 2 +- .../s2-membership-ownership-map.md | 255 +++++- .../Rendering/Scene/Arch/ArchRenderScene.cs | 127 ++- .../Rendering/Scene/RenderSceneContracts.cs | 59 +- .../Rendering/Walk/WalkProductionWorldData.cs | 778 ++++++------------ .../Streaming/LandblockPhysicsPublisher.cs | 28 + .../LandblockPhysicsContentBuilder.cs | 39 + .../Physics/ShadowObjectRegistry.cs | 381 ++++++++- .../Physics/LiveEntityCollisionBuilder.cs | 37 +- .../LiveEntityCollisionBuilderTests.cs | 72 ++ .../Rendering/ArchRenderSceneTests.cs | 95 ++- .../DirectionalShadowCasterFrameTests.cs | 47 +- .../Issue177StairDescentCameraFloodTests.cs | 2 +- .../Walk/WalkProductionWorldDataTests.cs | 617 +++++++------- .../LandblockPhysicsPublisherTests.cs | 67 ++ ...kPhysicsContentBuilderStaticSphereTests.cs | 73 ++ ...hadowObjectRegistryRetailCellArrayTests.cs | 384 +++++++++ 20 files changed, 2141 insertions(+), 1026 deletions(-) diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 8f763f78..02a532c9 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -1103,11 +1103,25 @@ retail's synthetic outdoor cell; the player's current cell separately owns sunlight and indoor lighting. A null root exists only for login/debug/streaming- gap fallback frames. `RetailPViewRenderer` and `RetailPViewPassExecutor` are composition/pass facades, and `PortalVisibilityBuilder` has no production -caller. This does **not** yet mean the complete world graph has one owner: -`WalkProductionWorldData` still reconstructs frame buckets from scene records, -AABBs, and aggregate shadow-owner cells. Campaign OVERHAUL OH3 replaces that -boundary with one canonical published cell graph and typed render/physics -membership transaction. +caller. **Campaign OVERHAUL S2 (chunks 1-5, closed 2026-09-03) gave render +and physics cross-cell membership one canonical owner:** +`ShadowObjectRegistry` computes retail's exact CELLARRAY +(`calc_cross_cells_static`/`add_shadows_to_cells`, Contract A/B) once per +registration/move transaction and retains it both as collision rows +(`GetOwnerCells`) and per-cell `RetailPartEntry` render rows +(`GetRetailPartEntriesInCell`) — one flood drives both, never two +independent ones. `WalkProductionWorldData` no longer reconstructs +membership from scene records, prepared visual AABBs, or per-record +aggregate shadow-owner cells: `GetCellStatics`/`GetCellDynamics`/ +`GetOutdoorStatics`/`GetOutdoorDynamics` are a borrowed, on-demand view over +the registry's per-cell entries, resolved to a projected record through +`RenderSceneQuery.TryGetByLocalEntityId`. Portal topology, building +ownership, and drawable-cell identity remain a separate concern S2 +deliberately left alone (S3's `WalkPView` state consolidates that +triplication, not this membership graph) — S2 scoped to render/physics +cross-cell membership only. Full detail: +`docs/architecture/worldbuilder-inventory.md`'s "Retail static render-shadow +cell membership" / "Consumer cutover to a borrowed per-cell view" entries. **Current draw discipline and known non-retail order.** The walk records an ordered event stream for landscape cells, building portals, EnvCell shells, diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8ad2d98b..3fbdae30 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -67,7 +67,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 88 active rows (AD-115 filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16) — `BuildCharacterTitleDisplay` clears the Profession element (`0x10000151`) when neither Int 261 CharacterTitleId nor String 5 Template resolves, where retail never clears `0x10000150`/`51`/`52` anywhere and would instead show the PREVIOUS target's stale title; AD-114 filed 2026-08-25 at Campaign AS slice AS2, owner-ruled ("we animate it, and I like it") — the examination window's preview clone tracks the assessed creature's live current animated pose every frame, where retail's clone plays its own private `CreatureMode` cycle decoupled from the live target's actual motion; AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 RETIRED 2026-08-28 — #386's named-retail message trace confirmed the vendor popup is content-sized and installed-DAT property 0x79 hides its disabled scrollbar; both behaviors are now ported; AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- +## 2. Adaptation (AD) — 90 active rows (AD-117 filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round — a render-only owner's no-cell-array SetPosition commit republishes its retail CELLARRAY as its destination cell alone, plus the three residual Contract A/B approximations the S2 retail-lens review named (visual-AABB circumsphere cheap reject, part rows published into unloaded neighbour cells, the unported `state & 0x1000` particle branch); AD-116 filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 — `WalkProductionWorldData`'s borrowed per-cell view contributes NO cell for an entity the registry has flooded but the presentation scene cannot resolve yet (the deleted parent-cell/root-position fallbacks are gone), counted once per distinct entity per frame in `UnregisteredRenderMembershipCount`; AD-115 filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16) — `BuildCharacterTitleDisplay` clears the Profession element (`0x10000151`) when neither Int 261 CharacterTitleId nor String 5 Template resolves, where retail never clears `0x10000150`/`51`/`52` anywhere and would instead show the PREVIOUS target's stale title; AD-114 filed 2026-08-25 at Campaign AS slice AS2, owner-ruled ("we animate it, and I like it") — the examination window's preview clone tracks the assessed creature's live current animated pose every frame, where retail's clone plays its own private `CreatureMode` cycle decoupled from the live target's actual motion; AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 RETIRED 2026-08-28 — #386's named-retail message trace confirmed the vendor popup is content-sized and installed-DAT property 0x79 hides its disabled scrollbar; both behaviors are now ported; AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- -to-line-break shaping, which retail's `ItemExamineUI::AddItemInfo @0x004AC050` does not do (wire text appends verbatim; the escape decode retail runs at `StringInfo` resolution now lives at our string source, `DatStringResolver` → `RetailStringEscapes`); AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate @@ -111,6 +111,8 @@ readiness/requeue adaptation. See | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AD-117 | **Filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round.** Four residual Contract A/B readings. (1) A RENDER-ONLY owner (retail part array, no collision shapes, hence never any retained collision cells) whose SetPosition commit carries no transition cell array (`PhysicsShadowCommitAction.None` / the keep-when-empty terminal branch) republishes its retail CELLARRAY as its destination seed cell ALONE (`ShadowObjectRegistry.RefreshPositionRows`), i.e. one `AddPartsShadow` row set without clip planes. (2) `ShadowShapeBuilder.FromStaticRenderParts` uses the visual-AABB circumsphere as the per-portal cheap-reject sphere for a part with no physics BSP, where retail uses `gfxobj->physics_sphere` else `drawing_sphere` (pc:310147-310152) — strictly larger, so it can only WIDEN membership. (3) `PublishRetailPartEntries` publishes part rows into every CELLARRAY id, including an unloaded neighbour cell `CEnvCell::find_transit_cells` added with a null owner, where retail's `add_shadows_to_cells` (pc:282850) zeroes that shadow's cell and skips `AddPartsShadow` until the cell loads. (4) The `state & 0x1000` particle branch (`add_particle_shadow_to_cell` 0x00514a70: the object's own cell only, never clip planes) is not ported. | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`RefreshPositionRows` render-only branch, `PublishRetailPartEntries`), `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromStaticRenderParts` non-BSP arm) | (1) retail's zero-sphere transition seeds `begin_cell` only, so the destination cell is the natural reading of the transition's array for a shapeless object; the previous behaviour (frozen at the registration cells forever) was strictly worse. (2) A cheap reject that fires less often admits a superset; the admitting tests (`Plane::intersect_box`, `box_intersects_cell`) are ported exactly. (3) The extra rows are unreachable by the walk until the cell is resident and `RefloodLandblock` converges them at hydration. (4) `ParticleRenderer` owns emitter placement and no production path registers an emitter, so the branch has no input. | (1) A travelling spell/visual-effect object drawn in one cell fewer or more than retail at a portal seam. (2) A decorative non-BSP part admitted to a neighbouring cell retail's cheap reject would have dropped — a draw clipped by that cell's portal planes, at worst a sliver. (3) A one-frame draw into a cell that just hydrated before its reflood ran. (4) If an emitter is ever routed through the registry it would flood as a bbox object instead of its own cell only. | `CPhysicsObj::SetPositionInternal` 0x00515330 (pc:283530-283541), `CPhysicsObj::add_shadows_to_cells` 0x00514ae0 (pc:282837-282875), `CEnvCell::find_transit_cells` 0x0052cae0 (pc:310147-310217), `CPhysicsObj::add_particle_shadow_to_cell` 0x00514a70; retire (1) with a cdb trace of `CTransition` at `num_sphere == 0` in the owner's capture session (`tools/walk-oracle/oh/`). | +| AD-116 | **Filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 (consumer cutover).** `WalkProductionWorldData.ResolveCellView`'s borrowed per-cell view treats an entity the registry HAS flooded into its retail CELLARRAY (so `ShadowObjectRegistry.GetRetailPartEntriesInCell` names it) but whose `RenderProjectionRecord` `RenderSceneQuery.TryGetByLocalEntityId` cannot resolve yet as contributing to NO cell for that frame — it is silently skipped rather than falling back to its authored parent cell or an outdoor root-position cell (both deleted this chunk). Every distinct entity id this happens for in one frame is counted once in `WalkProductionWorldData.UnregisteredRenderMembershipCount` and, when nonzero, reported by one print-only `[walk-membership]` line at the start of the next `BeginFrame`, gated on `RenderingDiagnostics.ProbeFacilityStairsEnabled`. | `src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs` (`ResolveCellView`, `UnregisteredRenderMembershipCount`, the `BeginFrame` diagnostic line) | Retail has no such gap at all: `CEnvCell::init_static_objects` installs the CELLARRAY before a static is ever drawable, and Contract B's collision (`shadow_object_list`) and render (`shadow_part_list`) products are ONE transaction, so they can never race. acdream's registry (the physics publisher) and its presentation scene (the projection journal) are two independently incremental pipelines fed off the same Create/appearance edge, so a transient one-frame window where the registry runs first is possible during streaming — the same class of race AD-49's residency reasoning already accepts for `CellTransit`'s own outdoor seed. Contributing NOTHING for that one frame matches retail's own rule ("an object not yet in a cell is not drawn") more closely than the deleted parent-cell/root-position fallbacks did, which could draw an object at a cell its real CELLARRAY does not actually include. | If the presentation journal's apply cadence ever falls more than one frame behind the registry's registration (not merely a same-frame ordering race), an entity would stay missing for several consecutive frames instead of appearing on the very next one — `UnregisteredRenderMembershipCount` staying nonzero across consecutive frames (not a single one-frame spike) is the signal that this row's "transient" premise has broken and needs re-investigation, not a widened fallback. | `CEnvCell::init_static_objects`; `CPartArray::AddPartsShadow` 0x00517e40 (`docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md` Contract B) | | AD-115 | **Filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16), classification: intentional.** `AppraisalUiController.BuildCharacterTitleDisplay` composes examination element `0x10000151` (Profession/title): when Int 261 `CharacterTitleId` is absent/unresolvable AND String 5 `Template` is also absent, it returns an empty string, and `ClearCreatureText` has already blanked the element for this `ApplyCreature` call, so the element stays cleared. Retail never clears `0x10000150`/`0x10000151`/`0x10000152` anywhere — neither `CharExamineUI::Show @0x004AB5D0` nor `BasicCreatureExamineUI::Init @0x004AB9C0` writes an empty string to those elements — so in this exact case retail would keep showing the PREVIOUS assessed target's title text on screen instead of clearing it. | `src/AcDream.App/UI/Layout/AppraisalUiController.cs` (`BuildCharacterTitleDisplay`, `ClearCreatureText`) | Deliberate improvement over retail's quirk: a stale leftover title from a prior target reads as more confusing/wrong to a player than a blank line for the current one; review F16 (2026-08-25) accepted the clear-on-no-source behavior as intentional. | None expected — this is a deliberate, reviewed divergence, not a game-feel regression; a future retail-faithfulness audit assuming `0x10000151` always mirrors retail's persistent stale-text behavior would be surprised to see it clear instead when the current target's title can't be resolved. | `CharExamineUI::Show @0x004AB5D0`; `BasicCreatureExamineUI::Init @0x004AB9C0` | | AD-114 | **Filed 2026-08-25 at Campaign AS slice AS2, owner-ruled 2026-08-25 (verbatim "we animate it, and I like it").** acdream's examination-window preview (`CreatureAppraisalFramePresenter` / `RetailCreatureAppraisalCloneFactory`) shares the assessed target's already-resolved live MeshRefs and re-synchronizes them every frame, so the preview clone plays the SAME current animated pose the live target is actually doing right now (attack, cast, run, idle, ...). Retail's `BasicCreatureExamineUI::Init @0x004AB9C0` instead clones the selected physics object ONCE, fixes its heading at 191.367905°, and lets its own private `CreatureMode` animate that clone independently — decoupled from whatever the live target is currently doing. | `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs` (`CreatureAppraisalFramePresenter`, `RetailCreatureAppraisalCloneFactory`) | Explicit owner direction, 2026-08-25 (`docs/plans/2026-08-25-assess-window-parity-campaign.md`: "The animated 3D paperdoll is an INTENTIONAL acdream deviation... Keep it"), noted alongside the owner's own observation that retail's static-clone colors are buggy — porting the decoupled-motion clone would not even be a faithfulness win here. | None expected — a deliberate, user-approved visual improvement over retail's decoupled clone motion, not a game-feel divergence; a future faithfulness audit assuming the preview mirrors retail's independent `CreatureMode` cycle would be surprised to see it track the live target's pose instead. | `BasicCreatureExamineUI::Init @0x004AB9C0`; `docs/plans/2026-08-25-assess-window-parity-campaign.md` | | AD-112 | **Filed 2026-08-23 with the sky default-script port (issues #28/#2, phase C.1.5c).** Retail plays the sky carriers' aurora/lightning/thunder PES by creating real `CPhysicsObj`s in the viewer-centered sky cells (`GameSky::MakeObject @0x00506EE0` → `CPhysicsObj::makeObject @0x00513970`; the Setup's `DefaultScript` marks `state \|= 0x80000` and `CPhysicsObj::animate_static_object @0x00513DF0` ticks `ScriptManager::UpdateScripts` + the ParticleManager). acdream plays the same scripts through `PhysicsScriptRunner` synthetic owners anchored at the camera (`SkyPesFrameController`), pass-routed by `props & 1` into the existing SkyPreScene/SkyPostScene particle draws — no sky-cell physics objects exist. Slot persistence keys on (index, gfx id, properties), the `CreateDeletePhysicsObjects @0x005073C0` identity law. The heading/rotation → pose axis mapping uses `UnitY` where retail's `GameSky::CalcFrame @0x00506F80` runs `set_heading` + `grotate`; every current PES carrier authors 0°/0° so the axis is unexercised. | `src/AcDream.App/Rendering/SkyPesFrameController.cs`; `src/AcDream.Core/World/SkyDescLoader.cs` (`SkyObjectData.DefaultScriptId`) | Retail's sky cells are camera-centered, so a camera-anchored world-space owner is the same geometry; the script/emitter engines are the production ones shared with entity effects; `SkyPesFrameControllerTests` pins the identity/persistence/window lifecycle. | A sky carrier authored with nonzero heading/rotation angles would orbit around the wrong axis; a PES whose hooks depend on real part frames (part_index targeting) would find only the synthetic owner's root pose. | `GameSky::MakeObject @0x00506EE0`; `GameSky::CreateDeletePhysicsObjects @0x005073C0`; `CPhysicsObj::animate_static_object @0x00513DF0`; `docs/research/2026-08-23-sky-default-script-port.md`; `SkyPesFrameControllerTests` | diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index 8e36eb46..e1552fef 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -193,19 +193,47 @@ per-entity retail CELLARRAY (`TryGetRetailCellArray`) plus per-cell part entries (`GetRetailPartEntriesInCell`) — not two independent floods, and not rebuilt per frame. A caller that supplies no part array (only test/legacy call sites remain) keeps the pre-chunk-3 collision-only dispatch untouched. -`WalkProductionWorldData`'s indoor and outdoor static sweeps BORROW the -retail array whole per record; they no longer recompute membership from each -MeshRef's prepared visual AABB (the deleted `ResolveStaticRenderCells` / -`ShadowObjectRegistry.ComputeStaticRenderCells` pair). A structural -consequence: an object mixing a colliding BSP part with a decorative -non-BSP part now gets a collision row for its BSP part in every cell the +**Consumer cutover to a borrowed per-cell view (S2 chunk 5, 2026-09-03).** +`WalkProductionWorldData`'s three per-frame scene sweeps (indoor static, +outdoor static, dynamic) and their five per-cell bucket dictionaries are +deleted. `GetCellStatics`/`GetCellDynamics`/`GetOutdoorStatics`/ +`GetOutdoorDynamics` instead read +`ShadowObjectRegistry.GetRetailPartEntriesInCell` for the QUERIED cell +directly — already in retail CELLARRAY-then-part-array insertion order — +collapse the entries to their distinct owning entity ids (an entity's own +run of entries for one cell is always contiguous, since the registry +removes-then-re-adds a whole entity's rows atomically), and resolve each id +back to its `RenderProjectionRecord` through +`RenderSceneQuery.TryGetByLocalEntityId` — a new presentation-side index +`ArchRenderScene` maintains alongside its existing per-cell/per-class +indices, keyed by the SAME `RenderSourceMetadata.LocalEntityId` the +registry is keyed by (App-only presentation state, never a second +membership source). Statics vs. dynamics is the record's own +`ProjectionClass` (`LiveDynamicRoot`/`EquippedChild` are dynamic; every +other class, including `ActiveAnimatedStatic`, is static — the same split +`ArchRenderScene`'s own internal indexing already used); building shells +are excluded from both — they draw at their own building's shell turn +(`_shellsByAnchor`, still filled by the ONE surviving narrow sweep, since +buildings are the landcell building channel, out of S2's scope), never at +the cell's ordinary object-list turn. A structural consequence carried +over from chunk 3: an object mixing a colliding BSP part with a decorative +non-BSP part gets a collision row for its BSP part in every cell the combined CELLARRAY reaches, including cells the BSP part alone would not -have crossed — membership is decided by the whole-part-array flood, not per -part; the BSP polygon test still decides actual contact at query time. A -record whose registry entry has no retail array yet (a streaming-window race -between the static-projection journal and the physics publisher) falls back -to its authored parent cell alone, counted by -`WalkProductionWorldData.UnregisteredStaticRenderFallbackCount`. +have crossed — membership is decided by the whole-part-array flood, not +per part; the BSP polygon test still decides actual contact at query time. +An entity the registry HAS flooded into a cell but whose projected record +the presentation journal has not applied yet this frame (the transient +race between the physics publisher and the projection journal — AD-116) +contributes to NO cell for that frame; there is no fallback to an authored +parent cell or a root-position cell any more (both deleted this chunk), +matching retail's own rule that an object not yet in a cell is not drawn. +Every distinct entity id this happens for in one frame is counted once in +`WalkProductionWorldData.UnregisteredRenderMembershipCount` (renamed from +`UnregisteredStaticRenderFallbackCount`, which under the pre-chunk-5 model +also covered the separate dynamic-record fallback) and reported by one +print-only `[walk-membership]` line — gated on +`RenderingDiagnostics.ProbeFacilityStairsEnabled`, the walk family's +existing diagnostic flag — at the start of the next frame when nonzero. A primitive-only Setup may reuse its already-authored cylsphere collision cell set; BSP-bearing and pure-visual statics use the all-visual-part box walk. The Facility Hub stair Setup `0x02000623` is the installed-DAT @@ -217,13 +245,15 @@ 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.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 +portal test. `WalkProductionWorldData.GetCellDynamics`/`GetOutdoorDynamics` +therefore read the SAME per-cell `GetRetailPartEntriesInCell` borrowed view +the static getters use (S2 chunk 5), filtered to the dynamic +`ProjectionClass`es — not a per-record `TryGetRetailCellArray` lookup with +its own fallback any more; an entity the registry has flooded into a cell +but the presentation scene cannot resolve yet contributes to no cell there, +counted by the shared `UnregisteredRenderMembershipCount` (AD-116), 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 @@ -260,9 +290,11 @@ 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. +registry, bounded and cycle-safe — `WalkProductionWorldData` never walks an +accepted-parent chain of its own; a child's part entries surface through the +SAME per-cell borrowed view (S2 chunk 5) every other dynamic record uses, +resolved to its own projection via `RenderSceneQuery.TryGetByLocalEntityId`, +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 diff --git a/docs/launch-options.md b/docs/launch-options.md index 89fda639..c8cbaa73 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -337,7 +337,7 @@ issue is closed, the strip was missed; delete both. | `ACDREAM_PROBE_CATHEDRAL_SKIP_LOOKIN_SHELL` | Campaign FW exact look-in shell discriminator | full hexadecimal cell id, e.g. `F4180112` | suppresses one exact EnvCell shell only when reached through a building look-in; its ordinary interior-root repaint is unchanged | **Behavior-changing diagnostic**; never use as a production fix | `RenderingDiagnostics.ProbeCathedralSkipLookInShellCellId` | | `ACDREAM_PROBE_CATHEDRAL_SKIP_SOUTH_BUILDING_SHELL` | Campaign FW south-building carrier discriminator | `=1` | suppresses only the own exterior shell of the cathedral building anchored at `0xF4180112`; its portal walk, look-in cells, and particles remain live | **Behavior-changing diagnostic**; never use as a production fix | `RenderingDiagnostics.ProbeCathedralSkipSouthBuildingShell` | | `ACDREAM_PROBE_CATHEDRAL_SHELL_ORDER` | Campaign FW floating-stair shell-order trace | `=1` | samples the ONE walk's actual replay event order every fifth cathedral frame (`CLEAR`, `SEALS`, punches, marks, and EnvCell shells) and reports render-stamp-deduplicated whole-shell turns for cells `0xF4180104/0106/0107/0112/0113/0114` | print-only; never changes admission, depth state, or draw order | `RenderingDiagnostics.ProbeCathedralShellOrderEnabled` | -| `ACDREAM_PROBE_FACILITY_STAIRS` | #177 Facility Hub stair probe (committed by the `b3b7d922` investigation checkpoint) | `=1` | traces the classifier admission, ordered-stream append, ring upload, and final indirect draw of the authored stair GfxObj `0x010000DE` and the local-player setup parts in cells `0x8A02015E/015F/01C1`, plus the walk root signature for those cells | print-only; never influences admission, order, or depth state; scheduled for deletion in Campaign OVERHAUL S5 | `RenderingDiagnostics.ProbeFacilityStairsEnabled` (read sites in `RetailPViewRenderer`, `WalkPView`, `WalkProductionWorldData`, `WalkStaticStreamPopulator`, `WbDrawDispatcher.*`) | +| `ACDREAM_PROBE_FACILITY_STAIRS` | #177 Facility Hub stair probe (committed by the `b3b7d922` investigation checkpoint) | `=1` | traces the classifier admission, ordered-stream append, ring upload, and final indirect draw of the authored stair GfxObj `0x010000DE` and the local-player setup parts in cells `0x8A02015E/015F/01C1`, plus the walk root signature for those cells; Campaign OVERHAUL S2 chunk 5 added a `[walk-membership]` line, printed once at the start of a frame only when `WalkProductionWorldData.UnregisteredRenderMembershipCount` was nonzero the PRIOR frame (an entity the registry flooded into a cell but the presentation scene could not resolve yet that frame) | print-only; never influences admission, order, or depth state; scheduled for deletion in Campaign OVERHAUL S5 | `RenderingDiagnostics.ProbeFacilityStairsEnabled` (read sites in `RetailPViewRenderer`, `WalkPView`, `WalkProductionWorldData`, `WalkStaticStreamPopulator`, `WbDrawDispatcher.*`) | | `ACDREAM_PROBE_SEAMDRAW` | #176, "throwaway apparatus" | `"1"`/`"true"`/blank → default #176 Facility Hub cell set (7 fixed hex ids); otherwise comma-separated hex cell-id list | change-deduped + 2 s-heartbeat `[seam-cell]`/`[seam-snap]`/`[seam-ent]`/`[seam-mask]` lines from `EnvCellRenderer.Render` and `WbDrawDispatcher` describing per-instance transforms and resolved light-set identities at target cells | print-only | `RenderingDiagnostics.ProbeSeamDrawEnabled` / `SeamDrawTargetCells` | | `ACDREAM_PROBE_STEP_WALK` | a6.p3 issue #98 | `=1` | gates `[step-walk]` lines at select points in the transition sub-step loop and step-down probe (requested vs adjusted offset, sphere positions, contact planes, walkable flags) | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeStepWalkEnabled` | | `ACDREAM_PROBE_SWEPT` | phase w stage 0 | `=1` | gates one `[cell-swept]` line per `ResolveWithTransition` call comparing the transition's swept cell vs the legacy static `ResolveCellId` path | print-only | `PhysicsDiagnostics.ProbeSweptEnabled` | 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 536fa011..f0d402cc 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 @@ -37,11 +37,11 @@ One transaction per object (`CPhysicsObj::calc_cross_cells_static` | Owner | Inputs | Output | Runs | Consumers | |---|---|---|---|---| | **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` | +| **END STATE (chunk 5, 2026-09-03).** `WalkProductionWorldData.GetCellStatics`/`GetCellDynamics`/`GetOutdoorStatics`/`GetOutdoorDynamics` → `ShadowObjectRegistry.GetRetailPartEntriesInCell(cellId)` → distinct entity ids in entry order → `RenderSceneQuery.TryGetByLocalEntityId` (`WalkProductionWorldData.cs` `ResolveCellView`; `RenderSceneContracts.cs`/`ArchRenderScene.cs` new `TryGetByLocalEntityId`/`_byLocalEntityId`). Superseded, in order: `ResolveStaticRenderCells`/`ComputeStaticRenderCells` (chunk 2 deleted these) → `ResolveIndoorStaticRenderCells`/`ResolveOutdoorStaticRenderCells`/`ResolveDynamicRenderCells` + the five per-cell bucket dictionaries + the three per-frame scene sweeps (chunk 5 deletes ALL of these) | none (borrowed, per QUERIED cell, on demand — no per-frame sweep of the scene at all any more) | one cell's retail `RetailPartEntry` rows, filtered to the record's own `ProjectionClass` (static vs. dynamic) and excluding `IsBuildingShell` | every `Get*` call at a walk turn (lazy, cached per cell per frame in the SAME `_cellCache`/`_cellDynamicCache`/`_outdoorMaterialized`/`_outdoorDynamicsMaterialized` dictionaries chunk 2 introduced) | `WalkFrameDriver.GetCellStatics`/`GetCellDynamics`/`GetOutdoorStatics`/`GetOutdoorDynamics`; `RetailPViewRenderer` particle owner union (`GetCellStatics`, unchanged call); unregistered fallback is now "contributes to no cell" (no authored-parent or root-position substitute), counted by the renamed `WalkProductionWorldData.UnregisteredRenderMembershipCount` (once per DISTINCT entity per frame, not per cell visit) — AD-116 | | **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` | +| **DELETED (chunk 5).** Was `WalkProductionWorldData.ResolveDynamicRenderCells` (a per-record `TryGetRetailCellArray` read). Dynamics now share the SAME `ResolveCellView` end-state row above as statics — no record-keyed lookup, no separate dynamic resolver, no separate fallback | — | — | — | superseded by the END STATE row above | | `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` | @@ -117,10 +117,11 @@ 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; -duplicate dictionaries and fallbacks are deleted; architecture text updated. -Lights and directional shadows stay as they are (S5). +**Chunk 5 — consumer cutover and deletion (landed 2026-09-03).** Remaining +`WalkProductionWorldData` buckets become borrowed views; particle owner-cell +union reads the same; duplicate dictionaries and fallbacks are deleted; +architecture text updated. Lights and directional shadows stay as they are +(S5). See §8 for the evidence. Owner gate G2 after chunk 5 (plan §9 S2). @@ -479,3 +480,245 @@ nesting, and cycle rejection). App suite (hermetic lanes): 6,761/6,761 (was 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. + +## 8. Chunk 5 evidence (2026-09-03) + +**A — borrowed per-cell views.** `WalkProductionWorldData.BeginFrame`'s three +per-frame scene sweeps (indoor static, outdoor static, dynamic) and their +scratch arrays (`_indoorSweepScratch`, `_dynamicSweepScratch`), the five +per-cell bucket dictionaries (`_indoorByCell`, `_indoorDynamicsByCell`, +`_outdoorByCell`, `_outdoorDynamicsByCell`), `BucketIndoorRecord`/ +`BucketOutdoorRecord`/`BucketDynamicRecord`, and +`ResolveIndoorStaticRenderCells`/`ResolveOutdoorStaticRenderCells`/ +`ResolveDynamicRenderCells` are all deleted. `GetCellStatics`/ +`GetCellDynamics`/`GetOutdoorStatics`/`GetOutdoorDynamics` share one private +resolver, `ResolveCellView(cellId, dynamic)`: read +`ShadowObjectRegistry.GetRetailPartEntriesInCell(cellId)` (already in retail +CELLARRAY-then-part-array insertion order), collapse ADJACENT entries with +the same `EntityId` to their distinct owning entity ids (safe because +`PublishRetailPartEntries` always removes-then-re-adds a whole entity's rows +atomically — one entity's own run for a cell is never interleaved with +another's), resolve each id to its `RenderProjectionRecord` via the new +`RenderSceneQuery.TryGetByLocalEntityId`, filter to the record's own +static-vs-dynamic `ProjectionClass` (`LiveDynamicRoot`/`EquippedChild` are +dynamic — the SAME split `ArchRenderScene.IsDynamic` already used +privately), and exclude `IsBuildingShell`. Indoor vs. outdoor needs no +dispatch of its own: the CALLER already queries the right id space (an +indoor `GetCellStatics(cellId)` call and an outdoor `GetOutdoorStatics(cellId)` +call each pass a cell id from their own id space), and the registry has +already published the entity into every crossed cell under its true id +either way. The per-frame result caches (`_cellCache`, +`_cellDynamicCache`, `_outdoorMaterialized`, `_outdoorDynamicsMaterialized`) +and the arena (`_arena`/`AppendToArena`) are UNCHANGED — chunk 5 adds one new +grow-on-demand scratch buffer, `_cellViewScratch` (starts at 64 records), +that `ResolveCellView` fills before a single `AppendToArena` call per cell, +matching the existing `_sweepScratch` growth shape. Building shells keep +their own narrow survivor sweep (`_shellsByAnchor`, `GetBuildingShellStatics`) +exactly as before — out of S2's scope (the landcell building channel). + +The scene-side half of this borrow is new: `RenderSceneQuery` gains +`TryGetByLocalEntityId(uint localEntityId, out RenderProjectionRecord)` +(`RenderSceneContracts.cs`'s `IRenderSceneQuerySource` interface + the query +struct forwarder), implemented in `ArchRenderScene` by a new +`Dictionary _byLocalEntityId` maintained at the +SAME `AddToIndices`/`RemoveFromIndices` insertion points as the existing +per-cell (`_cellStatics`/`_cellDynamics`) and per-class indices — purely +presentation-side derived state, cleared by `ClearIndices` alongside +everything else, never a second membership source. The one other +`IRenderSceneQuerySource` implementation in the tree, the +`DirectionalShadowCasterFrameTests.QuerySource` test fake, gained a matching +linear-scan implementation so the interface stays satisfiable outside +production. + +**B — effect-object render-only registration.** `LiveEntityCollisionBuilder. +Build` (Runtime) now computes `renderParts` (`ShadowShapeBuilder. +FromSetupRenderParts`) BEFORE its early-return gate, and returns a non-null +`LiveEntityCollisionRegistration` whenever EITHER `shapes` (collision) or +`renderParts` is non-empty (or `retainEmptyPayload` is set) — previously an +empty `shapes` list alone (a Setup with visual parts but no CylSphere/ +Sphere/physics-BSP part) returned `null` unconditionally, silently dropping +`renderParts` too and never reaching the registry at all. The materializer +call site (`DatLiveEntityProjectionMaterializer.cs:751-763`) needed NO +change: it already calls `LiveEntityCollisionBuilder.Register(_shadows, +collision)` whenever `Build` returns non-null, and `Register` already +forwards `registration.RenderParts` as `RegisterMultiPart`'s `partArray:` +argument. + +`ShadowObjectRegistry.RegisterMultiPart`'s `shapes.Count == 0` branch no +longer unconditionally deregisters: when a non-empty `partArray` is also +supplied, it calls the new private `RegisterRenderOnly`, which floods via +`ComputeContractACellArray` with an EMPTY `collisionShapes` argument (so +Contract A's cylsphere test is always false and the bbox route always wins — +"route per Contract A with an empty collision dispatch"), publishes the +resulting CELLARRAY/`RetailPartEntry` rows exactly like any other +registration, and retains `_entityShapes[entityId] = []` (not absent) so +`UpdatePosition`'s `IsMultiPart && _entityShapes.TryGetValue` dispatch and +`ReplacePositionRows`'s per-shape loop both take the correct "multi-part, +zero shapes" branch on a later move instead of falling back to the +single-shape `Register` path or synthesizing a bogus zero-radius collision +entry. `ReplaceMultiPartPayload`'s own "not yet registered" early-return +guard gained the same `partArray`-aware condition, and `ReplacePositionRows` +gained a `wroteCollisionEntries` guard so a render-only entity moving +through the direct (non-staged) `CommitSetPosition` path never gets a ghost +`_entityToCells` entry with zero backing `ShadowEntry` rows. NO collision row +is EVER published for a render-only entity — `GetOwnerCells` stays empty +across register/move/deregister — matching Contract B exactly (an object +contributes to `shadow_object_list` only when it has a part TO shadow +there). + +One adjacent gap was found and left OPEN, not fixed: `ShadowObjectRegistry. +RefloodOwnerForLandblock` (the STATIC/offline `LandblockPhysicsContentBuilder` +streaming-reflood path, `AcDream.Content`) calls `RegisterMultiPart`/ +`Register` without forwarding the entity's retained `partArray` at all — +since `RegisterMultiPart`'s body always `DeregisterCore`s (which clears the +retail array) before re-flooding, ANY reflooded entity with a retained retail +part array silently loses its render product on reflood, and a genuinely +render-only entity would be deregistered outright (its `shapes.Count == 0` +with no forwarded `partArray` hits the `Deregister` branch). This predates +chunk 5 (it exists since chunk 1b/3 threaded `partArray:` through every OTHER +call site) and is out of chunk 5's live-entity-materializer scope — no +production live-entity path reaches `RefloodOwnerForLandblock` (only the +offline static content builder does) — but it is a real gap for +landblock-edge STATICS reflooded near a streaming boundary and should be +scoped as its own small follow-up. + +**C — the one remaining fallback.** Under the borrowed-view model the +former TWO fallback directions collapse to ONE. "Scene has a record, registry +never registered it at all" is not observable any more — `ResolveCellView` +only ever iterates registry entries, so such an entity is simply never +visited (zero code, matching "an object not yet in a cell is not drawn" +exactly, no counter). "Registry HAS flooded the entity into a cell, but the +scene's projected record isn't resolvable yet" (the presentation journal +lagging the physics publisher within the same frame) is the one real, +observable race: `ResolveCellView`'s `!_scene.TryGetByLocalEntityId(...)` +branch counts the entity ONCE per frame (via a per-frame `HashSet +_unregisteredEntitiesThisFrame`, so an entity crossing several cells or +queried through both the static/dynamic getter pair is not double-counted) +into the renamed `WalkProductionWorldData.UnregisteredRenderMembershipCount`, +and contributes to NO cell — no authored-parent or root-position substitute +survives. `BeginFrame` prints one `[walk-membership] unregistered=N +tupleLandblock=0x…` line, gated on the existing walk-family flag +`RenderingDiagnostics.ProbeFacilityStairsEnabled` (reused, no new env var), +reporting the PRIOR frame's final count at the start of the next +`BeginFrame` — the only point a "frame is done" signal exists in this +class's lazy, on-demand `Get*` model. AD-116 files this deviation citing +AD-49's residency-race reasoning (the register's OTHER residency-timing row; +the ownership-map's original chunk-2 citation of "AD-40" was already stale +against the current register — AD-40 there is the unrelated fsf transient-bit +row — AD-49 is the one that actually argues residency-race safety). + +**D — `GetOwnerCells` / `TryGetRetailCellArray` App-layer consumer audit.** +`grep -rn "\.GetOwnerCells(" src/AcDream.App/ src/AcDream.Runtime/ +src/AcDream.Content/` after chunk 5 finds exactly ONE call site: +`WalkProductionWorldData.EmitFacilityShadowProbe` (unchanged this chunk) — +a diagnostic probe, gated on `ProbeFacilityStairsEnabled`, that PRINTS the +collision-owner cell set for the authored Facility/cathedral stair +SourceIds and the local player for comparison; it never feeds admission or +membership. `grep -rn "\.TryGetRetailCellArray(\|\.GetRetailPartEntriesInCell(" +src/AcDream.App/` finds every call site inside `WalkProductionWorldData.cs` +itself (`ResolveCellView`, `StaticBucketContains`) — nothing else in App +reconstructs or reads registry membership. `RetailPViewRenderer`'s particle +owner union calls `GetCellStatics` (the borrowed view), unchanged. + +**Test evidence.** Core `ShadowObjectRegistryRetailCellArrayTests`: 27/27 +(was 22; +5 new fixtures covering item B's render-only registration, +keep-when-empty, deregister, and move). App `LiveEntityCollisionBuilderTests`: +13/13 (was 12; +1 covering `Build`/`Register` producing a render-only +registration end to end against a real registry). App +`WalkProductionWorldDataTests`: 11/11 (was 12 pre-chunk-5 — 8 bucket-primitive +tests and 2 `ResolveDynamicRenderCells` tests deleted with their methods, 9 +new tests added against the real production path — a real bare +`ShadowObjectRegistry` + real `ArchRenderScene`, never a hand-fed bucket +dictionary — covering registry-driven resolution, the one remaining +fallback + its per-entity-not-per-cell counting, static/dynamic split, +building-shell exclusion, outdoor parity, per-frame caching, and +`StaticBucketContains`). Net App suite delta: 0 (6,761/6,761 both before and +after, since the file-level and materializer-level deltas cancel exactly). +`Issue177StairDescentCameraFloodTests.FacilityStairAssembly_ +RegisterAcross015FTo015E` (Lane=InstalledDat) required only the +`UnregisteredStaticRenderFallbackCount` → `UnregisteredRenderMembershipCount` +rename — its own registry/scene wiring already matches the chunk-5 model +byte-for-byte since it looks records up by `LocalEntityId`, not by any +formulaic id. + +**Automated gate.** Full Release solution build: 0 warnings, 0 errors. Core +`Lane!=Timing&Lane!=Linux`: 4,975/4,975 (4,970 + 5 new). App hermetic lanes: +6,761/6,761 (unchanged net). App `Lane=InstalledDat`: 147 passed / 97 skipped +/ 1 failed — the one failure, +`TowerAscentReplayTests.TowerAscent_StaircaseStaysConeVisible_EveryStep`, +is PRE-EXISTING and unrelated: the test file has no reference to +`WalkProductionWorldData` or `ShadowObjectRegistry` at all, its last commit +predates Campaign OVERHAUL entirely, and `docs/ISSUES.md`'s #119-residual +entry already documents this exact pin as needing re-validation against the +production resolver before it can be trusted ("its band came from the +harness's AABB root model, not the production sweep") — an S3-scope +viewcone/cone-cull concern, not S2 membership. Runtime +`Lane!=Timing&Lane!=Linux`: 1,884/1,884 (unchanged — chunk 5 touched no +Runtime test-visible behavior beyond `LiveEntityCollisionBuilder.Build`'s +early-return condition, already covered from the App side). + +**Allocation note.** `ResolveCellView` performs no per-call heap allocation +after warmup: `GetRetailPartEntriesInCell` returns an existing `List` +cast to `IReadOnlyList` (no copy), the entity-id dedup loop is a plain +`for` over that list with two `uint` locals, `_cellViewScratch` is a +grow-on-demand array reused every call (doubles only when a cell's +membership exceeds its current capacity), and `AppendToArena` copies into +the existing per-frame arena (`_arena`), also grow-on-demand. The one +per-frame allocation-shaped addition is `_unregisteredEntitiesThisFrame` +(a `HashSet`, cleared not reallocated every `BeginFrame`, same +grow-once-then-stable shape as every other per-frame dictionary/set in this +class) — its `.Add(uint)` calls are non-boxing value-type operations. + +## 9. S2 closeout: the reflood gap, the G2 self-gate finding, and the review fix round (2026-09-03) + +**Reflood (lead, landing chunk 5).** `RefloodOwnerForLandblock` re-registered +from `_entityShapes` only, so every owner reflooded by a landblock replacement +commit (`PhysicsEngine.ApplyCommittedOwnerReplacement`) or by the Content +builder's post-publication reflood lost its retail product — with chunk 5's +consumer reading nothing else, it vanished. Fixed by forwarding the retained +`_entityRetailPartArrays` entry exactly as `UpdatePosition` already did +(retail: a reflood is `recalc_cross_cells` over the SAME `CPartArray`). Pins: +`RefloodLandblock_KeepsTheRetailProductOfACollisionOwnerWithAPartArray`, +`RefloodLandblock_KeepsARenderOnlyOwner` (both fail without the fix). + +**G2 self-gate finding (pixel diff, not eyes).** Frame `02-facility-stairs.png` +differed from the chunk-4 run in 23% of pixels; the chunk-4/baseline frames +show the wall panels beside the corridor lit by its purple lights (left-wall +mean RGB 65,17,21 baseline / 78,20,25 chunk 4) while the chunk-5 frame shows +plain grey stone (29,26,23) with the drudge still pink-lit. Not lighting: the +panels are DAT statics with no collision geometry, and `PublishStaticEntity` +registered a static only under `entityBspCount > 0` / `setupShapes.Count > 0` +— they had only ever been drawn through the parent-cell fallback chunk 5 +deleted. `UnregisteredRenderMembershipCount` stayed 0 because it counts the +registry→scene direction only. Retail floods every object regardless of +collision (`CEnvCell::init_static_objects` 0x0052c350 → `add_obj_to_cell`; +`add_shadows_to_cells` 0x00514ae0 adds the shadow row unconditionally and +gates only `AddPartsShadow` on `part_array != 0`). Fix: both publishers +register a non-colliding static render-only (`RegisterMultiPart` with empty +shapes + the part array → `RegisterRenderOnly`). Pins: +`CompletePublication_NonCollidingStaticRegistersRenderOnly` (App), +`PublishStaticCollision_NonCollidingGfxObjStatic_RegistersRenderOnly` +(Content). + +**Review fix round (arch lens + retail lens, both Opus, every finding +lead-verified against the source before any edit).** + +| # | Finding (verified) | Fix | Pin | +|---|---|---|---| +| A1/R2 | `Suspend` removed collision rows only; a suspended remote kept render membership at its last cells | `Suspend` clears the retail rows + republishes children; part array/route retained; the un-suspending `.None` commit republishes from the retained cells | `Suspend_ClearsTheRetailProduct_AndTheUnsuspendingMoveRepublishesIt` | +| A2 | `AttachChild`/`DetachChild` never advanced `_mutationRevision`, so a prepared SetPosition's wholesale cell-list install could drop an attached child's rows | both advance the revision | `AttachChild_AndDetachChild_AdvanceTheMutationRevision` | +| A3 | a re-registration of an attached child (appearance update) flooded it independently, overriding the inherited root array | `PublishRetailCellArray` short-circuits for an attached child: refresh `_childPartArrays`, `PublishChildEntries` | `RegisterMultiPart_OfAnAttachedChild_KeepsInheritingTheRootsCells` | +| A4/R4 | `RemoveLandblock` and the non-rooted `RetireOwnerFromLandblock` branch left every retail row behind (unbounded growth, stale membership); render-only statics were unreachable by the static-retirement loop | `RemoveRetailProductForPrefix` (rows for the prefix's cells, per-owner cell pruning, static/render-only owners end with the landblock); the retire branch prunes retail cells first and handles render-only owners | `RemoveLandblock_ClearsTheRetailProduct_IncludingRenderOnlyStatics`, `RetireOwnerFromLandblock_PrunesTheRetailRowsOfANonRootedOwner` | +| R3 | `RefreshPositionRows` (`.None`/keep-when-empty) republished from collision cells only → a render-only owner could never move | terminal branch republishes a render-only owner at its destination seed cell (AD-117 item 1) | `CommitSetPosition_None_MovesARenderOnlyOwnerToItsDestinationCell` | +| A9 | `ReplaceMultiPartPayload` with a non-null EMPTY part array left a cell array with no rows and a permanent no-op on later moves | `partArray is { Count: > 0 }` | `ReplaceMultiPartPayload_WithAnEmptyPartArray_KeepsThePriorRows` | +| A7/A8 | `RemoveAll` closure per cell per move; LINQ `Where().ToArray()` in the staged capture | `RemoveOwnerPartRows` + `CollectOwnerRows`/`CollectOwnerPartRows` | existing suites | +| A5 | EnvCell shell projections carry their CELL id as `LocalEntityId`; dungeon cell ids alias the `0x8…` scenery namespace in `_byLocalEntityId` | shells are not indexed | `TryGetByLocalEntityId_IgnoresEnvCellShellRecords` | +| A6 | `IndexMembershipEquals` omitted `LocalEntityId`, so an in-place id rebind left the index stale | compares the id | `Update_ThatRebindsTheLocalEntityId_MovesTheIndex` | +| A10 | `_cellStatics`/`_cellDynamics` + `GetCellCount`/`CopyCellTo`/`GetCellStaticCount`/`CopyCell*To` had zero production callers | deleted | `ArchRenderSceneTests` updated | +| A11/A12, R5–R7 | live list handed out; probe-flag coupling; unloaded-neighbour rows; circumsphere cheap reject; particle branch | documented (AD-117 items 2–4; S5 tail) | — | + +Verified-correct list from both reviews (kept for S5): Contract A dispatch, +`find_bbox_cell_list` shape, `AddPartsShadow` order/clip-plane rule, +movement from the transition's array, keep-when-empty, child inheritance +order, `Deregister` symmetry, cross-generation adoption seeding the part +array, ordering determinism, zero-alloc `ResolveCellView`, layering. diff --git a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs index c43a3690..b25ed82d 100644 --- a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs +++ b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs @@ -31,10 +31,11 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource private readonly HashSet _selectable = []; private readonly HashSet _lightCandidates = []; private readonly HashSet _dirty = []; - private readonly Dictionary> - _cellStatics = []; - private readonly Dictionary> - _cellDynamics = []; + // Campaign OVERHAUL S2 chunk 5: presentation-side lookup by the entity's + // stable LocalEntityId — see IRenderSceneQuerySource.TryGetByLocalEntityId. + // Maintained at the same register/update/unregister points as the cell + // indices above; never a membership source. + private readonly Dictionary _byLocalEntityId = []; private ArchWorld _world; private RenderProjectionCounts _counts; private ulong _lastAppliedJournalSequence; @@ -434,6 +435,23 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource return false; } + bool IRenderSceneQuerySource.TryGetByLocalEntityId( + RenderSceneGeneration generation, + uint localEntityId, + out RenderProjectionRecord record) + { + EnsureQueryGeneration(generation); + if (_byLocalEntityId.TryGetValue(localEntityId, out RenderProjectionId id) + && _entries.TryGetValue(id, out SceneEntry entry)) + { + record = ReadRecord(in entry); + return true; + } + + record = default; + return false; + } + int IRenderSceneQuerySource.CopyById( RenderSceneGeneration generation, ReadOnlySpan ids, @@ -499,31 +517,6 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource return CopyIdsTo(source, destination); } - int IRenderSceneQuerySource.GetCellCount( - RenderSceneGeneration generation, - uint fullCellId, - bool dynamic) - { - EnsureQueryGeneration(generation); - Dictionary> index = - dynamic ? _cellDynamics : _cellStatics; - return index.TryGetValue(fullCellId, out var ids) ? ids.Count : 0; - } - - int IRenderSceneQuerySource.CopyCellTo( - RenderSceneGeneration generation, - uint fullCellId, - bool dynamic, - Span destination) - { - EnsureQueryGeneration(generation); - Dictionary> index = - dynamic ? _cellDynamics : _cellStatics; - return index.TryGetValue(fullCellId, out var ids) - ? CopyIdsTo(ids, destination) - : 0; - } - private static ArchWorld CreateWorld() => ArchWorld.Create( archetypeCapacity: InitialArchetypeCapacity, @@ -809,21 +802,14 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource if (staticProjection) { if (indoor) - { _indoorCellStatics.Add(record.Id); - AddCell(_cellStatics, record.Residency.FullCellId, record.Id); - } else - { _outdoorStatics.Add(record.Id); - } } else { _dynamics.Add(record.Id); - if (indoor) - AddCell(_cellDynamics, record.Residency.FullCellId, record.Id); - else + if (!indoor) _outdoorDynamics.Add(record.Id); if ((record.Flags & RenderProjectionFlags.PortalStraddling) != 0) _portalStraddlingDynamics.Add(record.Id); @@ -837,6 +823,17 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _lightCandidates.Add(record.Id); if (record.DirtyMask != RenderDirtyMask.None) _dirty.Add(record.Id); + // An EnvCell shell projection carries its CELL id in the entity-id + // slot (StaticRenderProjectionJournal.ProjectEnvCellShell); cell ids + // with a 0x8_ high nibble alias the procedural-scenery entity + // namespace, so shells never enter this index (S2 review F5) — the + // walk reaches them through the leaf-renderer shell path, never by + // entity id. A shell is the one record built WITHOUT an entity + // payload (every static/dynamic entity record carries its MeshRefs + // from RenderProjectionRecordFactory); the projection class cannot + // tell them apart because interior statics share IndoorCellStatic. + if (record.Source.LocalEntityId != 0 && !IsEnvCellShell(in record)) + _byLocalEntityId[record.Source.LocalEntityId] = record.Id; AdvanceIndexRevision(); AdvanceDirectionalShadowTopologyRevision(); } @@ -852,12 +849,24 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _selectable.Remove(record.Id); _lightCandidates.Remove(record.Id); _dirty.Remove(record.Id); - RemoveCell(_cellStatics, record.Residency.FullCellId, record.Id); - RemoveCell(_cellDynamics, record.Residency.FullCellId, record.Id); + if (record.Source.LocalEntityId != 0 + && _byLocalEntityId.TryGetValue( + record.Source.LocalEntityId, + out RenderProjectionId mapped) + && mapped == record.Id) + { + _byLocalEntityId.Remove(record.Source.LocalEntityId); + } AdvanceIndexRevision(); AdvanceDirectionalShadowTopologyRevision(); } + /// The record is an EnvCell shell (cell geometry), not an + /// entity: no entity payload was ever attached. See AddToIndices. + private static bool IsEnvCellShell(in RenderProjectionRecord record) => + record.ProjectionClass == RenderProjectionClass.IndoorCellStatic + && record.EntityPayload.MeshRefs is null; + private static bool IndexMembershipEquals( in RenderProjectionRecord left, in RenderProjectionRecord right) @@ -870,6 +879,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource | RenderProjectionFlags.SpatiallyResident; return left.ProjectionClass == right.ProjectionClass + // _byLocalEntityId is keyed by this field (S2 review F6). + && left.Source.LocalEntityId == right.Source.LocalEntityId && left.Source.ParentCellId == right.Source.ParentCellId && left.Residency.FullCellId == right.Residency.FullCellId && (left.Flags & indexedFlags) == (right.Flags & indexedFlags) @@ -1141,33 +1152,6 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource projectionClass is RenderProjectionClass.LiveDynamicRoot or RenderProjectionClass.EquippedChild; - private static void AddCell( - Dictionary> index, - uint fullCellId, - RenderProjectionId id) - { - if (fullCellId == 0) - return; - if (!index.TryGetValue(fullCellId, out HashSet? ids)) - { - ids = []; - index.Add(fullCellId, ids); - } - ids.Add(id); - } - - private static void RemoveCell( - Dictionary> index, - uint fullCellId, - RenderProjectionId id) - { - if (!index.TryGetValue(fullCellId, out HashSet? ids)) - return; - ids.Remove(id); - if (ids.Count == 0) - index.Remove(fullCellId); - } - private HashSet Index(RenderSceneIndex index) => index switch { @@ -1223,13 +1207,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource + _selectable.EnsureCapacity(0) + _lightCandidates.EnsureCapacity(0) + _dirty.EnsureCapacity(0); - foreach (HashSet ids in _cellStatics.Values) - slots += ids.EnsureCapacity(0); - foreach (HashSet ids in _cellDynamics.Values) - slots += ids.EnsureCapacity(0); - long cellLookupSlots = - _cellStatics.EnsureCapacity(0) - + _cellDynamics.EnsureCapacity(0); + long cellLookupSlots = _byLocalEntityId.EnsureCapacity(0); return checked(slots * 24L + cellLookupSlots * 40L); } @@ -1244,8 +1222,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _selectable.Clear(); _lightCandidates.Clear(); _dirty.Clear(); - _cellStatics.Clear(); - _cellDynamics.Clear(); + _byLocalEntityId.Clear(); } private void IncrementCount(RenderProjectionClass projectionClass) diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs index bc410372..91595543 100644 --- a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs +++ b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs @@ -557,6 +557,22 @@ internal interface IRenderSceneQuerySource RenderProjectionId id, out RenderProjectionRecord record); + /// + /// Campaign OVERHAUL S2 chunk 5: a presentation-side lookup by the + /// entity's stable — + /// the SAME id 's retail CELLARRAY and + /// per-cell RetailPartEntry rows are keyed by. This is derived + /// presentation state (maintained alongside the existing per-cell/ + /// per-class indices at the same register/update/unregister points), + /// never a second membership source: cell membership still comes from + /// the registry alone, this only resolves an entity id back to its + /// current projected record. + /// + bool TryGetByLocalEntityId( + RenderSceneGeneration generation, + uint localEntityId, + out RenderProjectionRecord record); + int CopyById( RenderSceneGeneration generation, ReadOnlySpan ids, @@ -572,16 +588,10 @@ internal interface IRenderSceneQuerySource RenderSceneIndex index, Span destination); - int GetCellCount( - RenderSceneGeneration generation, - uint fullCellId, - bool dynamic); - - int CopyCellTo( - RenderSceneGeneration generation, - uint fullCellId, - bool dynamic, - Span destination); + // Campaign OVERHAUL S2 review fix (arch F10): the per-cell scene indices + // (GetCellCount/CopyCellTo, keyed by the single authored FullCellId) are + // deleted — the registry's retail CELLARRAY is the ONLY render-membership + // source (ShadowObjectRegistry.GetRetailPartEntriesInCell). } internal readonly struct RenderSceneQuery @@ -626,6 +636,11 @@ internal readonly struct RenderSceneQuery out RenderProjectionRecord record) => Source.TryGet(Generation, id, out record); + public bool TryGetByLocalEntityId( + uint localEntityId, + out RenderProjectionRecord record) => + Source.TryGetByLocalEntityId(Generation, localEntityId, out record); + public int CopyById( ReadOnlySpan ids, Span destination) => @@ -644,30 +659,6 @@ internal readonly struct RenderSceneQuery Span destination) => Source.CopyIndexTo(Generation, index, destination); - public int GetCellStaticCount(uint fullCellId) => - Source.GetCellCount(Generation, fullCellId, dynamic: false); - - public int CopyCellStaticsTo( - uint fullCellId, - Span destination) => - Source.CopyCellTo( - Generation, - fullCellId, - dynamic: false, - destination); - - public int GetCellDynamicCount(uint fullCellId) => - Source.GetCellCount(Generation, fullCellId, dynamic: true); - - public int CopyCellDynamicsTo( - uint fullCellId, - Span destination) => - Source.CopyCellTo( - Generation, - fullCellId, - dynamic: true, - destination); - private IRenderSceneQuerySource Source => _source ?? throw new InvalidOperationException("The render-scene query is uninitialized."); diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs index de43a31d..dddce624 100644 --- a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs +++ b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs @@ -9,70 +9,70 @@ namespace AcDream.App.Rendering.Walk; /// /// Campaign FW3.2b-2: the production over /// the retained scene () and the FW3.1 -/// . Rebuilt facts per frame via -/// : +/// . /// -/// -/// Cell statics — ONE sweep -/// over authored indoor statics, bucketed into every cell of -/// 's retail -/// CELLARRAY. Campaign OVERHAUL S2 chunk 2: membership is BORROWED from that -/// registry — the sole owner of retail's calc_cross_cells_static -/// (0x00515160) → CPartArray::AddPartsShadow (0x00517e40) transaction -/// — never rebuilt in this class. A registered entity's array deliberately -/// includes non-colliding decorations and is distinct from the physics -/// shadow-object index (). -/// An entity the registry has not registered yet (the streaming window -/// where the static-projection journal published the record before the -/// physics publisher registered the entity — two independent incremental -/// state machines) falls back to its authored -/// alone and is counted in -/// . Per-cell PART -/// entries () -/// are not consumed here — buckets stay whole-record per cell, which equals -/// CELLARRAY membership; per-part draw admission is Campaign OVERHAUL S3. -/// Live dynamics — ONE global dynamic-index sweep, bucketed into every -/// cell in the object's retained physics CELLARRAY. Retail feeds that -/// exact array to CPhysicsObj::add_shadows_to_cells, which calls -/// CPartArray::AddPartsShadow for every member cell. A creature crossing -/// a stair portal must therefore remain drawable from both the feet cell and -/// the head cell; indexing only by its authored parent makes individual body -/// parts disappear at the portal edge. -/// Outdoor statics — ONE -/// sweep bucketed the same way as cell statics — the registry's retail -/// CELLARRAY first, falling back to -/// (counted the same way) -/// when unregistered — EXCLUDING building shells (they draw at their -/// building's own shell turn, retail CPhysicsPart::Draw(parts, 0) -/// @0x0059f331, not at the cell's DrawObjCell turn). -/// Building shells — the same sweep's IsBuildingShell records -/// bucketed by Source.BuildingShellAnchorCellId for portal-bearing -/// buildings. Portal-less buildings have no interior anchor; retail still -/// draws them at their landscape position-cell turn, so those records use -/// Source.EffectCellId, matching . -/// +/// +/// Campaign OVERHAUL S2 chunk 5: cell membership is a BORROWED VIEW over +/// 's retail per-cell part-entry product +/// ( — retail's +/// CPartArray::AddPartsShadow 0x00517e40 output, keyed by the SAME +/// CELLARRAY +/// answers). This class no longer sweeps the scene's static/dynamic indices +/// to REBUILD membership: for a queried cell id, +/// walks the registry's per-cell entries (already in retail +/// CELLARRAY-then-part-array insertion order), collapses them to their +/// distinct owning entity ids in that same order, and resolves each id back +/// to its current through +/// — a presentation-side +/// lookup by the SAME the +/// registry is keyed by (chunk 5's App-side addition to +/// ArchRenderScene). Statics vs. dynamics are the same +/// ProjectionClass split ArchRenderScene itself uses (a live +/// dynamic root or an equipped child is "dynamic"; every other class is +/// "static"), and building shells are excluded — they draw at their own +/// building's shell turn (retail CPhysicsPart::Draw(parts, 0) +/// @0x0059f331), never at the cell's ordinary DrawObjCell turn. +/// /// -/// The tuple landblock id handed to the classifier is the frame's player -/// landblock, matching the production walk's retained-scene query convention. +/// +/// An entity the registry HAS flooded into this cell but whose projected +/// record the presentation journal has not applied yet this frame (the +/// transient race between LiveEntityRuntime's projection journal and +/// ShadowObjectRegistry's physics-side registration, both driven off +/// the same Create/appearance edge but landing through independent +/// incremental pipelines) contributes to NO cell — retail draws nothing for +/// an object not yet in a cell; there is no second, conservative fallback. +/// Every DISTINCT entity id this happens for in one frame is counted once in +/// , regardless of how many +/// cells its CELLARRAY touches or how many Get* calls observe it. +/// /// -/// Campaign FW3.4a: , , -/// and used to materialize their result -/// with _cellScratch[..count] / [.. bucket] — a FRESH -/// RenderProjectionRecord[] allocation per distinct cell/anchor per -/// frame. At a town-density frame (dozens of cells) that was the single -/// largest contributor to the FW3.4 perf checkpoint's 14× frame-allocation -/// regression (1.9 MB/frame p50). replaces it: a -/// grow-only buffer, reset to length 0 once per frame in -/// , that every materialization call -/// s its records into instead of snapshotting a -/// new array — after the arena reaches its steady-state size (a few frames -/// of warmup, same shape as /'s -/// existing grow-on-demand pattern), zero further heap allocation occurs -/// here. Every segment is +/// Building shells are the ONE exception left to a per-frame sweep: +/// retail's shell/portal machinery is out of S2's scope (the landcell +/// building channel), so still reads a +/// small per-frame bucket (_shellsByAnchor) filled by a single narrow +/// sweep in +/// that keeps only IsBuildingShell records — +/// it never resolves ordinary static/dynamic membership. +/// +/// Campaign FW3.4a: , , +/// , , and +/// materialize their result into +/// — a grow-only buffer reset to length 0 once per frame +/// in — rather than a fresh RenderProjectionRecord[] +/// allocation per distinct cell/anchor per frame (the single largest +/// contributor to the FW3.4 perf checkpoint's 14× frame-allocation +/// regression, 1.9 MB/frame p50, before the arena existed). +/// is the matching grow-on-demand scratch +/// buffer collects one cell's filtered records +/// into before a single call — the same +/// steady-state-zero-allocation shape already +/// has. Every segment is /// STRICTLY per-frame scratch — nothing holds one across a frame boundary -/// (the driver/populator consume it immediately, matching -/// 's existing lifetime contract) — so reusing the -/// same backing array's memory next frame is safe. +/// (the driver/populator consume it immediately) — so reusing the same +/// backing array's memory next frame is safe. Per-cell RESULTS +/// ( etc.) are cleared and re-materialized once per +/// frame on first ask, same as before chunk 5. /// internal sealed class WalkProductionWorldData : IWalkFrameWorldData { @@ -80,24 +80,32 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData private readonly ShadowObjectRegistry _shadows; private RenderSceneQuery _scene; private uint _tupleLandblockId; - private int _renderCenterLbX; - private int _renderCenterLbY; private readonly Dictionary _cellCache = new(); private readonly Dictionary _cellDynamicCache = new(); private readonly Dictionary _facilityShadowProbeSignatures = new(); - private readonly Dictionary> _indoorByCell = new(); - private readonly Dictionary> _indoorDynamicsByCell = new(); - private readonly Dictionary> _outdoorByCell = new(); - private readonly Dictionary> _outdoorDynamicsByCell = new(); private readonly Dictionary> _shellsByAnchor = new(); private readonly Dictionary _outdoorMaterialized = new(); private readonly Dictionary _outdoorDynamicsMaterialized = new(); private readonly Dictionary _shellMaterialized = new(); - private readonly Func Cells)> _tryGetRetailCellArray; - private RenderProjectionRecord[] _indoorSweepScratch = new RenderProjectionRecord[256]; + + // Campaign OVERHAUL S2 chunk 5: every distinct entity id counted into + // UnregisteredRenderMembershipCount this frame, so the SAME entity + // touching several cells (or being asked for through both the static and + // dynamic Get* pair) is counted once — not once per cell visit. Cleared + // in BeginFrame alongside the counter itself. + private readonly HashSet _unregisteredEntitiesThisFrame = new(); + + // The one surviving per-frame sweep: building shells only (see this + // type's own doc comment). Statics/dynamics no longer sweep the scene at + // all — membership is a borrowed, on-demand view over the registry. private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024]; - private RenderProjectionRecord[] _dynamicSweepScratch = new RenderProjectionRecord[256]; + + // Campaign OVERHAUL S2 chunk 5: ResolveCellView's grow-on-demand scratch + // buffer — collects one cell's filtered records before a single + // AppendToArena call. Starts small: a cell's real membership is usually + // a handful of parts, not the hundreds an old full-scene sweep held. + private RenderProjectionRecord[] _cellViewScratch = new RenderProjectionRecord[64]; // Campaign FW3.4a: the per-frame, grow-only materialization arena — see // this type's own doc comment. @@ -110,455 +118,135 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData { _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); - _tryGetRetailCellArray = TryGetRetailCellArrayForEntity; - } - - private (bool Found, IReadOnlyList Cells) TryGetRetailCellArrayForEntity(uint entityId) - { - bool found = _shadows.TryGetRetailCellArray(entityId, out IReadOnlyList cells); - return (found, cells); } /// - /// Campaign OVERHAUL S2 chunk 2: how many indoor/outdoor static records - /// this frame fell back to a conservative single-cell membership - /// (indoor: the authored ; - /// outdoor: today's collision-flood - /// answer) because - /// had no retail - /// CELLARRAY registered yet for the entity. Retail has no such gap — - /// CEnvCell::init_static_objects installs the CELLARRAY before a - /// static is ever drawable — so a nonzero count here reflects two - /// independent incremental state machines (the static-projection journal - /// versus the physics publisher) racing during streaming, not - /// steady-state behavior; see AD-40's residency reasoning in - /// . Reset to zero - /// at the start of every . Campaign OVERHAUL S2 - /// chunk 5 decides this adaptation's fate against the connected-route - /// count. + /// Campaign OVERHAUL S2 chunk 5: how many DISTINCT entities this frame + /// were present in the registry's retail CELLARRAY (so + /// named + /// them) but had no resolvable yet + /// through — the + /// transient streaming window where the physics publisher's registration + /// outran the presentation journal's applied projection for the same + /// Create/appearance edge. Retail has no such gap + /// (CEnvCell::init_static_objects installs the CELLARRAY before a + /// static is ever drawable) so a nonzero count here reflects two + /// independent incremental state machines racing during streaming, not + /// steady-state behavior; filed as AD-116, citing the same residency-race + /// reasoning AD-49 already accepts for + /// 's own outdoor + /// seed. Such an entity + /// contributes to NO cell this frame — there is no second, conservative + /// fallback (chunk 5 removed the authored-parent-cell and + /// root-position-cell fallbacks chunk 2/4 carried). Reset to zero at the + /// start of every , after that PRIOR frame's + /// value has already been reported by the diagnostic line below. /// - public int UnregisteredStaticRenderFallbackCount { get; private set; } + public int UnregisteredRenderMembershipCount { get; private set; } - /// Rebuilds the frame's outdoor/shell buckets and clears the - /// per-cell cache. Call once per frame before the driver runs. + /// Rebuilds the frame's building-shell bucket and clears the + /// per-cell caches. Call once per frame before the driver runs. /// / - /// are the streaming recenter origin: record positions are - /// RENDER-ORIGIN-RELATIVE (each landblock's entities carry - /// (lbX − CenterX)·192 offsets), so mapping a position back to - /// its TRUE landblock byte needs the center added back — the first - /// connected gate of the FW3.2b-2 cutover shipped without this and most - /// outdoor scenery landed in garbage buckets no walk turn ever reads. + /// were the streaming recenter origin the deleted position-based + /// fallbacks used to convert a render-origin-relative position back to + /// its true landblock byte (LandscapeCellId). Campaign OVERHAUL + /// S2 chunk 5 deleted every position-based fallback — the registry's + /// CELLARRAY is already expressed in exact cell ids — so these two + /// parameters are accepted (unchanged call-site signature) but no + /// longer stored or read. internal void BeginFrame( RenderSceneQuery scene, uint tupleLandblockId, int renderCenterLbX, int renderCenterLbY) { + // Campaign OVERHAUL S2 chunk 5: report the PRIOR frame's count + // before resetting it — the count is only final once that frame's + // walk (a sequence of on-demand Get* calls this class has no other + // "frame is done" hook for) has finished, so this is necessarily a + // one-frame-delayed report, matching every other per-second/per- + // change probe in this file family (print-only; never influences + // admission). + if (UnregisteredRenderMembershipCount > 0 + && AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled) + { + Console.WriteLine( + $"[walk-membership] unregistered={UnregisteredRenderMembershipCount} " + + $"tupleLandblock=0x{_tupleLandblockId:X8}"); + } + _scene = scene; _tupleLandblockId = tupleLandblockId; - _renderCenterLbX = renderCenterLbX; - _renderCenterLbY = renderCenterLbY; _cellCache.Clear(); _cellDynamicCache.Clear(); _outdoorMaterialized.Clear(); _outdoorDynamicsMaterialized.Clear(); _shellMaterialized.Clear(); _arenaLength = 0; - UnregisteredStaticRenderFallbackCount = 0; - foreach (List bucket in _indoorByCell.Values) - bucket.Clear(); - foreach (List bucket in _indoorDynamicsByCell.Values) - bucket.Clear(); - foreach (List bucket in _outdoorByCell.Values) - bucket.Clear(); - foreach (List bucket in _outdoorDynamicsByCell.Values) - bucket.Clear(); + UnregisteredRenderMembershipCount = 0; + _unregisteredEntitiesThisFrame.Clear(); foreach (List bucket in _shellsByAnchor.Values) bucket.Clear(); - // Retail CEnvCell::init_static_objects does not leave an object solely - // in its authored parent cell. add_obj_to_cell -> - // calc_cross_cells_static -> CPartArray::AddPartsShadow registers all - // visual parts in every crossed cell, including parts with no physics - // BSP. Campaign OVERHAUL S2 chunk 2: that membership is borrowed - // whole from ShadowObjectRegistry's retained retail CELLARRAY — never - // recomputed here. - int required = _scene.IndexCounts.For(RenderSceneIndex.IndoorCellStatic); - if (required > _indoorSweepScratch.Length) - { - _indoorSweepScratch = new RenderProjectionRecord[ - Math.Max(required, _indoorSweepScratch.Length * 2)]; - } - int count = _scene.CopyIndexTo( - RenderSceneIndex.IndoorCellStatic, - _indoorSweepScratch); - for (int i = 0; i < count; i++) - { - ref readonly RenderProjectionRecord record = ref _indoorSweepScratch[i]; - IReadOnlyList renderCells = ResolveIndoorStaticRenderCells( - in record, _tryGetRetailCellArray, out bool usedFallback); - if (usedFallback) - UnregisteredStaticRenderFallbackCount++; - BucketIndoorRecord( - in record, - renderCells, - _indoorByCell, - _outdoorByCell); - } - - // CopyIndexTo THROWS on an undersized destination (ArchRenderScene - // validates up front — the first connected gate run of the FW3.2b-2 - // cutover crashed on exactly this at Aerlinthe's 5,040 outdoor - // statics), so presize from the query's own index counts. - required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorStatic); + // Building shells are the landcell BUILDING channel (out of S2): + // retail draws a shell at its own DrawBuilding turn, never through + // the cell's ordinary shadow_part_list walk (Contract C), so this + // is the one sweep that survives chunk 5 — it exists ONLY to bucket + // IsBuildingShell records by their authored anchor, never to + // resolve ordinary static/dynamic cell membership. + int required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorStatic); if (required > _sweepScratch.Length) { _sweepScratch = new RenderProjectionRecord[ Math.Max(required, _sweepScratch.Length * 2)]; } - count = _scene.CopyIndexTo(RenderSceneIndex.OutdoorStatic, _sweepScratch); + int count = _scene.CopyIndexTo(RenderSceneIndex.OutdoorStatic, _sweepScratch); for (int i = 0; i < count; i++) { ref readonly RenderProjectionRecord record = ref _sweepScratch[i]; - if (record.EntityPayload.IsBuildingShell) - { - uint anchor = BuildingShellBucketCellId(in record); - if (!_shellsByAnchor.TryGetValue(anchor, out List? shells)) - _shellsByAnchor[anchor] = shells = new List(); - shells.Add(record); + if (!record.EntityPayload.IsBuildingShell) continue; - } - IReadOnlyList renderCells = ResolveOutdoorStaticRenderCells( - in record, _tryGetRetailCellArray, _shadows.GetOwnerCells, out bool usedFallback); - if (usedFallback) - UnregisteredStaticRenderFallbackCount++; - BucketOutdoorRecord( - in record, - renderCells, - _outdoorByCell, - _renderCenterLbX, - _renderCenterLbY); - } - - // Retail CPhysicsObj::add_shadows_to_cells installs every PartArray in - // every cell from calc_cross_cells' retained CELLARRAY. The scene's - // parent-cell dictionary cannot represent that membership, so consume - // the global dynamic index once and rebuild both indoor and outdoor - // render buckets from ShadowObjectRegistry's exact retained array. - required = _scene.IndexCounts.For(RenderSceneIndex.Dynamic); - if (required > _dynamicSweepScratch.Length) - { - _dynamicSweepScratch = new RenderProjectionRecord[ - Math.Max(required, _dynamicSweepScratch.Length * 2)]; - } - count = _scene.CopyIndexTo( - RenderSceneIndex.Dynamic, - _dynamicSweepScratch); - for (int i = 0; i < count; i++) - { - ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i]; - IReadOnlyList renderCells = ResolveDynamicRenderCells( - in record, - _tryGetRetailCellArray, - _shadows.GetOwnerCells, - out bool usedFallback); - if (usedFallback) - UnregisteredStaticRenderFallbackCount++; - BucketDynamicRecord( - in record, - renderCells, - _indoorDynamicsByCell, - _outdoorDynamicsByCell, - _renderCenterLbX, - _renderCenterLbY); + uint anchor = BuildingShellBucketCellId(in record); + if (!_shellsByAnchor.TryGetValue(anchor, out List? shells)) + _shellsByAnchor[anchor] = shells = new List(); + shells.Add(record); } } - /// - /// 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, - out bool usedFallback) + /// Facility Hub / cathedral discriminator: does the given cell's + /// borrowed retail render membership contain a record with this exact + /// authored SourceId? Reimplemented directly over the registry's + /// per-cell entries (chunk 5) rather than reading a swept bucket + /// dictionary — 's + /// ProbeFacilityStairsEnabled block is the only caller. + internal bool StaticBucketContains(uint cellId, uint sourceId) { - ArgumentNullException.ThrowIfNull(tryGetRetailCellArray); - ArgumentNullException.ThrowIfNull(getOwnerCells); - - (bool found, IReadOnlyList cells) = - tryGetRetailCellArray(record.Source.LocalEntityId); - if (found) + IReadOnlyList entries = + _shadows.GetRetailPartEntriesInCell(cellId); + uint previousEntityId = 0; + bool havePrevious = false; + for (int i = 0; i < entries.Count; i++) { - usedFallback = false; - return cells; - } - - usedFallback = true; - return getOwnerCells(record.Source.LocalEntityId); - } - - /// - /// Campaign OVERHAUL S2 chunk 2: one authored indoor static's render-cell - /// membership, borrowed from - /// — retail's calc_cross_cells_static (0x00515160) → - /// CPartArray::AddPartsShadow (0x00517e40) CELLARRAY. When the - /// registry has no retail array yet for this entity (the streaming window - /// where the static-projection journal published the record before the - /// physics publisher registered the entity — two independent incremental - /// state machines; AD-40's residency reasoning in - /// ), the LAST-RESORT - /// fallback is the authored parent cell alone, and - /// reports it so the caller can count it. - /// - internal static IReadOnlyList ResolveIndoorStaticRenderCells( - in RenderProjectionRecord record, - Func Cells)> tryGetRetailCellArray, - out bool usedFallback) - { - ArgumentNullException.ThrowIfNull(tryGetRetailCellArray); - - (bool found, IReadOnlyList cells) = - tryGetRetailCellArray(record.Source.LocalEntityId); - if (found) - { - usedFallback = false; - return cells; - } - - usedFallback = true; - return record.Source.ParentCellId != 0u - ? new[] { record.Source.ParentCellId } - : Array.Empty(); - } - - /// - /// Campaign OVERHAUL S2 chunk 2: one authored outdoor static's - /// render-cell membership, borrowed from - /// the same way - /// as . The fallback for an - /// entity the registry has not registered yet is today's collision-flood - /// answer (itself - /// possibly empty — already carries its - /// own root-position-cell fallback for that case), counted the same way - /// as the indoor path via . - /// - internal static IReadOnlyList ResolveOutdoorStaticRenderCells( - in RenderProjectionRecord record, - Func Cells)> tryGetRetailCellArray, - Func> getOwnerCells, - out bool usedFallback) - { - ArgumentNullException.ThrowIfNull(tryGetRetailCellArray); - ArgumentNullException.ThrowIfNull(getOwnerCells); - - (bool found, IReadOnlyList cells) = - tryGetRetailCellArray(record.Source.LocalEntityId); - if (found) - { - usedFallback = false; - return cells; - } - - usedFallback = true; - return getOwnerCells(record.Source.LocalEntityId); - } - - internal bool StaticBucketContains(uint cellId, uint sourceId) => - _indoorByCell.TryGetValue( - cellId, - out List? records) - && records.Exists(record => record.Source.SourceId == sourceId); - - /// - /// Buckets one authored indoor static into the render cell lists produced - /// by retail's cross-cell PartArray walk. Outdoor cells are routed to the - /// landscape turn because a visual part may cross an exit portal. - /// - internal static void BucketIndoorRecord( - in RenderProjectionRecord record, - IReadOnlyList renderCells, - Dictionary> indoorBuckets, - Dictionary> outdoorBuckets) - { - ArgumentNullException.ThrowIfNull(renderCells); - ArgumentNullException.ThrowIfNull(indoorBuckets); - ArgumentNullException.ThrowIfNull(outdoorBuckets); - - bool added = false; - for (int i = 0; i < renderCells.Count; i++) - { - uint cellId = renderCells[i]; - uint low = cellId & 0xFFFFu; - if (low is >= 1u and <= 64u) - { - AddToBucket(in record, cellId, outdoorBuckets); - added = true; - } - else if (low >= 0x100u) - { - AddToBucket(in record, cellId, indoorBuckets); - added = true; - } - } - - if (!added && record.Source.ParentCellId != 0u) - AddToBucket(in record, record.Source.ParentCellId, indoorBuckets); - } - - /// - /// Installs one live PartArray into every cell in retail's retained - /// CELLARRAY. Interior and landscape memberships can coexist while - /// crossing a building exit. If the collision owner is not registered yet - /// (or the object is a visual-only effect), the authored interior parent - /// or outdoor root-position cell remains the conservative fallback. - /// - internal static void BucketDynamicRecord( - in RenderProjectionRecord record, - IReadOnlyList shadowCells, - Dictionary> indoorBuckets, - Dictionary> outdoorBuckets, - int renderCenterLbX, - int renderCenterLbY) - { - ArgumentNullException.ThrowIfNull(shadowCells); - ArgumentNullException.ThrowIfNull(indoorBuckets); - ArgumentNullException.ThrowIfNull(outdoorBuckets); - - bool added = false; - for (int i = 0; i < shadowCells.Count; i++) - { - uint cellId = shadowCells[i]; - uint low = cellId & 0xFFFFu; - if (low is >= 1u and <= 64u) - { - AddToBucket(in record, cellId, outdoorBuckets); - added = true; - } - else if (low >= 0x100u) - { - AddToBucket(in record, cellId, indoorBuckets); - added = true; - } - } - - if (added) - return; - - uint parentLow = record.Source.ParentCellId & 0xFFFFu; - if (record.Source.ParentCellId != 0u && parentLow >= 0x100u) - { - AddToBucket(in record, record.Source.ParentCellId, indoorBuckets); - return; - } - - uint outdoorCell = LandscapeCellId( - record.Transform.Position, - renderCenterLbX, - renderCenterLbY); - AddToBucket(in record, outdoorCell, outdoorBuckets); - } - - /// - /// Installs one outdoor object's render shadow in every outdoor cell of - /// its authoritative physics CELLARRAY. This is retail's - /// CPhysicsObj::add_shadows_to_cells → - /// CPartArray::AddPartsShadow path: a large object straddling a - /// landblock edge must remain reachable when its origin cell leaves the - /// landscape walk. Objects without a collision registration (notably - /// short-lived visual effects) retain the root-position fallback. - /// - internal static void BucketOutdoorRecord( - in RenderProjectionRecord record, - IReadOnlyList shadowCells, - Dictionary> buckets, - int renderCenterLbX, - int renderCenterLbY) - { - ArgumentNullException.ThrowIfNull(shadowCells); - ArgumentNullException.ThrowIfNull(buckets); - - bool added = false; - for (int i = 0; i < shadowCells.Count; i++) - { - uint cellId = shadowCells[i]; - uint cellIndex = cellId & 0xFFFFu; - if (cellIndex is < 1u or > 64u) + uint entityId = entries[i].EntityId; + if (havePrevious && entityId == previousEntityId) continue; + previousEntityId = entityId; + havePrevious = true; - AddToBucket(in record, cellId, buckets); - added = true; + if (_scene.TryGetByLocalEntityId(entityId, out RenderProjectionRecord record) + && record.Source.SourceId == sourceId) + { + return true; + } } - - if (!added) - { - uint cellId = LandscapeCellId( - record.Transform.Position, - renderCenterLbX, - renderCenterLbY); - AddToBucket(in record, cellId, buckets); - } - } - - private static void AddToBucket( - in RenderProjectionRecord record, - uint cellId, - Dictionary> buckets) - { - if (!buckets.TryGetValue( - cellId, - out List? bucket)) - { - buckets[cellId] = bucket = new List(); - } - bucket.Add(record); - } - - /// The landscape cell owning a RENDER-ORIGIN-RELATIVE position - /// — retail's 24 m cell grid inside the 192 m landblock, producing the - /// same TRUE (lb & 0xFFFF0000) | (cellX*8 + cellY + 1) encoding - /// the walk's landscape turn emits: the relative block index - /// (floor(p/192)) plus the streaming center recovers the true - /// landblock byte, because entity positions carry - /// (lbX − CenterX)·192 world offsets - /// (LandblockBuildFactory's worldOffset). - internal static uint LandscapeCellId( - Vector3 relativePosition, int renderCenterLbX, int renderCenterLbY) - { - int relBlockX = (int)MathF.Floor(relativePosition.X / 192f); - int relBlockY = (int)MathF.Floor(relativePosition.Y / 192f); - float localX = relativePosition.X - relBlockX * 192f; - float localY = relativePosition.Y - relBlockY * 192f; - int cellX = Math.Clamp((int)(localX / 24f), 0, 7); - int cellY = Math.Clamp((int)(localY / 24f), 0, 7); - uint landblock = - ((uint)(byte)(renderCenterLbX + relBlockX) << 24) - | ((uint)(byte)(renderCenterLbY + relBlockY) << 16); - return landblock | (uint)(cellX * 8 + cellY + 1); + return false; } public WalkFrameStaticRecords GetCellStatics(uint cellId) { if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) return cached; - WalkFrameStaticRecords records = - _indoorByCell.TryGetValue(cellId, out List? bucket) - && bucket.Count > 0 - ? new WalkFrameStaticRecords( - AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId) - : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; + WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: false); EmitFacilityShadowProbe(records.Records, cellId, "static"); _cellCache[cellId] = records; return records; @@ -568,19 +256,112 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData { if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) return cached; - WalkFrameStaticRecords records = - _indoorDynamicsByCell.TryGetValue( - cellId, - out List? bucket) - && bucket.Count > 0 - ? new WalkFrameStaticRecords( - AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId) - : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; + WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: true); EmitFacilityShadowProbe(records.Records, cellId, "dynamic"); _cellDynamicCache[cellId] = records; return records; } + public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) + { + if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached)) + return cached; + WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: false); + _outdoorMaterialized[cellId] = records; + return records; + } + + public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId) + { + if (_outdoorDynamicsMaterialized.TryGetValue( + cellId, + out WalkFrameStaticRecords cached)) + { + return cached; + } + + WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: true); + _outdoorDynamicsMaterialized[cellId] = records; + return records; + } + + /// + /// Campaign OVERHAUL S2 chunk 5: one cell id's borrowed retail render + /// membership. Reads + /// — already in retail CELLARRAY-then-part-array insertion order — + /// collapses adjacent entries down to their distinct owning entity ids + /// (an entity's own entries for one cell are always written as one + /// contiguous run: PublishRetailPartEntries removes then re-adds a + /// whole entity's rows atomically, never interleaving two entities' + /// rows), resolves each id to its current projected record through + /// , and keeps only + /// the records matching 's static/dynamic + /// class and excluding building shells. Indoor vs. outdoor is entirely a + /// property of WHICH cellId the caller passes (an indoor cell id's low + /// word is >= 0x100, an outdoor one is in [1, 64] — + /// retail's own two id spaces): the registry already published this + /// entity into every crossed cell under its own true id, so no + /// additional indoor/outdoor dispatch is needed here. + /// + private WalkFrameStaticRecords ResolveCellView(uint cellId, bool dynamic) + { + IReadOnlyList entries = + _shadows.GetRetailPartEntriesInCell(cellId); + if (entries.Count == 0) + return WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; + + int written = 0; + uint previousEntityId = 0; + bool havePrevious = false; + for (int i = 0; i < entries.Count; i++) + { + uint entityId = entries[i].EntityId; + if (havePrevious && entityId == previousEntityId) + continue; + previousEntityId = entityId; + havePrevious = true; + + if (!_scene.TryGetByLocalEntityId(entityId, out RenderProjectionRecord record)) + { + // The transient streaming window (this type's own doc + // comment): the registry already flooded this entity into + // its retail CELLARRAY, but the presentation journal has + // not applied its projected record yet this frame. Retail + // draws nothing for an object not yet in a cell — no second + // fallback. + if (_unregisteredEntitiesThisFrame.Add(entityId)) + UnregisteredRenderMembershipCount++; + continue; + } + if (record.EntityPayload.IsBuildingShell) + continue; // buildings draw at their own shell turn. + if (IsDynamicProjectionClass(record.ProjectionClass) != dynamic) + continue; + + if (written == _cellViewScratch.Length) + { + var grown = new RenderProjectionRecord[_cellViewScratch.Length * 2]; + Array.Copy(_cellViewScratch, grown, written); + _cellViewScratch = grown; + } + _cellViewScratch[written++] = record; + } + + return written == 0 + ? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId } + : new WalkFrameStaticRecords( + AppendToArena(_cellViewScratch.AsSpan(0, written)), _tupleLandblockId); + } + + /// The same static/dynamic split ArchRenderScene's own + /// internal indexing uses: a live dynamic root or an equipped child is + /// "dynamic"; every other (including + /// ) is + /// "static". + private static bool IsDynamicProjectionClass(RenderProjectionClass projectionClass) => + projectionClass is RenderProjectionClass.LiveDynamicRoot + or RenderProjectionClass.EquippedChild; + /// /// Facility Hub discriminator for retail's cross-cell render-shadow path. /// The scene query is currently keyed by authored parent cell, while retail @@ -643,41 +424,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData } } - public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) - { - if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached)) - return cached; - WalkFrameStaticRecords records = - _outdoorByCell.TryGetValue(cellId, out List? bucket) - && bucket.Count > 0 - ? new WalkFrameStaticRecords( - AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId) - : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; - _outdoorMaterialized[cellId] = records; - return records; - } - - public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId) - { - if (_outdoorDynamicsMaterialized.TryGetValue( - cellId, - out WalkFrameStaticRecords cached)) - { - return cached; - } - - WalkFrameStaticRecords records = - _outdoorDynamicsByCell.TryGetValue( - cellId, - out List? bucket) - && bucket.Count > 0 - ? new WalkFrameStaticRecords( - AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId) - : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; - _outdoorDynamicsMaterialized[cellId] = records; - return records; - } - public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) { uint anchor = BuildingShellBucketCellId(building); @@ -698,7 +444,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData /// Copies into at /// its current length, growing the arena first if needed (doubling, or /// exactly enough for an unusually large sweep — the same growth shape - /// / already use), + /// / already use), /// and returns the segment the copy landed in. A prior frame's growth can /// leave an earlier-returned segment pointing at a retired backing array /// — harmless, since that array's content stays valid and nothing reads diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index 33fd6988..65312b60 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -1084,6 +1084,34 @@ public sealed class LandblockPhysicsPublisher } } + // Campaign OVERHAUL S2 chunk 5 closeout: a DAT static with visual + // parts but NO collision geometry (decorative trim, wall panels, + // banners) is still a CPhysicsObj retail floods into its cells — + // calc_cross_cells_static (0x00515160) takes the find_bbox_cell_list + // route over the part array when there is no cylsphere, and + // add_shadows_to_cells (0x00514ae0) publishes AddPartsShadow rows + // for every part regardless of collision. Since chunk 5 the walk + // draws ONLY what the registry flooded (the parent-cell fallback is + // deleted), so such a static must register render-only here or it + // vanishes (G2 self-gate: the Facility Hub's purple-lit wall panels). + if (entityBspCount == 0 && entityCylinderCount == 0 + && partArray.Count > 0) + { + publication.StagingEngine.ShadowObjects.RegisterMultiPart( + entity.Id, + entity.Position, + entity.Rotation, + Array.Empty(), + 0u, + EntityCollisionFlags.None, + publication.Origin.X, + publication.Origin.Y, + landblock.LandblockId, + seedCellId: entity.ParentCellId ?? 0u, + isStatic: true, + partArray: partArray); + } + if (entityBspCount > 0) publication.BspOwnerCount++; if (entityCylinderCount > 0) diff --git a/src/AcDream.Content/LandblockPhysicsContentBuilder.cs b/src/AcDream.Content/LandblockPhysicsContentBuilder.cs index 7ab2b5e1..141f1a57 100644 --- a/src/AcDream.Content/LandblockPhysicsContentBuilder.cs +++ b/src/AcDream.Content/LandblockPhysicsContentBuilder.cs @@ -650,6 +650,8 @@ public static class LandblockPhysicsContentBuilder entity.SourceGfxObjOrSetupId); if (setup is null) { + RegisterRenderOnlyStatic( + engine, entity, partArray, landblock, origin); noCollision++; continue; } @@ -710,6 +712,8 @@ public static class LandblockPhysicsContentBuilder // LiveEntityCollisionBuilder's remarks for the retail anchor. if (setupShapes.Count == 0) { + RegisterRenderOnlyStatic( + engine, entity, partArray, landblock, origin); noCollision++; continue; } @@ -744,6 +748,41 @@ public static class LandblockPhysicsContentBuilder noCollision); } + /// + /// Campaign OVERHAUL S2 chunk 5 closeout: a DAT static with visual parts + /// but no collision geometry is still a CPhysicsObj retail floods + /// into its cells — calc_cross_cells_static (0x00515160) takes the + /// find_bbox_cell_list route over the part array when there is no + /// cylsphere, and add_shadows_to_cells (0x00514ae0) publishes + /// AddPartsShadow rows for every part regardless of collision. + /// The walk draws only what the registry flooded, so the static registers + /// render-only (empty collision shapes, whole part array); a static with + /// no visual part either stays unregistered. + /// + private static void RegisterRenderOnlyStatic( + PhysicsEngine engine, + WorldEntity entity, + IReadOnlyList partArray, + LoadedLandblock landblock, + Vector3 origin) + { + if (partArray.Count == 0) + return; + engine.ShadowObjects.RegisterMultiPart( + entity.Id, + entity.Position, + entity.Rotation, + Array.Empty(), + 0u, + EntityCollisionFlags.None, + worldOffsetX: origin.X, + worldOffsetY: origin.Y, + landblockId: landblock.LandblockId, + seedCellId: entity.ParentCellId ?? 0u, + isStatic: true, + partArray: partArray); + } + private static T Require( PreparedCollisionReadResult result, string kind, diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 9557f137..0ab7bac2 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -506,6 +506,20 @@ public sealed class ShadowObjectRegistry RetailCellArrayRoute route, IReadOnlyList partArray) { + // Campaign OVERHAUL S2 review fix (arch F3): retail never floods an + // attached child — add_shadows_to_cells (0x00514ae0) passes the + // ROOT's CELLARRAY down through `children`. A re-registration or move + // of an entity that is currently attached (an equipped item's + // appearance update through ReplaceMultiPartPayload/RegisterMultiPart) + // only refreshes the part array it inherits the root's cells with. + if (_childParent.ContainsKey(entityId)) + { + if (partArray.Count != 0) + _childPartArrays[entityId] = partArray; + _retailCellArrayRoutes[entityId] = route; + PublishChildEntries(entityId); + return; + } if (_retailCellArrays.TryGetValue(entityId, out List? previousCells)) { RemoveRetailPartEntriesFromCells(entityId, previousCells); @@ -604,13 +618,71 @@ public sealed class ShadowObjectRegistry cellIds[i], out List? entries)) { - entries.RemoveAll(e => e.EntityId == entityId); + RemoveOwnerPartRows(entries, entityId); if (entries.Count == 0) _retailPartEntriesByCell.Remove(cellIds[i]); } } } + /// The twin for the retail part + /// rows: a reverse index loop, no closure — this runs per cell on every + /// accepted move (arch review F7). + private static void RemoveOwnerPartRows( + List entries, + uint entityId) + { + for (int index = entries.Count - 1; index >= 0; index--) + { + if (entries[index].EntityId == entityId) + entries.RemoveAt(index); + } + } + + private static ShadowEntry[] CollectOwnerRows( + List entries, + uint entityId) + { + int count = 0; + for (int index = 0; index < entries.Count; index++) + { + if (entries[index].EntityId == entityId) + count++; + } + if (count == 0) + return Array.Empty(); + var rows = new ShadowEntry[count]; + int written = 0; + for (int index = 0; index < entries.Count; index++) + { + if (entries[index].EntityId == entityId) + rows[written++] = entries[index]; + } + return rows; + } + + private static RetailPartEntry[] CollectOwnerPartRows( + List entries, + uint entityId) + { + int count = 0; + for (int index = 0; index < entries.Count; index++) + { + if (entries[index].EntityId == entityId) + count++; + } + if (count == 0) + return Array.Empty(); + var rows = new RetailPartEntry[count]; + int written = 0; + for (int index = 0; index < entries.Count; index++) + { + if (entries[index].EntityId == entityId) + rows[written++] = entries[index]; + } + return rows; + } + /// /// Publishes retail's CPartArray::AddPartsShadow (0x00517e40) rows /// for one entity: for every cell in , in @@ -742,6 +814,12 @@ public sealed class ShadowObjectRegistry _childPartArrays[childEntityId] = childPartArray; PublishChildEntries(childEntityId); + // Campaign OVERHAUL S2 review fix (arch F2): an attach mutates shared + // per-cell rows, so it must invalidate any prepared SetPosition commit + // whose captured cell lists predate it (IsPreparedSetPositionCurrent + // keys on the mutation revision) — otherwise that commit's wholesale + // list install silently drops the child's rows. + AdvanceMutationRevision(); return true; } @@ -786,6 +864,7 @@ public sealed class ShadowObjectRegistry if (siblings.Count == 0) _parentChildren.Remove(parentId); } + AdvanceMutationRevision(); // see AttachChild (arch F2) return true; } @@ -1041,7 +1120,32 @@ public sealed class ShadowObjectRegistry bool publishMutation = true, IReadOnlyList? partArray = null) { - if (shapes.Count == 0) { Deregister(entityId); return; } + if (shapes.Count == 0) + { + // Campaign OVERHAUL S2 chunk 5 (item B): a live entity whose + // collision dispatch produced NO shapes (LiveEntityCollisionBuilder. + // Build's Shapes==[]) but whose Setup still has visual parts — the + // retail short-lived spell/visual effect-object case — is NOT a + // deregistration. Contract B's render membership + // (CPartArray::AddPartsShadow) does not require a successful + // collision dispatch; only the collision-side shadow_object_list + // does. Route it to the render-only registration path instead. + // A caller with no part array either (every pre-chunk-5 test + // fixture, and any genuinely shapeless entity) keeps the exact + // prior behavior: deregister. + if (partArray is { Count: > 0 }) + { + RegisterRenderOnly( + entityId, entityWorldPos, entityWorldRot, state, flags, + worldOffsetX, worldOffsetY, landblockId, seedCellId, + isStatic, publishMutation, partArray); + } + else + { + Deregister(entityId); + } + return; + } // Flood FIRST — keep-when-empty, see Register. uint seed = seedCellId != 0u @@ -1157,6 +1261,79 @@ public sealed class ShadowObjectRegistry } } + /// + /// Campaign OVERHAUL S2 chunk 5 (item B): registers a live entity that + /// has NO collision shapes at all but DOES have a visual part array — the + /// retail short-lived spell/visual effect-object case + /// (LiveEntityCollisionBuilder.Build returns + /// Shapes = [], RenderParts != []). Retail's + /// calc_cross_cells_static dispatch (Contract A) never + /// special-cases "no collision shapes": the cylsphere-vs-bbox TEST reads + /// the collision shapes (none here, so the test is always false) and the + /// flood unconditionally falls through to the bbox route over the WHOLE + /// part array ( with an empty + /// collisionShapes list). The entity therefore gets an exact + /// retail CELLARRAY and rows exactly like a + /// colliding entity, but never a collision row + /// anywhere — Contract B's shadow_object_list receives one entry + /// per CELLARRAY cell only when the object HAS a part to shadow there; + /// this object contributes to shadow_part_list only. + /// + /// is retained as an EMPTY (not absent) list + /// so every existing multi-part-dispatch site + /// (, , + /// ) takes the "multi-part, zero + /// shapes" branch on a later move/reflood instead of falling back to the + /// single-shape path or synthesizing a bogus + /// zero-radius shadow entry. + /// + /// + private void RegisterRenderOnly( + uint entityId, + Vector3 entityWorldPos, + Quaternion entityWorldRot, + uint state, + EntityCollisionFlags flags, + float worldOffsetX, + float worldOffsetY, + uint landblockId, + uint seedCellId, + bool isStatic, + bool publishMutation, + IReadOnlyList partArray) + { + // Flood FIRST — keep-when-empty, see Register. + uint seed = seedCellId != 0u + ? seedCellId + : DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId); + if (seed == 0u) return; + + (IReadOnlyList cellSet, RetailCellArrayRoute retailRoute) = + ComputeContractACellArray( + seed, + entityWorldPos, + entityWorldRot, + state, + collisionShapes: Array.Empty(), + partArray, + isStatic); + if (cellSet.Count == 0) return; // keep-when-empty (pc:283540). + + DeregisterCore(entityId, publishMutation: false); + _entityShapes[entityId] = Array.Empty(); + _entityReg[entityId] = new RegistrationRecord( + seed, entityWorldPos, entityWorldRot, state, flags, isStatic, + IsMultiPart: true, GfxObjId: 0u, Radius: 0f, + CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f); + if (publishMutation) + BumpOwnerVersion(entityId); + else + RefreshOwnerPrefixIndex(entityId); + + _entityRetailPartArrays[entityId] = partArray; + PublishRetailCellArray(entityId, cellSet, retailRoute, partArray); + } + /// /// Replaces an existing live PartArray collision payload in its current /// shadow-cell membership. Retail CPartArray::SetPart changes the @@ -1183,7 +1360,11 @@ public sealed class ShadowObjectRegistry if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior) || !prior.IsMultiPart) { - if (shapes.Count == 0) + // Campaign OVERHAUL S2 chunk 5 (item B): a not-yet-registered + // entity with no collision shapes but a real part array still + // needs registering (render-only) — RegisterMultiPart's own + // shapes.Count==0 branch now handles that dispatch. + if (shapes.Count == 0 && (partArray is null || partArray.Count == 0)) return; RegisterMultiPart( entityId, @@ -1217,7 +1398,10 @@ public sealed class ShadowObjectRegistry // this replaces the retained part array WITHOUT re-flooding. It // reuses the CURRENT retail CELLARRAY (if any) and only rewrites // which parts occupy it, mirroring the collision payload swap below. - if (partArray is not null) + // Arch review F9: a non-null but EMPTY part array is "no part to + // shadow" — treated exactly like the null case rather than leaving a + // cell array with no drawable rows behind. + if (partArray is { Count: > 0 }) { _entityRetailPartArrays[entityId] = partArray; if (_retailCellArrays.TryGetValue(entityId, out List? retailCells) @@ -2270,6 +2454,37 @@ public sealed class ShadowObjectRegistry return; } + // Campaign OVERHAUL S2 review fix (retail F3): a RENDER-ONLY owner + // (part array, no collision shapes, hence never any retained + // collision cells) still moves in retail — add_shadows_to_cells + // (0x00514ae0) adds the CShadowObj unconditionally (pc:282856) and + // gates only AddPartsShadow on part_array != 0, so a shapeless + // visual object keeps a cell array it travels with. Its transition + // carries no sphere, so that array is its destination cell alone + // (num_cells == 1 → AddPartsShadow without clip planes). AD-117 + // records the single-cell reading until a cdb trace pins the + // zero-sphere transition's exact cell list. + if (!_entityToCells.ContainsKey(entityId) + && !_suspendedEntityCells.ContainsKey(entityId) + && seedCellId != 0u + && _entityRetailPartArrays.TryGetValue( + entityId, + out IReadOnlyList? renderPartArray) + && renderPartArray.Count != 0) + { + _suspendedEntities.Remove(entityId); + _entityReg[entityId] = registration with + { + SeedCellId = seedCellId, + EntityWorldPos = worldPosition, + EntityWorldRot = worldRotation, + }; + _singleCellScratch[0] = seedCellId; + PublishRetailProductFromExactCells(entityId, _singleCellScratch); + BumpOwnerVersion(entityId); + 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 @@ -2285,6 +2500,8 @@ public sealed class ShadowObjectRegistry BumpOwnerVersion(entityId); } + private readonly uint[] _singleCellScratch = new uint[1]; + private void ReplacePositionRows( uint entityId, RegistrationRecord registration, @@ -2326,12 +2543,12 @@ public sealed class ShadowObjectRegistry exactCells.Add(cellId); } - if (registration.IsMultiPart - && _entityShapes.TryGetValue( - entityId, - out IReadOnlyList? shapes)) + IReadOnlyList? shapes = null; + bool isMultiPartDispatch = registration.IsMultiPart + && _entityShapes.TryGetValue(entityId, out shapes); + if (isMultiPartDispatch) { - foreach (ShadowShape shape in shapes) + foreach (ShadowShape shape in shapes!) { Vector3 partWorldPosition = worldPosition + Vector3.Transform(shape.LocalPosition, worldRotation); @@ -2371,7 +2588,15 @@ public sealed class ShadowObjectRegistry AddEntryToCell(entry, exactCells[index]); } - if (exactCells.Count == 0) + // Campaign OVERHAUL S2 chunk 5 (item B): a render-only multi-part + // entity (registration.IsMultiPart with an EMPTY retained shapes + // list — see RegisterRenderOnly) writes NO ShadowEntry rows above. + // _entityToCells must stay absent for it too, or a consumer reading + // it (GetOwnerCells, DeregisterCore's cleanup walk) would see cell + // ids that carry no actual collision row — a membership claim this + // registry never backs with a shadow_object_list entry. + bool wroteCollisionEntries = !isMultiPartDispatch || shapes!.Count != 0; + if (exactCells.Count == 0 || !wroteCollisionEntries) _entityToCells.Remove(entityId); else _entityToCells[entityId] = exactCells; @@ -2414,11 +2639,26 @@ public sealed class ShadowObjectRegistry foreach (uint cellId in cellIds) { if (_cells.TryGetValue(cellId, out var list)) - list.RemoveAll(entry => entry.EntityId == entityId); + RemoveOwnerRows(list, entityId); } _entityToCells.Remove(entityId); } + // Campaign OVERHAUL S2 review fix (arch F1 / retail F2): retail's + // remove_shadows_from_cells (0x00511230) is ONE transaction over both + // products — per shadow cell it calls CObjCell::remove_shadow_object + // AND CPartArray::RemoveParts, then recurses through children. A + // suspended object therefore has no render membership either; the + // retained part array and route survive so the un-suspending move + // (ReplacePositionRows → PublishRetailProductFromExactCells) + // republishes from the transition's cells. + if (_retailCellArrays.TryGetValue(entityId, out List? retailCells)) + { + RemoveRetailPartEntriesFromCells(entityId, retailCells); + _retailCellArrays.Remove(entityId); + RepublishAttachedChildren(entityId); + } + _suspendedEntities.Add(entityId); BumpOwnerVersion(entityId); return true; @@ -2502,6 +2742,19 @@ public sealed class ShadowObjectRegistry _withdrawnPrefixesByOwner.TryGetValue( entityId, out var withdrawnBeforeReflood); + + // Campaign OVERHAUL S2 chunk 5 closeout: a reflood is retail's + // recalc_cross_cells over the SAME CPartArray — the render product + // (retail cell array + AddPartsShadow rows) is recomputed from the + // retained part array exactly like the movement path above does. + // Without this the walk (which reads ONLY GetRetailPartEntriesInCell + // since chunk 5) lost every reflooded owner: landblock replacement + // commits (PhysicsEngine.ApplyCommittedOwnerReplacement) and the + // Content builder's post-publication reflood both come through here. + _entityRetailPartArrays.TryGetValue( + entityId, + out IReadOnlyList? retainedPartArray); + if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes)) { @@ -2517,7 +2770,8 @@ public sealed class ShadowObjectRegistry lbPrefix, reg.SeedCellId, reg.IsStatic, - publishMutation: false); + publishMutation: false, + partArray: retainedPartArray); } else { @@ -2537,7 +2791,8 @@ public sealed class ShadowObjectRegistry reg.Flags, reg.SeedCellId, reg.IsStatic, - publishMutation: false); + publishMutation: false, + partArray: retainedPartArray); } // Register is also the authoritative movement/replacement API and @@ -2870,10 +3125,69 @@ public sealed class ShadowObjectRegistry _withdrawnPrefixesByOwner.Remove(eid); } } + // Campaign OVERHAUL S2 review fix (arch F4 / retail F4): the retail + // render product ends with the landblock too — remove_shadows_from_ + // cells (0x00511230) never removes the CShadowObj without the + // AddPartsShadow rows. Every part row in the prefix's cells goes; + // each owner's cell array loses those cells (a dynamic owner keeps + // its part array for the reload reflood, a static owner ends here — + // including a render-only static, which has no collision cells and + // so was invisible to the loops above). + RemoveRetailProductForPrefix(lbPrefix, touchedOwners); foreach (uint entityId in touchedOwners) BumpOwnerVersion(entityId); } + private readonly List _prefixRemovalScratch = new(); + + private void RemoveRetailProductForPrefix( + uint lbPrefix, + HashSet touchedOwners) + { + _prefixRemovalScratch.Clear(); + foreach (uint cellId in _retailPartEntriesByCell.Keys) + { + if ((cellId & 0xFFFF0000u) == lbPrefix) + _prefixRemovalScratch.Add(cellId); + } + for (int i = 0; i < _prefixRemovalScratch.Count; i++) + _retailPartEntriesByCell.Remove(_prefixRemovalScratch[i]); + + _prefixRemovalScratch.Clear(); + foreach (var (ownerId, cells) in _retailCellArrays) + { + for (int i = cells.Count - 1; i >= 0; i--) + { + if ((cells[i] & 0xFFFF0000u) == lbPrefix) + { + cells.RemoveAt(i); + touchedOwners.Add(ownerId); + } + } + if (cells.Count == 0) + _prefixRemovalScratch.Add(ownerId); + } + for (int i = 0; i < _prefixRemovalScratch.Count; i++) + { + uint ownerId = _prefixRemovalScratch[i]; + _retailCellArrays.Remove(ownerId); + bool endsWithLandblock = + !_entityReg.TryGetValue(ownerId, out RegistrationRecord? registration) + || registration.IsStatic; + if (!endsWithLandblock) + continue; + _retailCellArrayRoutes.Remove(ownerId); + _entityRetailPartArrays.Remove(ownerId); + // A render-only static never had collision cells, so the static + // retirement loop above could not reach its registration. + _entityShapes.Remove(ownerId); + _entityReg.Remove(ownerId); + _suspendedEntities.Remove(ownerId); + _suspendedEntityCells.Remove(ownerId); + _withdrawnPrefixesByOwner.Remove(ownerId); + } + } + /// /// Retires one logical owner's rows from a streamed-out prefix. This is /// the owner-granular form used by the collision-generation retirement @@ -2895,10 +3209,39 @@ public sealed class ShadowObjectRegistry AdvanceMutationRevision(); return; } - if (!_entityToCells.TryGetValue(entityId, out List? cells)) - return; - bool touched = false; + // Campaign OVERHAUL S2 review fix (arch F4 / retail F4): the owner's + // retail part rows leave the retired prefix's cells together with + // its collision rows (remove_shadows_from_cells 0x00511230 removes + // both per cell); the part array stays for the reload reflood. + if (_retailCellArrays.TryGetValue(entityId, out List? retailCells)) + { + for (int index = retailCells.Count - 1; index >= 0; index--) + { + uint cellId = retailCells[index]; + if ((cellId & 0xFFFF0000u) != prefix) + continue; + touched = true; + retailCells.RemoveAt(index); + if (_retailPartEntriesByCell.TryGetValue( + cellId, + out List? partRows)) + { + RemoveOwnerPartRows(partRows, entityId); + if (partRows.Count == 0) + _retailPartEntriesByCell.Remove(cellId); + } + } + if (retailCells.Count == 0) + _retailCellArrays.Remove(entityId); + } + if (!_entityToCells.TryGetValue(entityId, out List? cells)) + { + if (touched) + BumpOwnerVersion(entityId); + return; + } + for (int index = cells.Count - 1; index >= 0; index--) { uint cellId = cells[index]; @@ -3311,8 +3654,7 @@ public sealed class ShadowObjectRegistry { rows.Add(new PreparedShadowCellRows( cellId, - entries.Where(entry => entry.EntityId == entityId) - .ToArray())); + CollectOwnerRows(entries, entityId))); } } } @@ -3341,8 +3683,7 @@ public sealed class ShadowObjectRegistry { retailRows.Add(new PreparedShadowRetailPartRows( cellId, - entries.Where(entry => entry.EntityId == entityId) - .ToArray())); + CollectOwnerPartRows(entries, entityId))); } } } diff --git a/src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs b/src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs index 4c91b4a9..14e95b1f 100644 --- a/src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs +++ b/src/AcDream.Runtime/Physics/LiveEntityCollisionBuilder.cs @@ -220,19 +220,15 @@ internal sealed class LiveEntityCollisionBuilder effectivePartGfxObjIds: effectivePartGfxObjIds, physicsBspBounds: _physicsBspBounds); - if (shapes.Count == 0 && !retainEmptyPayload) - return null; - - EntityCollisionFlags flags = EntityCollisionFlags.HasWeenie; - if (spawn.ObjectDescriptionFlags is { } descriptionFlags) - flags |= EntityCollisionFlagsExt.FromPwdBitfield(descriptionFlags); - if (spawn.ItemType == (uint)ItemType.Creature) - flags |= EntityCollisionFlags.IsCreature; - - // Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array — - // every Setup part, colliding or not — beside the BSP-exclusive - // `shapes` collision dispatch above. A side product only; nothing - // consumes it yet. + // Campaign OVERHAUL S2 chunk 5 (item B): retail's WHOLE visual part + // array — every Setup part, colliding or not — computed BEFORE the + // empty-shapes gate below. A live entity whose Setup carries visual + // parts but no collision geometry at all (a short-lived spell/visual + // effect object) must still register for render: Contract B's + // CPartArray::AddPartsShadow membership is not gated on a successful + // collision-shape dispatch, only on a non-null part array. Distinct + // from `shapes`, which stays the BSP-exclusive COLLISION dispatch + // (AP-152). IReadOnlyList renderParts = ShadowShapeBuilder.FromSetupRenderParts( setup, scale, @@ -241,6 +237,21 @@ internal sealed class LiveEntityCollisionBuilder _getGfxObj, _getVisualBounds); + // Campaign OVERHAUL S2 chunk 5: a Setup with NEITHER a collision + // shape NOR a visual part yields no registration at all — retail + // synthesizes nothing for a truly shapeless object (see this + // method's class remarks). `retainEmptyPayload` (ObjDesc updates + // that must be able to clear a prior collision payload down to + // nothing) still forces a registration through even here. + if (shapes.Count == 0 && renderParts.Count == 0 && !retainEmptyPayload) + return null; + + EntityCollisionFlags flags = EntityCollisionFlags.HasWeenie; + if (spawn.ObjectDescriptionFlags is { } descriptionFlags) + flags |= EntityCollisionFlagsExt.FromPwdBitfield(descriptionFlags); + if (spawn.ItemType == (uint)ItemType.Creature) + flags |= EntityCollisionFlags.IsCreature; + return new LiveEntityCollisionRegistration( entity.Id, entity.SourceGfxObjOrSetupId, diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs index b104f751..9aafb3cb 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs @@ -155,6 +155,78 @@ public sealed class LiveEntityCollisionBuilderTests Assert.Equal(part, shape.GfxObjId); } + /// + /// Campaign OVERHAUL S2 chunk 5 (item B): a short-lived spell/visual + /// effect object's Setup can carry a visual part with no physics BSP and + /// no CylSpheres/Spheres — retail's FindObjCollisions synthesizes + /// no COLLISION shape for such a part (this file's own + /// remark), + /// but the part is still a real visual part + /// CPartArray::AddPartsShadow must register for render (Contract + /// B). must not collapse + /// this case to "no registration at all" just because the COLLISION + /// dispatch came up empty — it must still return a registration whose + /// RenderParts carries the part, and + /// (the exact call + /// DatLiveEntityProjectionMaterializer makes) must register it + /// render-only: a real retail CELLARRAY with per-cell + /// RetailPartEntry rows, but NO collision row anywhere. + /// + [Fact] + public void Build_PartWithNoCollisionButResolvableVisualBounds_RegistersRenderOnly() + { + const uint part = 0x0100BEEFu; + var setup = new Setup(); + setup.Parts.Add(part); + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + physicsBspBounds: _ => null, + PoseResolver(), + getGfxObj: _ => null, + getVisualBounds: id => id == part + ? new GfxObjVisualBounds + { + Min = new Vector3(-1f, -1f, -1f), + Max = new Vector3(1f, 1f, 1f), + Center = Vector3.Zero, + Radius = 1.5f, + HalfExtents = new Vector3(1f, 1f, 1f), + } + : null); + + LiveEntityCollisionRegistration registration = Assert.IsType( + builder.Build( + entity, + setup, + [part], + spawn, + record.ServerGuid, + record.Generation, + record.WorldEntity!, + record.FinalPhysicsState, + Vector3.Zero)); + + Assert.Empty(registration.Shapes); + ShadowShape renderPart = Assert.Single(registration.RenderParts); + Assert.Equal(part, renderPart.GfxObjId); + + var registry = new ShadowObjectRegistry(); + LiveEntityCollisionBuilder.Register(registry, registration); + + Assert.True( + registry.TryGetRetailCellArray(entity.Id, out IReadOnlyList cells)); + Assert.NotEmpty(cells); + Assert.Empty(registry.GetOwnerCells(entity.Id)); + var entries = registry.GetRetailPartEntriesInCell(cells[0]) + .Where(e => e.EntityId == entity.Id) + .ToList(); + Assert.Single(entries); + Assert.Equal(part, entries[0].GfxObjId); + } + /// /// AP-152. Every other fixture in this file is primitive-only or BSP-only, /// so nothing at the App layer used to exercise the CylSphere+BSP diff --git a/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs b/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs index 83a6dc5e..e1dc5a31 100644 --- a/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs @@ -520,13 +520,6 @@ public sealed class ArchRenderSceneTests LightCandidate: 1, Dirty: 6), query.IndexCounts); - Assert.Equal(1, query.GetCellStaticCount(indoorCell)); - Assert.Equal(1, query.GetCellDynamicCount(indoorCell)); - var cellRecords = new RenderProjectionRecord[1]; - Assert.Equal(1, query.CopyCellStaticsTo(indoorCell, cellRecords)); - Assert.Equal(indoorStatic.Id, cellRecords[0].Id); - Assert.Equal(1, query.CopyCellDynamicsTo(indoorCell, cellRecords)); - Assert.Equal(cellDynamic.Id, cellRecords[0].Id); Assert.True(scene.Memory.EstimatedIndexBytes > 0); } @@ -580,7 +573,6 @@ public sealed class ArchRenderSceneTests Assert.Equal(1, updated.IndexCounts.Translucent); Assert.Equal(1, updated.IndexCounts.LightCandidate); Assert.Equal(1, updated.IndexCounts.Dirty); - Assert.Equal(1, updated.GetCellDynamicCount(indoorCell)); RenderProjectionRecord replacement = rebucketed with { @@ -592,7 +584,6 @@ public sealed class ArchRenderSceneTests RenderSceneQuery replaced = scene.OpenQuery(); Assert.Equal(0, replaced.IndexCounts.Dynamic); Assert.Equal(1, replaced.IndexCounts.IndoorCellStatic); - Assert.Equal(1, replaced.GetCellStaticCount(indoorCell)); scene.Apply( [ @@ -1085,6 +1076,92 @@ public sealed class ArchRenderSceneTests Assert.Equal(changed.LatestRevision, query.DirectionalShadowTransformRevision); } + // Campaign OVERHAUL S2 review fix round (F5/F6): the LocalEntityId index. + + private static RenderProjectionRecord WithLocalEntityId( + RenderProjectionRecord record, + uint localEntityId) => + record with + { + Source = new RenderSourceMetadata( + LocalEntityId: localEntityId, + ServerGuid: 0, + SourceId: 1, + ParentCellId: record.Source.ParentCellId, + EffectCellId: 0, + BuildingShellAnchorCellId: 0, + TransformFingerprint: new RenderSceneHash128(1, 1), + GeometryFingerprint: new RenderSceneHash128(2, 2), + AppearanceFingerprint: new RenderSceneHash128(3, 3)), + }; + + [Fact] + public void TryGetByLocalEntityId_IgnoresEnvCellShellRecords() + { + // An EnvCell shell carries its CELL id in the entity-id slot; a + // dungeon cell id such as 0x8A020100 is numerically a procedural + // scenery entity id. The shell must never shadow the real entity. + const uint aliasedId = 0x8A020100u; + RenderSceneGeneration generation = Generation(31); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord shell = WithLocalEntityId( + Record(310, 1, RenderProjectionClass.IndoorCellStatic), aliasedId); + RenderProjectionRecord scenery = WithLocalEntityId( + Record(311, 1, RenderProjectionClass.OutdoorStatic), aliasedId); + + scene.Apply( + [ + RenderProjectionDelta.Register(generation, 1, shell), + RenderProjectionDelta.Register(generation, 2, scenery), + ]); + RenderSceneQuery query = scene.OpenQuery(); + + Assert.True(query.TryGetByLocalEntityId(aliasedId, out RenderProjectionRecord found)); + Assert.Equal(scenery.Id, found.Id); + + // Unregistering the shell never disturbs the entity's mapping. + scene.Apply( + [ + RenderProjectionDelta.Unregister( + generation, + 3, + shell.Id, + shell.OwnerIncarnation), + ]); + query = scene.OpenQuery(); + Assert.True(query.TryGetByLocalEntityId(aliasedId, out found)); + Assert.Equal(scenery.Id, found.Id); + } + + [Fact] + public void Update_ThatRebindsTheLocalEntityId_MovesTheIndex() + { + RenderSceneGeneration generation = Generation(32); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord original = WithLocalEntityId( + Record(320, 1, RenderProjectionClass.LiveDynamicRoot), 19); + scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]); + Assert.True(scene.OpenQuery().TryGetByLocalEntityId(19, out _)); + + // Same class, cell, flags, mesh count and sort key — only the id + // changes; the index must follow it (IndexMembershipEquals compares + // the id since the S2 review fix). + RenderProjectionRecord rebound = WithLocalEntityId(original, 20); + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateFlags, + generation, + 2, + rebound), + ]); + RenderSceneQuery query = scene.OpenQuery(); + + Assert.False(query.TryGetByLocalEntityId(19, out _)); + Assert.True(query.TryGetByLocalEntityId(20, out RenderProjectionRecord found)); + Assert.Equal(original.Id, found.Id); + } + private static RenderProjectionRecord Record( ulong id, ulong incarnation, diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs index 88c5825a..67048994 100644 --- a/tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs +++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs @@ -103,7 +103,6 @@ public sealed class DirectionalShadowCasterFrameTests Assert.Equal( [RenderSceneIndex.OutdoorStatic, RenderSceneIndex.OutdoorDynamic], source.CopiedIndices); - Assert.Equal(0, source.CellQueries); Assert.Equal(2, frame.Stats.IndexCopies); } @@ -635,7 +634,6 @@ public sealed class DirectionalShadowCasterFrameTests public int IndexCountReads { get; private set; } public int IndexCopies { get; private set; } - public int CellQueries { get; private set; } public int ProjectionReads { get; private set; } public int BatchedProjectionCopies { get; private set; } public ulong TopologyRevision { get; private set; } = 1; @@ -731,6 +729,32 @@ public sealed class DirectionalShadowCasterFrameTests return false; } + public bool TryGetByLocalEntityId( + RenderSceneGeneration generation, + uint localEntityId, + out RenderProjectionRecord record) + { + for (int index = 0; index < _statics.Length; index++) + { + if (_statics[index].Source.LocalEntityId == localEntityId) + { + record = _statics[index]; + return true; + } + } + for (int index = 0; index < _dynamics.Length; index++) + { + if (_dynamics[index].Source.LocalEntityId == localEntityId) + { + record = _dynamics[index]; + return true; + } + } + + record = default; + return false; + } + public int CopyById( RenderSceneGeneration generation, ReadOnlySpan ids, @@ -791,25 +815,6 @@ public sealed class DirectionalShadowCasterFrameTests return values.Length; } - public int GetCellCount( - RenderSceneGeneration generation, - uint fullCellId, - bool dynamic) - { - CellQueries++; - throw new InvalidOperationException("Directional shadows do not query PView cells."); - } - - public int CopyCellTo( - RenderSceneGeneration generation, - uint fullCellId, - bool dynamic, - Span destination) - { - CellQueries++; - throw new InvalidOperationException("Directional shadows do not query PView cells."); - } - public void ReplaceStatics( RenderProjectionRecord[] values, diff --git a/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs b/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs index 1badf347..e1c768a8 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs @@ -316,7 +316,7 @@ public class Issue177StairDescentCameraFloodTests Assert.Contains( worldData.GetCellStatics(FacilityHub | 0x015Eu).Records, record => record.Id == projectionId); - Assert.Equal(0, worldData.UnregisteredStaticRenderFallbackCount); + Assert.Equal(0, worldData.UnregisteredRenderMembershipCount); } /// diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs index f155d92a..95238fb0 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs @@ -3,9 +3,23 @@ using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene.Arch; using AcDream.App.Rendering.Walk; using AcDream.Core.Physics; +using AcDream.Core.World; namespace AcDream.App.Tests.Rendering.Walk; +/// +/// Campaign OVERHAUL S2 chunk 5: no +/// longer sweeps the scene to rebuild membership into bucket dictionaries — +/// GetCellStatics/GetCellDynamics/GetOutdoorStatics/ +/// GetOutdoorDynamics are borrowed, on-demand views over +/// 's retail per-cell part-entry product, +/// resolved back to a through +/// . Every test below +/// drives the REAL production path end to end — a real (bare, no-DAT) +/// and a real +/// — rather than a hand-fed bucket dictionary, since the resolution itself IS +/// the thing under test now. +/// public sealed class WalkProductionWorldDataTests { [Fact] @@ -79,288 +93,6 @@ public sealed class WalkProductionWorldDataTests WalkProductionWorldData.BuildingShellBucketCellId(building)); } - [Fact] - public void BucketOutdoorRecord_UsesEveryOutdoorPhysicsShadowCellAcrossLandblockEdge() - { - RenderProjectionRecord record = Record( - id: 0x1234u, - position: new Vector3(191f, 191f, 0f)); - var buckets = new Dictionary>(); - - WalkProductionWorldData.BucketOutdoorRecord( - in record, - [0xF07F0040u, 0xF0800001u, 0xF4180101u], - buckets, - renderCenterLbX: 0xF0, - renderCenterLbY: 0x7F); - - Assert.Equal([0xF07F0040u, 0xF0800001u], buckets.Keys.Order()); - Assert.All(buckets.Values, bucket => Assert.Equal(record, Assert.Single(bucket))); - } - - [Fact] - public void BucketOutdoorRecord_UnregisteredEffectFallsBackToRootPositionCell() - { - RenderProjectionRecord record = Record( - id: 0x5678u, - position: new Vector3(193f, 25f, 0f)); - var buckets = new Dictionary>(); - - WalkProductionWorldData.BucketOutdoorRecord( - in record, - Array.Empty(), - buckets, - renderCenterLbX: 0xEF, - renderCenterLbY: 0x7F); - - Assert.Equal([0xF07F0002u], buckets.Keys); - Assert.Equal(record, Assert.Single(buckets[0xF07F0002u])); - } - - [Fact] - public void BucketIndoorRecord_InstallsCrossCellPartInBothInteriorCells() - { - RenderProjectionRecord record = Record( - id: 0x48A02035u, - position: new Vector3(55.25f, -46.47f, -3f)) with - { - Source = new RenderSourceMetadata() with - { - LocalEntityId = 0x48A02035u, - ParentCellId = 0x8A02015Fu, - }, - }; - var indoor = new Dictionary>(); - var outdoor = new Dictionary>(); - - WalkProductionWorldData.BucketIndoorRecord( - in record, - [0x8A02015Fu, 0x8A02015Eu], - indoor, - outdoor); - - Assert.Equal([0x8A02015Eu, 0x8A02015Fu], indoor.Keys.Order()); - Assert.All(indoor.Values, bucket => Assert.Equal(record, Assert.Single(bucket))); - Assert.Empty(outdoor); - } - - [Fact] - public void BucketIndoorRecord_CanCrossAnExitIntoLandscapeCell() - { - RenderProjectionRecord record = Record( - id: 0x48A02035u, - position: Vector3.Zero) with - { - Source = new RenderSourceMetadata() with - { - LocalEntityId = 0x48A02035u, - ParentCellId = 0x8A02015Fu, - }, - }; - var indoor = new Dictionary>(); - var outdoor = new Dictionary>(); - - WalkProductionWorldData.BucketIndoorRecord( - in record, - [0x8A02015Fu, 0x8A020021u], - indoor, - outdoor); - - Assert.Equal(record, Assert.Single(indoor[0x8A02015Fu])); - Assert.Equal(record, Assert.Single(outdoor[0x8A020021u])); - } - - [Fact] - public void BucketDynamicRecord_InstallsMultipartPlayerInEveryCrossedInteriorCell() - { - RenderProjectionRecord record = Record( - id: 0x000F4243u, - position: new Vector3(58.81f, -49.42f, -0.85f)) with - { - Source = new RenderSourceMetadata() with - { - LocalEntityId = 0x000F4243u, - ParentCellId = 0x8A02015Eu, - }, - }; - var indoor = new Dictionary>(); - var outdoor = new Dictionary>(); - - WalkProductionWorldData.BucketDynamicRecord( - in record, - [0x8A02015Eu, 0x8A02015Fu, 0x8A0201C1u], - indoor, - outdoor, - renderCenterLbX: 0x8A, - renderCenterLbY: 0x02); - - Assert.Equal( - [0x8A02015Eu, 0x8A02015Fu, 0x8A0201C1u], - indoor.Keys.Order()); - Assert.All( - indoor.Values, - bucket => Assert.Equal(record, Assert.Single(bucket))); - Assert.Empty(outdoor); - } - - [Fact] - public void BucketDynamicRecord_UnregisteredInteriorEffectFallsBackToParentCell() - { - RenderProjectionRecord record = Record( - id: 0x00001234u, - position: Vector3.Zero) with - { - Source = new RenderSourceMetadata() with - { - LocalEntityId = 0x00001234u, - ParentCellId = 0x8A02015Fu, - }, - }; - var indoor = new Dictionary>(); - var outdoor = new Dictionary>(); - - WalkProductionWorldData.BucketDynamicRecord( - in record, - Array.Empty(), - indoor, - outdoor, - renderCenterLbX: 0x8A, - renderCenterLbY: 0x02); - - Assert.Equal(record, Assert.Single(indoor[0x8A02015Fu])); - 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_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: childId, - position: Vector3.Zero) with - { - Source = new RenderSourceMetadata() with - { - LocalEntityId = childId, - ParentCellId = 0xF4180104u, - }, - EntityPayload = new RenderEntityPayload() with - { - CasterIdentity = RenderCasterIdentityKind.EquippedChild, - }, - }; - - IReadOnlyList cells = - WalkProductionWorldData.ResolveDynamicRenderCells( - in wand, - id => shadows.TryGetRetailCellArray(id, out IReadOnlyList c) - ? (true, c) - : (false, Array.Empty()), - shadows.GetOwnerCells, - out bool usedFallback); - - 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); - } - - // ----------------------------------------------------------------- - // Campaign OVERHAUL S2 chunk 2: BeginFrame's indoor static sweep now - // borrows membership from ShadowObjectRegistry.TryGetRetailCellArray - // instead of rebuilding it (the deleted ResolveStaticRenderCells / - // ShadowObjectRegistry.ComputeStaticRenderCells pair). These two tests - // drive the REAL production path end to end — a real (bare, no-DAT) - // ShadowObjectRegistry, a real ArchRenderScene, and BeginFrame itself — - // rather than the hardcoded-cell-array style of the Bucket* tests above, - // which exercise only the (unchanged) bucketing primitive. - // ----------------------------------------------------------------- - private static ShadowShape Bsp(uint gfxObjId, float radius = 1f) => ShadowShape.Bsp( gfxObjId, @@ -388,20 +120,54 @@ public sealed class WalkProductionWorldDataTests SourceId = sourceId, ParentCellId = parentCellId, }, + // Every production entity record carries its MeshRefs + // (RenderProjectionRecordFactory); only an EnvCell SHELL has no + // payload, and ArchRenderScene keeps shells out of the + // LocalEntityId index on exactly that distinction. + EntityPayload = new RenderEntityPayload() with + { + MeshRefs = [new MeshRef(sourceId, Matrix4x4.Identity)], + }, }; - [Fact] - public void BeginFrame_IndoorStatic_RegisteredEntityUsesRegistryRetailCellArray() - { - const uint entityId = 0x48A02001u; - const uint retailCellId = 0x8A02015Fu; - // A DECOY parent cell, deliberately different from the registered - // retail array's cell, so the assertions below can only pass if the - // indoor sweep actually consulted the registry rather than falling - // back to the authored parent. - const uint decoyParentCellId = 0x8A0201C1u; + private static RenderProjectionRecord DynamicRecord( + uint entityId, uint sourceId, uint parentCellId) => + IndoorStaticRecord(entityId, sourceId, parentCellId) with + { + Id = RenderProjectionId.FromRaw(0x0100_0000_0000_0000u | entityId), + ProjectionClass = RenderProjectionClass.LiveDynamicRoot, + }; - var shadows = new ShadowObjectRegistry(); + private static RenderProjectionRecord OutdoorStaticRecord( + uint entityId, uint sourceId, uint cellId, bool isBuildingShell = false) => + new RenderProjectionRecord() with + { + Id = RenderProjectionId.FromRaw(0x0200_0000_0000_0000u | entityId), + ProjectionClass = RenderProjectionClass.OutdoorStatic, + OwnerIncarnation = RenderOwnerIncarnation.FromRaw(1), + Transform = new RenderTransform(Matrix4x4.Identity), + PreviousTransform = new PreviousRenderTransform(Matrix4x4.Identity), + Residency = new RenderSpatialResidency( + RenderSpatialBucket.FromRaw(cellId), cellId & 0xFFFF0000u, cellId), + Flags = RenderProjectionFlags.Draw, + Source = new RenderSourceMetadata() with + { + LocalEntityId = entityId, + SourceId = sourceId, + ParentCellId = cellId, + }, + EntityPayload = new RenderEntityPayload() with + { + IsBuildingShell = isBuildingShell, + }, + }; + + private static void Register( + ShadowObjectRegistry shadows, + uint entityId, + uint seedCellId, + uint landblockId) + { IReadOnlyList parts = new[] { Bsp(0x01001234u) }; shadows.RegisterMultiPart( entityId, @@ -412,10 +178,26 @@ public sealed class WalkProductionWorldDataTests flags: EntityCollisionFlags.None, worldOffsetX: 0f, worldOffsetY: 0f, - landblockId: 0x8A020000u, - seedCellId: retailCellId, + landblockId: landblockId, + seedCellId: seedCellId, isStatic: true, partArray: parts); + } + + [Fact] + public void GetCellStatics_RegisteredEntityIsResolvedByLocalEntityIdAtTheRetailCell() + { + const uint entityId = 0x48A02001u; + const uint retailCellId = 0x8A02015Fu; + // A DECOY parent cell, deliberately different from the registered + // retail array's cell, so the assertions below can only pass if the + // per-cell view actually consulted the registry rather than the + // authored parent (deleted alongside the sweep this test used to + // exercise). + const uint decoyParentCellId = 0x8A0201C1u; + + var shadows = new ShadowObjectRegistry(); + Register(shadows, entityId, retailCellId, landblockId: 0x8A020000u); Assert.True(shadows.TryGetRetailCellArray(entityId, out IReadOnlyList retailCells)); Assert.Equal(new[] { retailCellId }, retailCells); @@ -435,34 +217,265 @@ public sealed class WalkProductionWorldDataTests Assert.DoesNotContain( worldData.GetCellStatics(decoyParentCellId).Records, record => record.Id == projection.Id); - Assert.Equal(0, worldData.UnregisteredStaticRenderFallbackCount); + Assert.Equal(0, worldData.UnregisteredRenderMembershipCount); } [Fact] - public void BeginFrame_IndoorStatic_UnregisteredEntityFallsBackToParentCellAndCountsFallback() + public void GetCellStatics_RegistryAheadOfSceneContributesToNoCellAndCountsFallback() { const uint entityId = 0x48A02002u; + const uint retailCellId = 0x8A02015Fu; + + // The registry HAS flooded this entity into its retail CELLARRAY — + // exactly the streaming-window race chunk 5 keeps as the ONE + // remaining fallback: the physics publisher (registry) ran before + // the presentation journal applied this frame's projected record. + var shadows = new ShadowObjectRegistry(); + Register(shadows, entityId, retailCellId, landblockId: 0x8A020000u); + Assert.True(shadows.TryGetRetailCellArray(entityId, out _)); + + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1); + using var scene = new ArchRenderScene(generation); + // Deliberately no scene.Apply — the projected record does not exist + // yet this frame. + + var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows); + worldData.BeginFrame( + scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02); + + Assert.Equal(0, worldData.GetCellStatics(retailCellId).Records.Count); + Assert.Equal(1, worldData.UnregisteredRenderMembershipCount); + } + + [Fact] + public void GetCellStatics_UnregisteredEntityContributesToNoCellWithoutCounting() + { + // The OPPOSITE race: the scene has a projected record, but the + // registry never registered a retail CELLARRAY for this entity at + // all. Under the borrowed-view model this entity is never visited + // (nothing in the registry names it), so it correctly appears in NO + // cell and does not inflate the one remaining fallback counter — + // that counter only tracks entities the REGISTRY has flooded. + const uint entityId = 0x48A02003u; const uint parentCellId = 0x8A02015Fu; - // Bare, empty registry: entityId is never registered, so - // TryGetRetailCellArray must answer false for it. var shadows = new ShadowObjectRegistry(); Assert.False(shadows.TryGetRetailCellArray(entityId, out _)); RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1); using var scene = new ArchRenderScene(generation); RenderProjectionRecord projection = - IndoorStaticRecord(entityId, sourceId: 0x02000002u, parentCellId); + IndoorStaticRecord(entityId, sourceId: 0x02000003u, parentCellId); scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]); + var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows); + worldData.BeginFrame( + scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02); + + Assert.Equal(0, worldData.GetCellStatics(parentCellId).Records.Count); + Assert.Equal(0, worldData.UnregisteredRenderMembershipCount); + } + + [Fact] + public void UnregisteredRenderMembershipCount_CountsDistinctEntitiesNotCellVisits() + { + // One entity crossing SEVERAL cells — outdoor, since the bbox flood's + // fixed outdoor expansion reliably crosses cells even against a bare + // registry with no portal DAT data to cross INDOOR cells with (see + // GetOutdoorStatics_UsesTheSameRegistryDrivenViewAsIndoor) — all + // unresolved in the scene, must count once total, not once per cell + // the registry flooded it into. + const uint entityId = 0x87640002u; + const uint seedCellId = 0x87640030u; + + IReadOnlyList parts = new[] { Bsp(0x01001234u) }; + var shadows = new ShadowObjectRegistry(); + shadows.RegisterMultiPart( + entityId, + Vector3.Zero, + Quaternion.Identity, + parts, + state: 0u, + flags: EntityCollisionFlags.None, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0x87640000u, + seedCellId: seedCellId, + isStatic: true, + partArray: parts); + Assert.True(shadows.TryGetRetailCellArray(entityId, out IReadOnlyList cells)); + Assert.True(cells.Count >= 2, "fixture must actually cross more than one cell"); + + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1); + using var scene = new ArchRenderScene(generation); + + var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows); + worldData.BeginFrame( + scene.OpenQuery(), 0x87640000u, renderCenterLbX: 0x87, renderCenterLbY: 0x64); + + foreach (uint cellId in cells) + Assert.Equal(0, worldData.GetOutdoorStatics(cellId).Records.Count); + + Assert.Equal(1, worldData.UnregisteredRenderMembershipCount); + } + + [Fact] + public void GetCellDynamics_OnlyReturnsDynamicClassRecordsFromTheSameCell() + { + const uint staticEntityId = 0x48A02010u; + const uint dynamicEntityId = 0x48A02011u; + const uint sharedCellId = 0x8A02015Fu; + + var shadows = new ShadowObjectRegistry(); + Register(shadows, staticEntityId, sharedCellId, landblockId: 0x8A020000u); + Register(shadows, dynamicEntityId, sharedCellId, landblockId: 0x8A020000u); + + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord staticProjection = + IndoorStaticRecord(staticEntityId, sourceId: 0x02000010u, sharedCellId); + RenderProjectionRecord dynamicProjection = + DynamicRecord(dynamicEntityId, sourceId: 0x02000011u, sharedCellId); + scene.Apply([ + RenderProjectionDelta.Register(generation, 1, staticProjection), + RenderProjectionDelta.Register(generation, 2, dynamicProjection), + ]); + var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows); worldData.BeginFrame( scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02); Assert.Contains( - worldData.GetCellStatics(parentCellId).Records, + worldData.GetCellStatics(sharedCellId).Records, + record => record.Id == staticProjection.Id); + Assert.DoesNotContain( + worldData.GetCellStatics(sharedCellId).Records, + record => record.Id == dynamicProjection.Id); + + Assert.Contains( + worldData.GetCellDynamics(sharedCellId).Records, + record => record.Id == dynamicProjection.Id); + Assert.DoesNotContain( + worldData.GetCellDynamics(sharedCellId).Records, + record => record.Id == staticProjection.Id); + } + + [Fact] + public void GetCellStatics_ExcludesBuildingShellRecordsFromTheSameCell() + { + const uint shellEntityId = 0x48A02020u; + const uint ordinaryEntityId = 0x48A02021u; + const uint sharedCellId = 0x8A02015Fu; + + var shadows = new ShadowObjectRegistry(); + Register(shadows, shellEntityId, sharedCellId, landblockId: 0x8A020000u); + Register(shadows, ordinaryEntityId, sharedCellId, landblockId: 0x8A020000u); + + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord shellProjection = + IndoorStaticRecord(shellEntityId, sourceId: 0x02000020u, sharedCellId) with + { + EntityPayload = new RenderEntityPayload() with { IsBuildingShell = true }, + }; + RenderProjectionRecord ordinaryProjection = + IndoorStaticRecord(ordinaryEntityId, sourceId: 0x02000021u, sharedCellId); + scene.Apply([ + RenderProjectionDelta.Register(generation, 1, shellProjection), + RenderProjectionDelta.Register(generation, 2, ordinaryProjection), + ]); + + var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows); + worldData.BeginFrame( + scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02); + + Assert.DoesNotContain( + worldData.GetCellStatics(sharedCellId).Records, + record => record.Id == shellProjection.Id); + Assert.Contains( + worldData.GetCellStatics(sharedCellId).Records, + record => record.Id == ordinaryProjection.Id); + } + + [Fact] + public void GetOutdoorStatics_UsesTheSameRegistryDrivenViewAsIndoor() + { + const uint entityId = 0x87640001u; + const uint outdoorCellId = 0x87640030u; + + var shadows = new ShadowObjectRegistry(); + Register(shadows, entityId, outdoorCellId, landblockId: 0x87640000u); + Assert.True(shadows.TryGetRetailCellArray(entityId, out IReadOnlyList retailCells)); + // The outdoor bbox flood's fixed expansion crosses more than the + // seed cell even for a small radius; only the seed cell's own + // membership matters for this test. + Assert.Contains(outdoorCellId, retailCells); + + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord projection = + OutdoorStaticRecord(entityId, sourceId: 0x02000030u, outdoorCellId); + scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]); + + var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows); + worldData.BeginFrame( + scene.OpenQuery(), 0x87640000u, renderCenterLbX: 0x87, renderCenterLbY: 0x64); + + Assert.Contains( + worldData.GetOutdoorStatics(outdoorCellId).Records, record => record.Id == projection.Id); - Assert.Equal(1, worldData.UnregisteredStaticRenderFallbackCount); + } + + [Fact] + public void GetCellStatics_CachesTheResultForTheRestOfTheFrame() + { + const uint entityId = 0x48A02030u; + const uint retailCellId = 0x8A02015Fu; + + var shadows = new ShadowObjectRegistry(); + Register(shadows, entityId, retailCellId, landblockId: 0x8A020000u); + + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord projection = + IndoorStaticRecord(entityId, sourceId: 0x02000030u, retailCellId); + scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]); + + var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows); + worldData.BeginFrame( + scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02); + + WalkFrameStaticRecords first = worldData.GetCellStatics(retailCellId); + WalkFrameStaticRecords second = worldData.GetCellStatics(retailCellId); + + Assert.Equal(first.Records.Array, second.Records.Array); + Assert.Equal(first.Records.Offset, second.Records.Offset); + Assert.Equal(first.Records.Count, second.Records.Count); + } + + [Fact] + public void StaticBucketContains_FindsARegisteredSourceIdInItsRetailCell() + { + const uint entityId = 0x48A02040u; + const uint sourceId = 0x020009A2u; + const uint retailCellId = 0xF4180112u; + + var shadows = new ShadowObjectRegistry(); + Register(shadows, entityId, retailCellId, landblockId: 0xF4180000u); + + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord projection = + IndoorStaticRecord(entityId, sourceId, retailCellId); + scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]); + + var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows); + worldData.BeginFrame( + scene.OpenQuery(), 0xF4180000u, renderCenterLbX: 0xF4, renderCenterLbY: 0x18); + + Assert.True(worldData.StaticBucketContains(retailCellId, sourceId)); + Assert.False(worldData.StaticBucketContains(retailCellId, sourceId + 1)); + Assert.False(worldData.StaticBucketContains(0xF4180107u, sourceId)); } private static RenderProjectionRecord Record(uint id, Vector3 position) => diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index ca117968..9604d01a 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -1333,6 +1333,73 @@ public sealed class LandblockPhysicsPublisherTests Resolved = new Dictionary(), }; + /// + /// Campaign OVERHAUL S2 chunk 5 closeout (G2 self-gate finding): a DAT + /// static with visual parts but no collision geometry is still flooded + /// into its cells by retail (calc_cross_cells_static 0x00515160 bbox route + /// + add_shadows_to_cells 0x00514ae0 AddPartsShadow rows), so the static + /// publisher registers it render-only — retail cell array + part entries, + /// zero collision rows. Before this pin the publisher skipped it outright + /// and, with the walk's parent-cell fallback deleted, the Facility Hub's + /// decorative wall panels vanished. + /// + [Fact] + public void CompletePublication_NonCollidingStaticRegistersRenderOnly() + { + const uint gfxObjId = 0x01000079u; + var fixture = Fixture(); + fixture.Cache.RegisterGfxObjForTest(gfxObjId, RenderOnlyGfx(radius: 0.8f)); + WorldEntity entity = new() + { + Id = 0x80A9B402u, + SourceGfxObjOrSetupId = gfxObjId, + Position = new Vector3(12f, 12f, 0f), + Rotation = Quaternion.Identity, + MeshRefs = [new MeshRef(gfxObjId, Matrix4x4.Identity)], + }; + + Publish(fixture.Publisher, Build(FirstLandblock, [entity])); + + ShadowObjectRegistry shadows = fixture.Engine.ShadowObjects; + // No collision row anywhere (Contract B: nothing to contribute). + Assert.Empty(shadows.AllEntriesForDebug()); + // But the retail render product exists: cell array + one part entry. + Assert.True(shadows.TryGetRetailCellArray(entity.Id, out var cells)); + Assert.NotEmpty(cells); + Assert.Equal(RetailCellArrayRoute.BoundingBox, shadows.GetRetailCellArrayRoute(entity.Id)); + int matching = 0; + foreach (RetailPartEntry part in shadows.GetRetailPartEntriesInCell(cells[0])) + { + if (part.EntityId != entity.Id) + continue; + matching++; + Assert.Equal(gfxObjId, part.GfxObjId); + } + Assert.Equal(1, matching); + Assert.Equal(0, fixture.Publisher.Diagnostics.StaticBspOwnerCount); + Assert.Equal(0, fixture.Publisher.Diagnostics.StaticCylinderOwnerCount); + } + + /// A GfxObj with visual bounds and NO physics BSP — a purely + /// decorative static part. + private static GfxObjPhysics RenderOnlyGfx(float radius) => new() + { + BoundingSphere = new Sphere + { + Origin = Vector3.Zero, + Radius = radius, + }, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = new Dictionary(), + VisualBounds = new FlatGfxObjVisualBounds( + new Vector3(-radius), + new Vector3(radius), + Vector3.Zero, + radius, + new Vector3(radius)), + }; + private static GfxObj PhysicsGfx() => new() { Flags = DatReaderWriter.Enums.GfxObjFlags.HasPhysics, diff --git a/tests/AcDream.Content.Tests/LandblockPhysicsContentBuilderStaticSphereTests.cs b/tests/AcDream.Content.Tests/LandblockPhysicsContentBuilderStaticSphereTests.cs index 7eb48dff..15b03e95 100644 --- a/tests/AcDream.Content.Tests/LandblockPhysicsContentBuilderStaticSphereTests.cs +++ b/tests/AcDream.Content.Tests/LandblockPhysicsContentBuilderStaticSphereTests.cs @@ -108,4 +108,77 @@ public sealed class LandblockPhysicsContentBuilderStaticSphereTests Assert.Equal(expectedOrdered[i].CylHeight, entries[i].CylHeight); } } + + /// + /// Campaign OVERHAUL S2 chunk 5 closeout (G2 self-gate finding): a DAT + /// static whose GfxObj has visual geometry but no physics BSP is still a + /// CPhysicsObj retail floods into its cells (calc_cross_cells_static + /// 0x00515160 bbox route over the part array + add_shadows_to_cells + /// 0x00514ae0 AddPartsShadow rows), so the Content publisher registers it + /// render-only: retail cell array + part entry, zero collision rows, and + /// it still counts as a no-collision owner. + /// + [Fact] + public void PublishStaticCollision_NonCollidingGfxObjStatic_RegistersRenderOnly() + { + const uint gfxObjId = 0x01000043u; + var gfx = new GfxObj + { + VertexArray = new VertexArray + { + Vertices = new Dictionary + { + [0] = new SWVertex { Origin = new Vector3(-0.5f, -0.5f, 0f) }, + [1] = new SWVertex { Origin = new Vector3(0.5f, -0.5f, 0f) }, + [2] = new SWVertex { Origin = new Vector3(0f, 0.5f, 1.2f) }, + }, + }, + }; + FlatGfxObjCollisionAsset asset = FlatCollisionAssetBuilder.FlattenGfxObj(gfx); + Assert.True(asset.PhysicsBsp.RootIndex < 0); // the fixture really has no physics + Assert.NotNull(asset.VisualBounds); + + var entity = new WorldEntity + { + Id = 0x80A9B402u, + SourceGfxObjOrSetupId = gfxObjId, + Position = new Vector3(12f, 12f, 0f), + Rotation = Quaternion.Identity, + MeshRefs = new[] { new MeshRef(gfxObjId, Matrix4x4.Identity) }, + }; + var landblock = new LoadedLandblock( + LandblockId, + new LandBlock { Terrain = new TerrainInfo[81], Height = new byte[81] }, + new[] { entity }); + var collisions = new LandblockCollisionBuild( + ImmutableDictionary.Empty.Add(gfxObjId, asset), + ImmutableDictionary.Empty, + ImmutableDictionary.Empty, + ImmutableDictionary.Empty, + ImmutableArray.Create(gfxObjId), + ImmutableArray.Empty, + ImmutableArray.Empty); + var engine = new PhysicsEngine(); + var cache = new PhysicsDataCache(); + LandblockPhysicsContentBuilder.CachePreparedObjects(cache, collisions); + + LandblockPhysicsContentBuilder.StaticCollisionPublication publication = + LandblockPhysicsContentBuilder.PublishStaticCollision( + engine, cache, landblock, collisions, origin: Vector3.Zero); + + Assert.Equal(0, publication.SetupOwnerCount); + Assert.Equal(0, publication.BspOwnerCount); + Assert.Equal(1, publication.NoCollisionCount); + Assert.Empty(engine.ShadowObjects.AllEntriesForDebug()); + Assert.True(engine.ShadowObjects.TryGetRetailCellArray(entity.Id, out var cells)); + Assert.NotEmpty(cells); + Assert.Equal( + RetailCellArrayRoute.BoundingBox, + engine.ShadowObjects.GetRetailCellArrayRoute(entity.Id)); + var parts = engine.ShadowObjects.GetRetailPartEntriesInCell(cells[0]) + .Where(part => part.EntityId == entity.Id) + .ToArray(); + Assert.Single(parts); + Assert.Equal(gfxObjId, parts[0].GfxObjId); + } } diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs index 5a8640e3..a2bdf42d 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs @@ -867,4 +867,388 @@ public class ShadowObjectRegistryRetailCellArrayTests Assert.True(reg.TryGetRetailCellArray(childId, out var childCells)); Assert.Equal(rootCellsAfter, childCells); } + + // ------------------------------------------------------------------- + // Campaign OVERHAUL S2 chunk 5 (item B): a live entity whose collision + // dispatch produced NO shapes but whose Setup still has a visual part + // array (the retail short-lived spell/visual effect-object case) still + // registers for render — Contract A's cylsphere-vs-bbox TEST reads the + // COLLISION shapes (none here, so it is always false) and the flood + // unconditionally falls through to the bbox route over the WHOLE part + // array. No ShadowEntry collision row is ever published; only the + // retail CELLARRAY and per-cell RetailPartEntry rows are. + // ------------------------------------------------------------------- + + [Fact] + public void RegisterMultiPart_EmptyShapesWithPartArray_RegistersRenderOnlyNoCollisionRows() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x50u; + IReadOnlyList partArray = new[] { Bsp(0x0100_0060u, radius: 2f) }; + + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: true, partArray: partArray); + + Assert.True(reg.TryGetRetailCellArray(entityId, out var cells)); + Assert.Equal(new[] { CellA }, cells); + Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId)); + + var entries = reg.GetRetailPartEntriesInCell(CellA) + .Where(e => e.EntityId == entityId).ToList(); + Assert.Single(entries); + Assert.Equal(0x0100_0060u, entries[0].GfxObjId); + + // No collision row anywhere: shadow_object_list gets nothing for an + // object with no part to contribute to it (Contract B). + Assert.Empty(reg.GetOwnerCells(entityId)); + } + + [Fact] + public void RegisterMultiPart_EmptyShapesAndNoPartArray_StillDeregisters() + { + // Byte-for-byte prior behavior: a caller that supplies neither + // collision shapes nor a part array is a genuine deregistration, not + // a render-only registration. + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x51u; + IReadOnlyList parts = new[] { Bsp(0x0100_0061u) }; + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: true, partArray: parts); + Assert.True(reg.TryGetRetailCellArray(entityId, out _)); + + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: true); + + Assert.False(reg.TryGetRetailCellArray(entityId, out var cells)); + Assert.Empty(cells); + Assert.Empty(reg.GetOwnerCells(entityId)); + Assert.Empty(reg.GetRetailPartEntriesInCell(CellA)); + } + + [Fact] + public void RegisterMultiPart_RenderOnly_KeepWhenEmptyPreservesThePriorRegistration() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x52u; + IReadOnlyList partArray = new[] { Bsp(0x0100_0062u, radius: 2f) }; + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: true, partArray: partArray); + Assert.True(reg.TryGetRetailCellArray(entityId, out var before)); + Assert.Equal(new[] { CellA }, before); + + // landblockId 0 (no seedCellId either) => DeriveOutdoorSeed returns 0 + // => the retail keep-when-empty gate (pc:283540): neither product is + // touched. + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, landblockId: 0u, + isStatic: true, partArray: partArray); + + Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); + Assert.Equal(before, after); + Assert.NotEmpty(reg.GetRetailPartEntriesInCell(CellA)); + } + + [Fact] + public void RegisterMultiPart_RenderOnly_DeregisterClearsEveryProduct() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x53u; + IReadOnlyList partArray = new[] { Bsp(0x0100_0063u, radius: 2f) }; + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: true, partArray: partArray); + Assert.True(reg.TryGetRetailCellArray(entityId, out _)); + + reg.Deregister(entityId); + + Assert.False(reg.TryGetRetailCellArray(entityId, out var cells)); + Assert.Empty(cells); + Assert.Empty(reg.GetRetailPartEntriesInCell(CellA)); + } + + [Fact] + public void UpdatePosition_RenderOnly_RecomputesTheRetailCellArrayAtTheNewPosition() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x54u; + IReadOnlyList partArray = new[] { Bsp(0x0100_0064u, radius: 2f) }; + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: true, partArray: partArray); + Assert.True(reg.TryGetRetailCellArray(entityId, out var before)); + Assert.Equal(new[] { CellA }, before); + + var moved = new Vector3(42f, 12f, 50f); // 30 m away in X -> CellB + reg.UpdatePosition(entityId, moved, Quaternion.Identity, OffX, OffY, LbId); + + Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); + Assert.Equal(new[] { CellB }, after); + Assert.DoesNotContain( + reg.GetRetailPartEntriesInCell(CellA), + e => e.EntityId == entityId); + var movedEntries = reg.GetRetailPartEntriesInCell(CellB) + .Where(e => e.EntityId == entityId).ToList(); + Assert.Single(movedEntries); + Assert.Equal(0x0100_0064u, movedEntries[0].GfxObjId); + // Still no collision row after the move. + Assert.Empty(reg.GetOwnerCells(entityId)); + } + + // ------------------------------------------------------------------- + // Chunk 5 closeout: a streaming reflood (RefloodOwnerForLandblock, the + // path landblock replacement commits and the Content builder's + // post-publication reflood both take) is retail's recalc_cross_cells + // over the SAME CPartArray, so the retail render product survives it. + // Before this pin the reflood re-registered from the collision shapes + // only and every reflooded owner vanished from the walk. + // ------------------------------------------------------------------- + + [Fact] + public void RefloodLandblock_KeepsTheRetailProductOfACollisionOwnerWithAPartArray() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x60u; + IReadOnlyList collision = new[] { Bsp(0x0100_0070u, radius: 2f) }; + IReadOnlyList parts = new[] + { + Bsp(0x0100_0070u, radius: 2f), + Bsp(0x0100_0071u, localPosition: new Vector3(0.5f, 0f, 0f), radius: 1f), + }; + + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, collision, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: true, partArray: parts); + Assert.True(reg.TryGetRetailCellArray(entityId, out var before)); + var beforeEntries = reg.GetRetailPartEntriesInCell(CellA) + .Where(e => e.EntityId == entityId).Select(e => e.GfxObjId).ToList(); + Assert.Equal(new[] { 0x0100_0070u, 0x0100_0071u }, beforeEntries); + + reg.RefloodLandblock(LbId); + + Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); + Assert.Equal(before, after); + Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId)); + var afterEntries = reg.GetRetailPartEntriesInCell(CellA) + .Where(e => e.EntityId == entityId).Select(e => e.GfxObjId).ToList(); + Assert.Equal(beforeEntries, afterEntries); + // The collision product is untouched by the reflood as well. + Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); + } + + [Fact] + public void RefloodLandblock_KeepsARenderOnlyOwner() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x61u; + IReadOnlyList parts = new[] { Bsp(0x0100_0072u, radius: 2f) }; + + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: true, partArray: parts); + Assert.True(reg.TryGetRetailCellArray(entityId, out _)); + + reg.RefloodLandblock(LbId); + + // Still registered, still render-only, still in its cell: the empty + // collision shape list plus the retained part array is the + // render-only registration, not the "nothing supplied" deregistration. + Assert.True(reg.TryGetRetailCellArray(entityId, out var cells)); + Assert.Equal(new[] { CellA }, cells); + var entries = reg.GetRetailPartEntriesInCell(CellA) + .Where(e => e.EntityId == entityId).ToList(); + Assert.Single(entries); + Assert.Equal(0x0100_0072u, entries[0].GfxObjId); + Assert.Empty(reg.GetOwnerCells(entityId)); + } + + // ------------------------------------------------------------------- + // S2 review fix round (2026-09-03): lifetime symmetry of the retail + // render product. remove_shadows_from_cells (0x00511230) removes the + // CShadowObj row AND the AddPartsShadow rows in one transaction, and + // recurses through children — every acdream path that drops one product + // drops the other. + // ------------------------------------------------------------------- + + private static int CountRows(ShadowObjectRegistry reg, uint cellId, uint entityId) + => reg.GetRetailPartEntriesInCell(cellId).Count(e => e.EntityId == entityId); + + [Fact] + public void Suspend_ClearsTheRetailProduct_AndTheUnsuspendingMoveRepublishesIt() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x70u; + IReadOnlyList parts = new[] { Bsp(0x0100_0080u, radius: 2f) }; + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: false, partArray: parts); + Assert.Equal(1, CountRows(reg, CellA, entityId)); + + Assert.True(reg.Suspend(entityId)); + + Assert.False(reg.TryGetRetailCellArray(entityId, out _)); + Assert.Equal(0, CountRows(reg, CellA, entityId)); + Assert.Empty(reg.GetOwnerCells(entityId)); + // The route and part array are retained for the republish. + Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId)); + + // A .None commit republishes at the RETAINED (suspended) cells for + // both products — the un-suspend path. + reg.CommitSetPosition( + entityId, Pos, Quaternion.Identity, + seedCellId: CellA, worldOffsetX: OffX, worldOffsetY: OffY, + action: PhysicsShadowCommitAction.None, + crossCellIds: ImmutableArray.Empty); + + Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); + Assert.True(reg.TryGetRetailCellArray(entityId, out var cells)); + Assert.Equal(new[] { CellA }, cells); + Assert.Equal(1, CountRows(reg, CellA, entityId)); + } + + [Fact] + public void AttachChild_AndDetachChild_AdvanceTheMutationRevision() + { + var reg = new ShadowObjectRegistry(); + const uint rootId = 0x71u; + const uint childId = 0x72u; + IReadOnlyList rootParts = new[] { Bsp(0x0100_0081u, radius: 2f) }; + reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: false, partArray: rootParts); + + ulong before = reg.MutationRevision; + Assert.True(reg.AttachChild(childId, rootId, new[] { Bsp(0x0100_0082u) })); + Assert.True(reg.MutationRevision > before); + + before = reg.MutationRevision; + Assert.True(reg.DetachChild(childId)); + Assert.True(reg.MutationRevision > before); + } + + [Fact] + public void RegisterMultiPart_OfAnAttachedChild_KeepsInheritingTheRootsCells() + { + var reg = new ShadowObjectRegistry(); + const uint rootId = 0x73u; + const uint childId = 0x74u; + IReadOnlyList rootParts = new[] { Bsp(0x0100_0083u, radius: 2f) }; + reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: false, partArray: rootParts); + Assert.True(reg.AttachChild(childId, rootId, new[] { Bsp(0x0100_0084u) })); + Assert.Equal(1, CountRows(reg, CellA, childId)); + + // An appearance update re-registers the child at its own (different) + // position with a NEW part array: retail never floods a child, so it + // keeps the root's CELLARRAY and only its parts change. + IReadOnlyList newParts = new[] { Bsp(0x0100_0085u, radius: 1f) }; + reg.RegisterMultiPart(childId, new Vector3(42f, 12f, 50f), Quaternion.Identity, newParts, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellB, isStatic: false, partArray: newParts); + + Assert.True(reg.TryGetRetailCellArray(childId, out var childCells)); + Assert.Equal(new[] { CellA }, childCells); + Assert.Equal(0, CountRows(reg, CellB, childId)); + var rows = reg.GetRetailPartEntriesInCell(CellA).Where(e => e.EntityId == childId).ToList(); + Assert.Single(rows); + Assert.Equal(0x0100_0085u, rows[0].GfxObjId); + } + + [Fact] + public void RemoveLandblock_ClearsTheRetailProduct_IncludingRenderOnlyStatics() + { + var reg = new ShadowObjectRegistry(); + const uint collidingId = 0x75u; + const uint renderOnlyId = 0x76u; + IReadOnlyList parts = new[] { Bsp(0x0100_0086u, radius: 2f) }; + reg.RegisterMultiPart(collidingId, Pos, Quaternion.Identity, parts, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: true, partArray: parts); + reg.RegisterMultiPart(renderOnlyId, Pos, Quaternion.Identity, Array.Empty(), + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: true, partArray: parts); + Assert.Equal(1, CountRows(reg, CellA, renderOnlyId)); + + reg.RemoveLandblock(LbId); + + Assert.False(reg.TryGetRetailCellArray(collidingId, out _)); + Assert.False(reg.TryGetRetailCellArray(renderOnlyId, out _)); + Assert.Empty(reg.GetRetailPartEntriesInCell(CellA)); + // Both statics ended with their landblock — nothing left to suspend. + Assert.False(reg.Suspend(collidingId)); + Assert.False(reg.Suspend(renderOnlyId)); + } + + [Fact] + public void RetireOwnerFromLandblock_PrunesTheRetailRowsOfANonRootedOwner() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x77u; + IReadOnlyList parts = new[] { Bsp(0x0100_0087u, radius: 2f) }; + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: false, partArray: parts); + Assert.Equal(1, CountRows(reg, CellA, entityId)); + + reg.RetireOwnerFromLandblock(entityId, LbId); + + Assert.Equal(0, CountRows(reg, CellA, entityId)); + Assert.False(reg.TryGetRetailCellArray(entityId, out _)); + Assert.Empty(reg.GetOwnerCells(entityId)); + // A live owner survives the prefix retirement (it is still logically + // alive for the reload reflood), so it can still be suspended. + Assert.True(reg.Suspend(entityId)); + } + + [Fact] + public void ReplaceMultiPartPayload_WithAnEmptyPartArray_KeepsThePriorRows() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x78u; + IReadOnlyList parts = new[] { Bsp(0x0100_0088u, radius: 2f) }; + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: false, partArray: parts); + + reg.ReplaceMultiPartPayload(entityId, Pos, Quaternion.Identity, parts, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: false, partArray: Array.Empty()); + + // Never a cell-array claim with no drawable rows behind it. + Assert.True(reg.TryGetRetailCellArray(entityId, out var cells)); + Assert.Equal(new[] { CellA }, cells); + Assert.Equal(1, CountRows(reg, CellA, entityId)); + } + + [Fact] + public void CommitSetPosition_None_MovesARenderOnlyOwnerToItsDestinationCell() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x79u; + IReadOnlyList parts = new[] { Bsp(0x0100_0089u, radius: 1f) }; + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: false, partArray: parts); + Assert.Equal(1, CountRows(reg, CellA, entityId)); + + // A render-only owner has no collision cells to republish from, so + // its "cell did not change" commit would otherwise freeze it at its + // registration cells forever (retail review F3): it travels with its + // destination cell instead (AD-117). + reg.CommitSetPosition( + entityId, new Vector3(42f, 12f, 50f), Quaternion.Identity, + seedCellId: CellB, worldOffsetX: OffX, worldOffsetY: OffY, + action: PhysicsShadowCommitAction.None, + crossCellIds: ImmutableArray.Empty); + + Assert.True(reg.TryGetRetailCellArray(entityId, out var cells)); + Assert.Equal(new[] { CellB }, cells); + Assert.Equal(0, CountRows(reg, CellA, entityId)); + Assert.Equal(1, CountRows(reg, CellB, entityId)); + Assert.Empty(reg.GetOwnerCells(entityId)); + } }