diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 519a5d1f..fa262dd4 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,31 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #335 — The INDOOR half of retail's part-array `find_transit_cells` is not ported: an EnvCell neighbour is admitted on a SPHERE test where retail uses a BOX + +**Status:** OPEN +**Severity:** low. Over-inclusive only — extra broadphase candidates indoors, never a missed one. The opposite direction (the outdoor half) was #334 and is closed. +**Filed:** 2026-08-06, at the #334 fix. +**Component:** physics / cell membership +**Register row:** AP-159. + +#334 ported `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 and the OUTDOOR arm of the part-array `find_transit_cells` it dispatches to (`CLandCell::find_transit_cells` @0x00533840 → `add_all_outside_cells` @0x00533360 → `add_cell_block` @0x005331d0). The INDOOR arm of that same dispatch is still acdream's sphere traversal. + +**What retail does** (`CEnvCell::find_transit_cells` @0x0052cae0, disassembled from the PDB-paired 2013-09-06 binary), per portal × per part: + +1. cheap reject: the part's `CGfxObj::physics_sphere` centre through `Position::localtolocal` (`0x0052cb5a`), tested against the portal plane with `eps = F_EPSILON + radius` (`0x0052cb65`); +2. on pass, the ADMITTING test is box-vs-plane: `CPhysicsPart::GetBoundingBox` @0x0050d600 (`0x0052cbdd`) → `BBox::LocalToLocal` @0x005b1e60 (`0x0052cbf9`) → `Plane::intersect_box` @0x005aa170 (`0x0052cc05`); +3. if the side differs from `portal_side`: `other_cell_id == 0xFFFFFFFF` (`0x0052cc1e`) sets the “leads outside” flag; otherwise `CCellPortal::GetOtherCell` @0x0053ba30 (`0x0052cc2b`) — a THISCALL on the portal record (`ecx` set at `0x0052cc18`) taking ONE explicit argument, `cellarray->do_not_load_cells` (`0x0052cc27 mov eax,[edi+4]`; the CELLARRAY layout is +0 `added_outside`, +4 `do_not_load_cells`, +8 `num_cells`, +0xc `cells`, cross-checked against `find_bbox_cell_list`'s `0x00510fc8`/`0x00510fcf` zeroing and `add_all_outside_cells`' `0x0053336c` read of `[arg3]`). That RESOLVES the #334 contract's open question 11.4 in the AFFIRMATIVE — the flag IS threaded through, as the single explicit argument, not omitted. Then `BBox::LocalToLocal` into the destination and `CCellStruct::box_intersects_cell` @0x00533910 → `BSPTREE` @0x0053c880 gates the add (`0x0052cc5a`); +4. after all portals, the outside flag runs `add_all_outside_cells` (`0x0052ccea`). + +**What acdream does:** `CellTransit.BuildShadowCellSetFromParts`'s indoor arm calls `FindTransitCellsSphere` with the per-part BSP root spheres (`ShadowObjectRegistry.BuildBspPartSpheres`), i.e. step 1's cheap reject used as the admitting test. Same for the outdoor building bridge (`CEnvCell::check_building_transit` @0x0052c5d0). + +**Why it was deferred rather than folded into #334:** closing it needs a BOX traversal of the containment BSP in BOTH the graph (`BSPQuery`) and the production flat (`FlatBspQuery`) representations, plus their exact referee — a separately gateable change with no bearing on #334's outdoor defect, and one that no #334 gate would exercise. Adding ~150 lines of unverified geometry under a green-but-uncovering test is the failure mode this campaign has now hit ten times. + +**Files:** `src/AcDream.Core/Physics/CellTransit.cs` (`BuildShadowCellSetFromParts` indoor arm, `FindTransitCellsSphere`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildBspPartSpheres`). + +--- + ## #333 — The shadow broadphase reach filter measures from the PART ORIGIN, so an off-centre BSP part can be in the right cell and still never be tested **Status:** OPEN @@ -1741,6 +1766,28 @@ it. Do #297 FIRST — #298 depends on it. the radar, same defect class, same fix shape (mirror the property into the bitfield/snapshot at its source). Filed from the #297 delta review. +- **#336 — OPEN — `RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate` is a FOURTH, load-sensitive flake — distinct from #302, #308 and #321. LOW.** + `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs:2381` asserts + `GC.GetAllocatedBytesForCurrentThread()` is EXACTLY 0 across a warmed 10,000-iteration + steady-contact refresh loop. Observed failing once on 2026-08-06 inside a full-solution + `dotnet test AcDream.slnx -c Release -m:1` run (measured 2,944 bytes), then passing on the + immediate full-suite retry, on both of two isolated `AcDream.Runtime.Tests` project runs, + and on a filtered single-test run. + **Filed rather than absorbed, and deliberately NOT conflated with the other three.** + It shares #302's MECHANISM (an exact `GC.GetAllocatedBytesForCurrentThread()` assertion, + sensitive to JIT tiering and background GC on the measuring thread) but it is a different + test in a different assembly — #302 is `AcDream.App.Tests`, this is + `AcDream.Runtime.Tests` — so “the known allocation flake” would hide whichever of the two + is real on any given run. #308 is a wall-clock deadline in `AcDream.Core.Net.Tests`; + #321 is a concurrent-decode dedup in the sound cache. Four mechanisms, four rows. + **Not caused by #334's cell-membership port,** which is the change in flight when it was + seen: the measured loop calls only `RuntimeCollisionReportingState` handling, registers no + shadow inside the measurement, and touches none of `CellTransit` / `ShadowObjectRegistry` / + `ShadowShape`. Fix shape, same as #302: warm the path before measuring, or assert a bounded + range rather than an exact zero — matching how the other allocation gates in the repo are + written. Do not delete the assertion; the 0 B/resolve budget it guards is a real Slice I1 + invariant. + - **#302 — OPEN — `PortalProjectionTests.ClipToRegion_FrameOwnedStore_ReusesExactResultArray` is flaky. LOW.** Measured 1 failure in 6 consecutive isolated runs of `AcDream.App.Tests` at `88348f67`, and once in a full-suite run that passed on @@ -10556,7 +10603,19 @@ missing is the plugin-API surface. ## #334 — Large static formations lose collision at their boundaries (Neftet) -**Status:** OPEN +**Status:** DONE (2026-08-06) — retail's `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 path is ported. A physics-BSP object's outdoor cell membership is now the FILLED land-cell rectangle its authored `CGfxObj::gfx_bound_box` spans, crossing landblock boundaries freely, instead of the fixed 3×3 sphere neighbourhood. Awaiting the user's live gate at the same Neftet formations. + +**Fix.** `CellTransit.BuildShadowCellSetFromParts` + `CellTransit.AddAllOutsideCellsFromParts` (`CLandCell::add_all_outside_cells` @0x00533360 + `add_cell_block` @0x005331d0, disassembled from the PDB-paired 2013-09-06 binary — Binary Ninja mis-renders four separate constructs inside that one function); `ShadowPartGeometry` / `ShadowPartBox` carry the BSP root sphere AND the authored box as one value; `ShadowObjectRegistry.RegisterMultiPart` dispatches on `HAS_PHYSICS_BSP_PS` exactly as retail does at `0x00515285`, and `BuildFloodSpheres`' BSP arm is deleted rather than left unreachable. Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read “extra broadphase candidates, never a missed one” — #334 is a missed one); AP-159 (indoor part-array overload, issue #335) and AD-49 (seed-time rectangle) filed. + +**Cost, measured over the installed DATs BEFORE any code was written** (1,258 physics-BSP GfxObjs with vertices): cells/object p50 = 4, p90 = 4, p99 = 12, max 49 (7×7). The port is CHEAPER than the old 3×3 = 9 for 98.97% of them — the crossover is exact, any object under 24 m of XY extent yields at most 2×2. Row totals (`shapes × cells`) over all 1,031 landblocks carrying BSP owners fall from 97,173 to 15,607 (0.161×); dense Arwic 0xC6A9 falls 342 → 43 (0.126×). Exactly ONE landblock more than doubles (0x8964, 45 → 112 rows, 2.489×). The worst single-owner rectangle in the whole world is 81 cells (9×9) in 0x8766 — above the 7×7 bound predicted from root-sphere statistics, because that bound assumed the BSP root sphere bounds the whole vertex array and it bounds only the physics polygons' subset. + +**Precondition confirmed before any expected cell set was pinned:** `0x010046D8`'s authored box is 96 m × 96 m about a part origin at block-local (63.78, 56.29) — cell (2,2) = `0x87640013`, which independently corroborates the 3×3-centred-at-`0x87640013` diagnosis derived from the live probe. Its rectangle spans cell columns 0..4 on both axes and DOES contain `0x87640011` and `0x87640019`, the two cells the probe measured empty. + +**Gate note (AP-158 / #333):** the fix is necessary and not sufficient in general. A player at the far corner of a large new rectangle can still be discarded by the broadphase reach filter, which measures from the part origin. The live gate FAILS if `inCell` rises while `rejectedReach` rises with it; the remedy for that is #333, not a wider budget here. + +Original finding below. + +**Status (at filing):** OPEN **Severity:** HIGH — walk-through and fall-through on world geometry. **Filed:** 2026-08-06, user-reported in live play. **Component:** physics / collision / broadphase diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0d49d97f..c1b2bc6c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 48 active rows (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) +## 2. Adaptation (AD) — 49 active rows (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 visible-cell availability, full-catalog containment-root validation, and the @@ -111,6 +111,7 @@ readiness/requeue adaptation. See | AD-46 | **LIVE. Reframed at Campaign V slice V11 (2026-07-29), when GL was deleted and the comparison that discovered this row ceased to exist.** Dense alpha-blended distant scenery (the treeline) may read slightly denser than retail's, because the anisotropic TAP PATTERN is implementation-defined and acdream's Vulkan driver does not tap identically to retail's D3D9 one. Both request the same sampler state — trilinear, clamp-and-repeat, the device's maximum anisotropy. **What changed at V11 is only the left-hand side of the comparison**: this was measured GL-vs-Vulkan (~15% of the pixels in the band), and it is now a Vulkan-vs-retail question against the D3D oracle in the last column. The measurement below is retained as the evidence that the residual is a tap pattern and not a bug, even though one of its two arms no longer exists. | `src/AcDream.App/Rendering/Wb/WorldTextureArray.cs` (`RhiWorldTextureArray.WorldArrayAnisotropy`); measured in plan §5.5.19, reframed §5.5.24 | Not assumed — narrowed by measurement while both backends still existed, on an offline capture with no session, no entities and both clocks pinned. Anisotropy 1 → 41,509 differing pixels in the tree band; anisotropy 16 (GL's value, and retail's `m_D3DCaps.MaxAnisotropy`) → 22,266, and the rest of the frame fell to 497 px of 563,200, i.e. 8.8e-04, inside the campaign's 0.001 threshold. The residual was not a sub-pixel shift (an integer shift search found none), not a sharpness change (high-frequency energy matched within 5%), and not depth precision (forcing Vulkan's window-depth range to GL's compressed [0.5, 1] moved it by 3%). Monotone improvement toward GL's own anisotropy with no knob left is what made it a driver property rather than a bug. | Distant foliage shimmers or reads denser than retail's. The class is confined to alpha-blended dense overlap: opaque terrain, roofs, walls, water, statics, the character and the whole retained UI are inside threshold. **Now unfalsifiable by self-differential** — with GL gone, the only way to retire this row is a side-by-side against the retail client, not against another acdream backend. | `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`, whose `SetSamplerState(stage, 0xA /* D3DSAMP_MAXANISOTROPY */, m_D3DCaps.MaxAnisotropy)` at `0x005a4230` is the value acdream requests | | AD-47 | **Filed at Campaign V slice V11 (2026-07-29); the campaign's risk register scheduled this row here.** Multisample resolve sample POSITIONS are unspecified by both the Vulkan and D3D9 specifications, so acdream's MSAA-on silhouette edges do not match retail's pixel-for-pixel even at the same sample count. acdream's strict pixel gates therefore run with MSAA forced OFF on every arm, and MSAA-on gets only a relaxed visual smoke. | `src/AcDream.App/RuntimeOptions.cs` (`ACDREAM_MSAA_SAMPLES`); forced to 0 in `tools/run-offline-pixel-gate.ps1` | Measured, not assumed: plan §5.5.16 compared two backends at 4x and found **8.83% of the frame differing — 81,359 px of 921,600 — essentially all of it hugging foliage and silhouette edges**, which is ninety-fold over the 0.001 gate threshold. That is two implementations' sample patterns, not a renderer divergence, which is why forcing MSAA off is what makes the remaining difference attributable rather than a threshold relaxation. | Edge quality on thin geometry (fence rails, foliage, distant railings) differs from retail at the sub-pixel level whenever MSAA is on, which is the ordinary player configuration. Because the gates run MSAA off, **a real regression confined to the multisample path would not be caught by them** — that is the actual exposure this row records. | D3D9 `D3DRS_MULTISAMPLEANTIALIAS` / `D3DMULTISAMPLE_TYPE` as set by `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`; retail's sample pattern is the driver's, exactly as ours is | | AD-48 | **Filed at Campaign V slice V11 (2026-07-29).** Presentation is paced by the Vulkan swapchain present mode (FIFO, i.e. VSync) or by a refresh-rate software pacer when uncapped, rather than by retail's D3D9 `Present` with its own frame-rate limiter. Frame delivery cadence, and therefore input-to-photon latency, is a property of our present path rather than a port of retail's. | `src/AcDream.App/RuntimeOptions.cs:98-100`; `src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchain.cs` | Retail's limiter and ours both bound the frame rate to the display; the simulation is fixed-step and clock-driven, so gameplay timing does not ride on presentation cadence. The uncapped path exists for measurement and is not the shipping default. | A pacing mismatch shows up as judder or input latency that differs from retail's feel without any visual difference in a captured frame — invisible to every pixel gate by construction. Issue **#235** (the capped/RDP jump-presentation cadence alias) is the known live instance of this class. | D3D9 `IDirect3DDevice9::Present`; retail's frame limiter in `RenderDeviceD3D` | +| AD-49 | **Filed 2026-08-06 at the #334 fix.** `CellTransit.BuildShadowCellSetFromParts` runs the outdoor cell rectangle AT SEED TIME for an outdoor seed, then gates only the growing-array WALK on cell residency. Retail's `find_bbox_cell_list` @0x00510fc0 gates everything on `obj->cell` (`0x00510fed test eax,eax` / `je 0x511020`), reaching `add_all_outside_cells` only from the walk. Retail can: a placed `CPhysicsObj` always holds a resident `CObjCell`. acdream's `CellGraph` residency is transiently false during landblock streaming (the #168 / #169 residence-race family), so deferring the rectangle to the walk would drop a landblock static or a live entity to a SINGLE cell for the window before its landblock publishes. This is the same residency policy `BuildShadowCellSet` already applies to its outdoor seed - retail's `CObjCell::find_cell_list` calls `add_all_outside_cells` at `0x0052b53f`, ahead of the `arg4` walk gate at `0x0052b576` - so the two registration floods differ only in sphere-vs-box, which is the whole of #334. | `src/AcDream.Core/Physics/CellTransit.cs` (`BuildShadowCellSetFromParts` seed block) | Keeps the sphere and box floods on ONE residency rule, so a future streaming-race fix has one place to change rather than two that disagree. The alternative - retail's literal shape - would introduce a new transient under-inclusive window, which is the #98 / #168 direction. | Over-inclusive only: an object whose landblock is not yet resident registers its full rectangle immediately instead of after the reflood (`ShadowObjectRegistry.RefloodOwnerForLandblock`, driven by `LandblockPhysicsContentBuilder.PublishStaticCollision`'s tail). The rows are correct the moment the cells exist; nothing is registered that the box does not span. | `CPhysicsObj::find_bbox_cell_list` 0x00510fc0 (0x00510fe2 / 0x00510fed); `CObjCell::find_cell_list` 0x0052b4e0 (0x0052b53f / 0x0052b576) | | AD-50 | **Filed at Campaign N slice N2 (2026-07-29).** The inbound sequence tracker's watermark (`highestIDReceived_`) initializes to **1**, not retail's zero-init of `ReceiverData`. Watermark INIT only — every mechanism (sanity window, duplicate/parked-key path, gap walk, re-park, RejectRetransmit abandonment) is the verbatim retail port. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`AceInitialWatermark`) | ACE never emits S2C sequence 1: its `PacketSequence` starts unprimed at `uint.MaxValue`, the cleartext ConnectRequest takes NextValue 0, and the first ENCRYPTED flush re-primes CurrentValue to 1 so the first encrypted sequenced packet is 2 (ACE NetworkSession.cs:716-717 + Sequence/UIntSequence.cs:9-13,30-41; pinned by the N0 double and the N2 clean-lifecycle conformance test asserting min encrypted S2C sequence == 2 with zero NAKs). A zero-init watermark would gap-walk the permanent id-1 hole: one spurious NAK, the first pre-drawn word mis-assigned to id 1, and the keystream off by one from the very first encrypted packet. holtburger seeds the same value (crates/holtburger-session/src/session/api.rs:30, `last_server_seq: 1`), mirroring ACE's own C2S-side `lastReceivedPacketSequence = 1` (NetworkSession.cs:57). | Against a hypothetical server that DOES emit sequence 1 as its first encrypted packet (retail's own numbering), init-1 would classify it "not newer" and drop it as a duplicate — the mirror-image wedge. Only ACE-family servers exist for this client today. | `ReceiverData` zero-init (construction inside `SharedNet`; `highestIDReceived_` starts 0); `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the walk that would mis-NAK id 1) | | AD-51 | **Filed at Campaign N slice N4 (2026-07-29).** The inbound sequence tracker keeps a reclaimed-word pool (per-parked-word draw ordinals + `PriorityQueue` consumed lowest-draw-order-first) that retail has no counterpart for: on a VALIDATED cleartext `RejectRetransmit`, the word the gap walk parked for the reject packet's OWN sequence is removed, every later-drawn parked word is shifted down one position, and the excess word feeds the next fresh draws. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`OnCleartextRejectSequence`, `NextWord`, `ParkedWord`); trigger at `src/AcDream.Core.Net/WorldSession.cs` (RejectRetransmit consumption) | Retail's inbound invariant is "every missing id was an encrypted packet whose keystream word the server drew" — true against retail servers, whose cleartext packets always borrow live sequences (acks/NAKs reuse `highestIDSent_`; `FlowQueue::TransmitNewPackets @ 0x00547A60` sequences only reliable packets). ACE breaks it in exactly one place: `RejectRetransmit` takes a FRESH sequence through FlushPackets, cleartext, drawing NO S2C keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748). Without the reclaim, our gap walk pre-draws a word for that id, the inbound stream runs permanently one word ahead, and every later encrypted packet fails checksum — the N2 desync class reintroduced through the reject path. The pool is provably empty against a retail server, so retail behavior is untouched. Reject BODY ids keep the N2 discard (their words were drawn on both sides — consumed-in-place). Known unreachable corner: a reject whose own id later appears inside another reject's body (first reject pruned after 120 s of sustained loss with the session alive) would discard a never-drawn word; probabilistically impossible against ACE's 60 s silence timeout and the 0.6 s NAK cadence. | Against a hypothetical non-ACE server that assigns fresh cleartext sequences to packets OTHER than RejectRetransmit, those ids would still mis-park with no reclaim trigger — inbound desync. Only ACE-family servers exist for this client today, and ACE has exactly the one path. | `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the gap walk whose invariant ACE breaks); `SharedNet::HandleEmptyAck @ 0x005448F0` (retail's reject consumption — body ids only, no own-sequence machinery because retail never needs it) | | AD-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks | @@ -162,7 +163,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 110 active rows (AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 111 active rows (AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -183,9 +184,10 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-153 | **Filed 2026-08-06 at the AP-152 retirement — a modelling difference the fix itself introduces.** Retail's shape-dispatch flag is CACHED ONCE. `CPartArray::CacheHasPhysicsBSP` @0x00518110 walks the part array, ORs 0x10000 into `CPartArray::pa_state` on the first part whose `gfxobj->physics_bsp` is non-null, and `CPhysicsObj::CacheHasPhysicsBSP` @0x0050f570 mirrors it onto `CPhysicsObj::state+0xa8`. A full `.text` scan for direct call/jmp to 0x0050f570 finds EXACTLY ONE caller, `CPhysicsObj::InitPartArrayObject+0x7e` @0x0051272e — so after an `AnimPartChanged` part swap retail's DISPATCH flag is stale while its per-part test (`CPhysicsPart::find_obj_collisions` @0x0050d8d0) stays live. acdream's step-0 gate is LIVE in both: it re-derives from the effective part identities on every `FromSetup` call. | `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 0); `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (`ReconcileAppearance`) | The two disagree only when a swap adds or removes the LAST physics-BSP part. Humanoid part swaps (clothing / armour) involve no physics-BSP GfxObj on either side, so this is unreachable against ACE today. Deliberately NOT modelled with cached state — that would be inventing staleness to reproduce a retail bug. | If a server ever swapped a prop's part array across the physics-BSP boundary, acdream would switch its collision geometry on the swap where retail would keep dispatching on the construction-time flag: a prop that gained a BSP part would lose its primitive immediately in acdream and only on re-init in retail. | `CPartArray::CacheHasPhysicsBSP` 0x00518110; `CPhysicsObj::CacheHasPhysicsBSP` 0x0050f570; sole caller `CPhysicsObj::InitPartArrayObject+0x7e` 0x0051272e | | AP-154 | **Filed 2026-08-06 at the AP-152 retirement (contract §11.6) — an undeclared dependency on a specific server implementation.** Retail COMPUTES `HAS_PHYSICS_BSP_PS` itself from its own part array (AP-153's anchors). acdream's query-time guard `Transition.BspOnlyDispatch` reads it out of the SERVER's wire `PhysicsState`: `LiveEntityCollisionBuilder.cs:161` copies `exactRecord.FinalPhysicsState` into `ShadowEntry.State`, and a repo-wide grep for `PhysicsStateFlags.HasPhysicsBsp` in `src/` returns only that predicate and one unrelated mover-state read. acdream never ORs the bit in client-side. It happens to be correct because ACE derives the same DAT bit (`WorldObject_Networking.cs:665-668` from `SetupFlags.HasPhysicsBSP`), overriding the weenie's authored value — which is why a 2018 weenie dump showing `PhysicsState = 0x8` for the cottage door does not contradict our own live capture of `0x10008`. | `src/AcDream.Core/Physics/TransitionTypes.cs:1348` (`BspOnlyDispatch`), call sites `:3911` / `:3954`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:161` | Narrowed, not closed, by the AP-152 fix: the shape list no longer contains a primitive for a BSP-bearing object, so the guard has nothing left to skip and the OUTCOME is now independent of the wire. The guard itself still keys on the wire. Not bundled — changing `registration.State` touches every consumer of `FinalPhysicsState` (Hidden, Missile, ethereal layer 2, the `[setstate]` log) and needs its own gate. | Against a server that does not derive the bit from the DAT, a BSP-bearing object built by a producer other than `FromSetup` would have its primitive tested where retail tests only the BSP. | `CPartArray::CacheHasPhysicsBSP` 0x00518110 (derives) vs `LiveEntityCollisionBuilder.cs:161` (copies); `HAS_PHYSICS_BSP_PS` acclient.h:2833 | | AP-155 | **Filed 2026-08-06 at the AP-152 retirement; NARROWED 2026-08-06 to its static-publication half alone.** Its flood half was bundled here with a different code path, a different population and a different gate — the exact fault the C4 handoff warns about — and its direction was recorded BACKWARDS; both are now split out as AP-156. **Static paths emit a Setup Sphere as a height-capped CYLINDER.** `LandblockPhysicsPublisher.cs:1030-1037` and `LandblockPhysicsContentBuilder.cs:683-690` both convert a Setup Sphere to `ShadowCollisionType.Cylinder` with `CylHeight = radius * 2f` and the origin shifted down by one radius; the live path emits a true `ShadowCollisionType.Sphere`, produced at exactly ONE site in `src/` (`ShadowShapeBuilder.cs`). Retail tests a Setup Sphere with `CSphere::intersects_sphere` @0x00537a80 / @0x00537fd0 (two overloads) in both cases — 3-D distance, no height clamp. The static paths also derive "has BSP" from `entity.MeshRefs` (the render mesh list) where the live path derives it from `setup.Parts` plus the effective post-`AnimPartChanged` identities; the two sources can disagree. | `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:1030-1037`; `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:683-690` | Affects static props only and changes their collision geometry over a much larger population than AP-152's 172, so it needs its own count and its own gate. Deliberately not folded into the AP-152 or AP-156 commits. | A static prop whose Setup carries a Sphere blocks over a height-clamped cylinder instead of a true sphere, and rests one radius lower than the authored origin. | `CSphere::intersects_sphere` 0x00537a80 / 0x00537fd0 | -| AP-156 | **Filed 2026-08-06, split out of AP-155(b) at the AP-152 retail-conformance review, WITH ITS DIRECTION CORRECTED — and its worst half FIXED in the same commit.** **CORRECTION.** AP-155(b) recorded the flood approximation as *over*-inclusive ("a sphere contains the box's inscribed extent but is larger in the diagonal"), and that recorded direction was the stated reason the residual was safe to defer. It was empirically inverted. `BuildFloodSpheres` took each physics-BSP part's ROOT BOUNDING SPHERE RADIUS (`FlatCollisionAssetBuilder.cs:393` -> `LiveEntityCollisionBuilder.cs:137`) and centred it on the PART ORIGIN (`ShadowShapeBuilder.cs:194`), discarding the root sphere's own `Origin`. Measured over the installed `client_portal.dat`, independently twice: 376 of 973 physics-BSP parts have `|origin| > radius/2`, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD, Setup 0x0200129A); **POPULATION CORRECTED 2026-08-06 at the fix review (finding R2).** The row as filed said the flood failed to contain the object's own BSP sphere for '170 of the 172 AP-152 Setups'. That understates it: 172 is AP-152's DISPATCH population (Setups carrying BOTH a primitive and a physics-BSP part). After AP-152 EVERY BSP-bearing Setup floods from its BSP shapes alone, so the discarded origin mis-placed the flood across all 530 of them. Re-measured against PHYSICS-POLYGON VERTICES — a different DAT field from the sphere, so the measurement is not circular — by an independent scratch program outside the repo: **525 of the 530** BSP-bearing Setups have at least one flood sphere move; **428** fail vertex-level containment at a 1 mm tolerance (412 at 1 cm, the figure the fix review quotes); **0** fail after the fix, at any tolerance down to zero. Worst shortfall 35.869 m at entity scale 1.75 on Setup 0x0200129A. The old figures — 170 of 172, worst 9.911 m on 0x02000255 — remain correct for what they measured (root-sphere containment over the 172), and 43 of them had a post-AP-152 flood strictly SMALLER than the pre-AP-152 one. Indoor floods are 3-D (`CellTransit.cs:601` routes every `id & 0xFFFF >= 0x0100` candidate through `FindTransitCellsSphere`), so a tall prop or door slab was simply absent from EnvCells it occupies and never a broadphase candidate there — UNDER-inclusive membership, the #98 / #168 class. **FIXED HERE.** `ShadowShape.BoundsCenter` carries the root sphere's own centre in the shape's local frame; `FromSetup` and `FromLandblockBspParts` fill it from the SAME resolver that supplies the radius, and `BuildFloodSpheres` places the sphere at `partWorldPos + rotate(BoundsCenter, partWorldRot)`. Retail does exactly this: `CGfxObj::physics_sphere` (`[gfxobj+0x74]`) is assigned `BSPTREE::GetSphere(physics_bsp)` @0x005397e0 (`mov eax,[ecx]; add eax,4` — the root `BSPNODE`'s `CSphere`, past its 4-byte vftable), and `CEnvCell::find_transit_cells` @0x0052cae0 — the part-array overload reached from `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 through `CPartArray::calc_cross_cells_static` @0x00518160's `[vtbl+0x7c]` dispatch — loads it at `0x0052cb36 mov esi,[ecx+0x74]`, transforms its CENTRE through the part's own `Position` at `[part+0x30]` (`0x0052cb4c add eax,0x30` / `0x0052cb5a call Position::localtolocal`), and only then reads the radius at `0x0052cb65 fadd [esi+0xc]`. The same commit also retired the 10-sphere clamp on this branch: retail's clamp lives inside the CYLSPHERE overload alone (`CObjCell::find_cell_list` @0x0052b9f0, `0x0052ba21 cmp eax,0xa` / `0x0052ba28 mov ebp,0xa`) while the BSP walk has none — 7 installed Setups carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their tail parts were dropped from the flood entirely. **WHAT REMAINS OPEN.** acdream floods from the per-part spheres through its own sphere-vs-portal walk (`CellTransit.FindTransitCellsSphere`), where retail hands the part array to each cell's own `find_transit_cells` and tests every part's sphere against that cell's portal planes in cell-local space. The sphere SET is now exact; the TRAVERSAL is still acdream's. `find_bbox_cell_list`'s name notwithstanding, retail never forms a bounding box — AP-155(b)'s "acdream approximates retail's bounding BOX" was wrong as well. **SECOND RESIDUAL, added 2026-08-06 at the fix review (finding R4): acdream SCALES the flood sphere; retail does not.** `ShadowShapeBuilder` multiplies both the radius and (new in this commit) the centre by the entity/part scale. Retail's `CEnvCell::find_transit_cells` @0x0052cae0 reads only `CPhysicsPart::pos` (`[part+0x30]`) and never `CPhysicsPart::gfxobj_scale` (`[part+0x24]`), while `CPhysicsPart::find_obj_collisions` @0x0050d8d0 DOES thread `gfxobj_scale.z` into `SPHEREPATH::cache_localspace_sphere` — so retail's cross-cell walk is itself under-inclusive for scaled parts and acdream's is not. Over-inclusive for scale > 1 (safe), under-inclusive for scale < 1 (the #98/#168 direction). **ENFORCEMENT, added 2026-08-06 at the fix review (finding A1).** The invariant now lives at the TYPE, not only at the producer seam: `ShadowShape`'s constructor is private and BSP shapes are built only through `ShadowShape.Bsp(..., FlatCollisionSphere localBounds)`, which takes radius and centre as ONE value and scales them together. The former public 7-argument constructor with `BoundsCenter = default` let a future BSP producer reintroduce this exact bug silently and green. **CONNECTED-GATE NOTE (finding A2). A null result on tall props is EXPECTED until AP-158 / #333 lands, and is not evidence against this fix.** The geometry now lands in the right cell and is then discarded one layer down by acdream's own `maxReach` broadphase filter, which measures from the same part origin: 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above that filter's roughly 2.5 m walking budget, and 46 above 5 m. | `src/AcDream.Core/Physics/ShadowShape.cs` (`BoundsCenter`); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 3, `FromLandblockBspParts`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`); `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (single bounds resolver); tests `ShadowObjectRegistryMultiPartTests.BuildFloodSpheres_BspShape_CentresOnTheBoundsCentreNotThePartOrigin` / `_RotatesTheBoundsCentreByThePartRotation` / `_CapsCylSpheresAtTenButNeverTheBspParts`, `ShadowRegistrationOverflowTests.FromLandblockBspParts_CarriesTheScaledRootSphereCentre`, `InstalledSetupBspPrimitiveDispatchTests.InstalledSetups_BspFloodSpheres_ContainTheirOwnPhysicsPolygons` (oracle swapped to physics-polygon vertices at the fix review, finding R1: the shipped assertion compared two hand-copies of the same expression and was algebraically identically zero for any DAT input) | The traversal residual is a genuine approximation with its own gate, not a deferral of this fix. Closing it means porting the per-cell `find_transit_cells` part-array overload, which is different work from getting the sphere set right. | A cell whose portal geometry a part's sphere overlaps in the sphere-vs-plane sense, but which the part's actual polygons do not reach, joins the object's shadow set: extra broadphase candidates, never a missed one. The under-inclusive direction is what the fix above removed. | `BSPTREE::GetSphere` 0x005397e0; `CGfxObj::physics_sphere` `[gfxobj+0x74]`; `CEnvCell::find_transit_cells` 0x0052cae0 (0x0052cb36 / 0x0052cb4c / 0x0052cb65); `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPartArray::calc_cross_cells_static` 0x00518160; `CObjCell::find_cell_list` 0x0052b9f0 (0x0052ba21) | +| AP-156 | **Filed 2026-08-06, split out of AP-155(b) at the AP-152 retail-conformance review, WITH ITS DIRECTION CORRECTED — and its worst half FIXED in the same commit.** **CORRECTION.** AP-155(b) recorded the flood approximation as *over*-inclusive ("a sphere contains the box's inscribed extent but is larger in the diagonal"), and that recorded direction was the stated reason the residual was safe to defer. It was empirically inverted. `BuildFloodSpheres` took each physics-BSP part's ROOT BOUNDING SPHERE RADIUS (`FlatCollisionAssetBuilder.cs:393` -> `LiveEntityCollisionBuilder.cs:137`) and centred it on the PART ORIGIN (`ShadowShapeBuilder.cs:194`), discarding the root sphere's own `Origin`. Measured over the installed `client_portal.dat`, independently twice: 376 of 973 physics-BSP parts have `|origin| > radius/2`, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD, Setup 0x0200129A); **POPULATION CORRECTED 2026-08-06 at the fix review (finding R2).** The row as filed said the flood failed to contain the object's own BSP sphere for '170 of the 172 AP-152 Setups'. That understates it: 172 is AP-152's DISPATCH population (Setups carrying BOTH a primitive and a physics-BSP part). After AP-152 EVERY BSP-bearing Setup floods from its BSP shapes alone, so the discarded origin mis-placed the flood across all 530 of them. Re-measured against PHYSICS-POLYGON VERTICES — a different DAT field from the sphere, so the measurement is not circular — by an independent scratch program outside the repo: **525 of the 530** BSP-bearing Setups have at least one flood sphere move; **428** fail vertex-level containment at a 1 mm tolerance (412 at 1 cm, the figure the fix review quotes); **0** fail after the fix, at any tolerance down to zero. Worst shortfall 35.869 m at entity scale 1.75 on Setup 0x0200129A. The old figures — 170 of 172, worst 9.911 m on 0x02000255 — remain correct for what they measured (root-sphere containment over the 172), and 43 of them had a post-AP-152 flood strictly SMALLER than the pre-AP-152 one. Indoor floods are 3-D (`CellTransit.cs:601` routes every `id & 0xFFFF >= 0x0100` candidate through `FindTransitCellsSphere`), so a tall prop or door slab was simply absent from EnvCells it occupies and never a broadphase candidate there — UNDER-inclusive membership, the #98 / #168 class. **FIXED HERE.** `ShadowShape.BoundsCenter` carries the root sphere's own centre in the shape's local frame; `FromSetup` and `FromLandblockBspParts` fill it from the SAME resolver that supplies the radius, and `BuildFloodSpheres` places the sphere at `partWorldPos + rotate(BoundsCenter, partWorldRot)`. Retail does exactly this: `CGfxObj::physics_sphere` (`[gfxobj+0x74]`) is assigned `BSPTREE::GetSphere(physics_bsp)` @0x005397e0 (`mov eax,[ecx]; add eax,4` — the root `BSPNODE`'s `CSphere`, past its 4-byte vftable), and `CEnvCell::find_transit_cells` @0x0052cae0 — the part-array overload reached from `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 through `CPartArray::calc_cross_cells_static` @0x00518160's `[vtbl+0x7c]` dispatch — loads it at `0x0052cb36 mov esi,[ecx+0x74]`, transforms its CENTRE through the part's own `Position` at `[part+0x30]` (`0x0052cb4c add eax,0x30` / `0x0052cb5a call Position::localtolocal`), and only then reads the radius at `0x0052cb65 fadd [esi+0xc]`. The same commit also retired the 10-sphere clamp on this branch: retail's clamp lives inside the CYLSPHERE overload alone (`CObjCell::find_cell_list` @0x0052b9f0, `0x0052ba21 cmp eax,0xa` / `0x0052ba28 mov ebp,0xa`) while the BSP walk has none — 7 installed Setups carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their tail parts were dropped from the flood entirely. **WHAT REMAINS OPEN.** acdream floods from the per-part spheres through its own sphere-vs-portal walk (`CellTransit.FindTransitCellsSphere`), where retail hands the part array to each cell's own `find_transit_cells` and tests every part's sphere against that cell's portal planes in cell-local space. The sphere SET is now exact; the TRAVERSAL is still acdream's. `find_bbox_cell_list`'s name notwithstanding, retail never forms a bounding box — AP-155(b)'s "acdream approximates retail's bounding BOX" was wrong as well. **SECOND RESIDUAL, added 2026-08-06 at the fix review (finding R4): acdream SCALES the flood sphere; retail does not.** `ShadowShapeBuilder` multiplies both the radius and (new in this commit) the centre by the entity/part scale. Retail's `CEnvCell::find_transit_cells` @0x0052cae0 reads only `CPhysicsPart::pos` (`[part+0x30]`) and never `CPhysicsPart::gfxobj_scale` (`[part+0x24]`), while `CPhysicsPart::find_obj_collisions` @0x0050d8d0 DOES thread `gfxobj_scale.z` into `SPHEREPATH::cache_localspace_sphere` — so retail's cross-cell walk is itself under-inclusive for scaled parts and acdream's is not. Over-inclusive for scale > 1 (safe), under-inclusive for scale < 1 (the #98/#168 direction). **ENFORCEMENT, added 2026-08-06 at the fix review (finding A1).** The invariant now lives at the TYPE, not only at the producer seam: `ShadowShape`'s constructor is private and BSP shapes are built only through `ShadowShape.Bsp(..., FlatCollisionSphere localBounds)`, which takes radius and centre as ONE value and scales them together. The former public 7-argument constructor with `BoundsCenter = default` let a future BSP producer reintroduce this exact bug silently and green. **CONNECTED-GATE NOTE (finding A2). A null result on tall props is EXPECTED until AP-158 / #333 lands, and is not evidence against this fix.** The geometry now lands in the right cell and is then discarded one layer down by acdream's own `maxReach` broadphase filter, which measures from the same part origin: 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above that filter's roughly 2.5 m walking budget, and 46 above 5 m. | `src/AcDream.Core/Physics/ShadowShape.cs` (`BoundsCenter`); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 3, `FromLandblockBspParts`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`); `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (single bounds resolver); tests `ShadowObjectRegistryMultiPartTests.BuildFloodSpheres_BspShape_CentresOnTheBoundsCentreNotThePartOrigin` / `_RotatesTheBoundsCentreByThePartRotation` / `_CapsCylSpheresAtTenButNeverTheBspParts`, `ShadowRegistrationOverflowTests.FromLandblockBspParts_CarriesTheScaledRootSphereCentre`, `InstalledSetupBspPrimitiveDispatchTests.InstalledSetups_BspFloodSpheres_ContainTheirOwnPhysicsPolygons` (oracle swapped to physics-polygon vertices at the fix review, finding R1: the shipped assertion compared two hand-copies of the same expression and was algebraically identically zero for any DAT input) | The traversal residual is a genuine approximation with its own gate, not a deferral of this fix. Closing it means porting the per-cell `find_transit_cells` part-array overload, which is different work from getting the sphere set right. **OUTDOOR HALF CLOSED 2026-08-06 by #334 (see AP-159 for what remains).** | **RISK COLUMN CORRECTED 2026-08-06 at the #334 fix — as written below it was FALSE, and its falsity is what let #334 sit unnoticed inside this row.** It generalised the INDOOR direction (sphere-vs-portal-plane, over-inclusive) to the whole residual. The OUTDOOR direction was the opposite and strictly worse: acdream routed BSP-bearing objects through `CObjCell::find_cell_list`, whose outdoor expansion is a hard-capped ±1-cell 3×3 for ANY radius, so every formation wider than one 24 m land cell was MISSED in its outer cells — a user-observed loss of collision, not extra candidates. Original text, retained for the record: *"A cell whose portal geometry a part's sphere overlaps in the sphere-vs-plane sense, but which the part's actual polygons do not reach, joins the object's shadow set: extra broadphase candidates, never a missed one. The under-inclusive direction is what the fix above removed."* That statement now holds only for the indoor half, which is AP-159. | `BSPTREE::GetSphere` 0x005397e0; `CGfxObj::physics_sphere` `[gfxobj+0x74]`; `CEnvCell::find_transit_cells` 0x0052cae0 (0x0052cb36 / 0x0052cb4c / 0x0052cb65); `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPartArray::calc_cross_cells_static` 0x00518160; `CObjCell::find_cell_list` 0x0052b9f0 (0x0052ba21) | | AP-157 | **Filed 2026-08-06 at the AP-152 retail-conformance review (finding F4) — an unregistered substitution that predates AP-152 and was stepped over when its neighbours were filed.** `CPhysicsObj::calc_cross_cells`' THIRD branch (`0x005152dc` -> `CPartArray::GetSortingSphere` @0x00518b00 -> `CObjCell::find_cell_list` @0x0052b990) floods from ONE authored whole-object sphere: `GetSortingSphere` returns `[partArray+0x54] + 0x70`, i.e. `CSetup::sorting_sphere` (acclient.h: `CSetup` carries `CSphere sorting_sphere` immediately after `step_up_height`), and that overload takes a single sphere with no cap. acdream's `only == null` branch floods from EVERY non-BSP, non-Cylinder shape instead — the Setup's per-part `Spheres` array. Different DAT field, different cardinality, different extent. 4,154 of 5,935 installed Setups carry a non-zero `SortingSphere` and `DatReaderWriter.Setup` already exposes it, so this is available rather than blocked. Same site, second item: `BuildFloodSpheres` collapses a Cylinder to one sphere at its BASE point with the cylinder radius and IGNORES `CylHeight` entirely, where retail's `CObjCell::find_cell_list` @0x0052b9f0 is handed the `CCylSphere` array as `(low_pt, radius, height)`. | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`, the `anyCyl` and `only == null` branches) | Deliberately NOT folded into the AP-156 fix. It is a different branch of `calc_cross_cells`, reached only by objects with neither a physics BSP nor a CylSphere, so its population is disjoint from the 172 AP-152 Setups and its live gate is a different set of objects. Bundling it would make the AP-156 connected gate un-attributable — which is exactly how AP-155 came to carry two lifecycles under one id. | Sorting-sphere half: an object with several authored Spheres floods from all of them rather than from the one authored whole-object sphere — usually wider (max 5 Spheres on any installed Setup, so retail's 10-cap is never the difference), but a `sorting_sphere` LARGER than every per-part Sphere would make acdream under-inclusive, the #98 / #168 direction. CylHeight half: a tall thin cylinder floods a sphere of its radius at its base and can miss the cells its upper half occupies. | `CPhysicsObj::calc_cross_cells` 0x00515230 (0x005152dc / 0x005152e3 / 0x005152fb); `CPartArray::GetSortingSphere` 0x00518b00 (`[+0x54]+0x70`); `CObjCell::find_cell_list` 0x0052b990 (sorting sphere) / 0x0052b9f0 (cylsphere, `(low_pt, radius, height)`) | | AP-158 | **Filed 2026-08-06 at the AP-156 fix review (finding A2) — an UNREGISTERED INVENTION, not a port, that predates AP-156 and is issue #333.** The shadow broadphase discards a candidate outright when `distToCurr > sphereRadius + obj.Radius + movement.Length() + 2f`. **Retail has no distance pre-filter at all.** `CObjCell::find_obj_collisions` @0x0052b750, disassembled from the PDB-paired binary for this row rather than inherited: it early-returns `OK_TS` only when `sphere_path.insert_type == INITIAL_PLACEMENT_INSERT` (`0x0052b759 cmp dword [ebx+0x174],2` / `0x0052b765 je 0x52b7a0`), then walks `shadow_object_list` (`[cell+0xc8]`, count `[cell+0xc4]`) and calls `CPhysicsObj::FindObjCollisions` (`0x0052b78b call 0x50f050`) on every entry whose `physobj` is unparented (`[physobj+0x40] == 0`) and is not the mover itself — UNCONDITIONALLY. There is no distance test in the function. Neither the `+ 2f` slack nor the `movement.Length()` term has a retail counterpart; retail's own cross-cell slack constant is `F_EPSILON` = 1.9999999e-4 m (`0x0052cb5f fld dword [0x7c8c70]`), 0.0002 m and not 2 m. **Second half of the defect:** the filter measures `currPos - obj.Position`, i.e. from the PART ORIGIN, while `obj.Radius` is the BSP root bounding-sphere radius measured about a centre that AP-156 established is frequently metres away — `ShadowEntry` does not carry the `BoundsCenter` that `ShadowShape` now does. A mover touching the geometry is up to `d + R + r` from the part origin and is admitted only when `d <= movement + 2`, roughly 2.5 m for a walking player. | `src/AcDream.Core/Physics/TransitionTypes.cs:3757-3765`; `ShadowEntry` (`src/AcDream.Core/Physics/ShadowObjectRegistry.cs:2735`) carries no `BoundsCenter` | Deliberately NOT folded into the AP-156 commit: different code path (collision query, not cell membership) and it needed its own retail question answered, which this row answers. The minimal fix is mechanical — carry `BoundsCenter` on `ShadowEntry` and measure from `obj.Position + rotate(obj.BoundsCenter, obj.Rotation)`; only whether to keep the `+ 2f` slack at all is genuinely open. | **This is the gate immediately downstream of AP-156, and it can mask AP-156's entire visible benefit.** 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above the ~2.5 m budget and 46 above 5 m; at a test scale of 1.75 those become 4.4 m and 8.75 m against an unchanged budget. Worked case: Setup 0x02000255, one part, root sphere origin (0.000, -0.007, 9.911), radius 10.522 — a player against its upper half is ~20.4 m from the part origin while `maxReach` is ~13.5 m. Discarded before `BSPQuery` ever runs. A tall prop that still does not block after AP-156 is THIS row, not a failure of AP-156. | `CObjCell::find_obj_collisions` 0x0052b750 (0x0052b759 / 0x0052b765 / 0x0052b788 / 0x0052b78b), pseudo-C 308916-308940; `CEnvCell::find_transit_cells` 0x0052cae0 (`F_EPSILON` at 0x0052cb5f -> 0x7c8c70); issue #333 | +| AP-159 | **Filed 2026-08-06 at the #334 fix - the INDOOR half of AP-156's traversal residual, now the whole of it.** #334 ported retail's `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 path so a physics-BSP object's OUTDOOR membership is the filled land-cell rectangle its authored `CGfxObj::gfx_bound_box` spans (`CLandCell::add_all_outside_cells` @0x00533360 -> `add_cell_block` @0x005331d0). The INDOOR arm of that same walk is NOT ported: retail's part-array `CEnvCell::find_transit_cells` @0x0052cae0 admits a neighbour cell on a BOX test - `CPhysicsPart::GetBoundingBox` @0x0050d600 -> `BBox::LocalToLocal` @0x005b1e60 (`0x0052cbf9`) -> `Plane::intersect_box` @0x005aa170 (`0x0052cc05`), then `BBox::LocalToLocal` into the destination and `CCellStruct::box_intersects_cell` @0x00533910 -> `BSPTREE` @0x0053c880 - where acdream keeps `CellTransit.FindTransitCellsSphere`'s sphere-vs-portal-plane test, fed from the SAME per-part `CGfxObj::physics_sphere` values retail uses for its cheap `eps = F_EPSILON + radius` pre-reject at `0x0052cb65`. The outdoor building bridge (`CEnvCell::check_building_transit` @0x0052c5d0) is on the same sphere input for the same reason. Deferred deliberately: closing it needs a new BOX traversal of the containment BSP in BOTH the graph (`BSPQuery`) and the production flat (`FlatBspQuery`) representations plus their exact referee, which is a separately gateable change with no bearing on #334's outdoor defect. Filed as issue #335. | `src/AcDream.Core/Physics/CellTransit.cs` (`BuildShadowCellSetFromParts`, indoor arm); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildBspPartSpheres`) | The sphere set is exact (AP-156) and the sphere is a strictly LOOSER admitter than the box for a convex part, so the indoor set is a superset of retail's. Retail is itself conservative here in four compounding ways (render-mesh AABB over physics hull, axis-aligned re-fit after rotation, filled rectangle over per-cell test, one rectangle unioned across parts), so an over-inclusive indoor set is the same direction retail errs in. | A cell whose portal plane a part's sphere straddles but whose box does not joins the object's shadow set: extra broadphase candidates, never a missed one. This is AP-156's original risk statement, which is true of the indoor half and was false of the outdoor half. | `CEnvCell::find_transit_cells` 0x0052cae0 (0x0052cbdd / 0x0052cbf9 / 0x0052cc05 / 0x0052cc5a); `Plane::intersect_box` 0x005aa170; `CCellStruct::box_intersects_cell` 0x00533910; `CEnvCell::check_building_transit` 0x0052c5d0 | | ~~AP-152~~ | **RETIRED 2026-08-06 (the commit that filed it is one day old; this retirement corrects four statements in it).** `ShadowShapeBuilder.FromSetup` now DISPATCHES instead of unioning: a step-0 gate derived from the parts suppresses steps 1 and 2 whenever any part's EFFECTIVE GfxObj carries a physics BSP. Retail's priority, re-disassembled from the PDB-paired binary for this commit rather than inherited: `CPhysicsObj::FindObjCollisions` @0x0050f050 tests `HAS_PHYSICS_BSP_PS` FIRST (`0x0050f165 test dword [esi+0xa8],0x10000` / `0x0050f16f je 0x50f1a2`) and leaves the BSP branch through the UNCONDITIONAL `0x0050f19d jmp 0x50f2b0`, which is past the CylSphere loop at 0x50f1a2 AND the Sphere loop at 0x50f21d; a CylSphere-bearing object that survives its loop RETURNS (`0x0050f1d6 jae 0x50f317`); a Setup with zero spheres returns the seeded OK_TS (`0x0050f22f je 0x50f31b`). **BSP wins.** **CORRECTION 1 — the row's risk statement was FALSE as written.** It predicted "catching or stopping on a doorway sill". acdream did not test the extra primitive either: `Transition.BspOnlyDispatch` (`TransitionTypes.cs:1348`, landed 2026-05-25 as A6.P7) already skipped BOTH primitive branches (`:3911`, `:3954`) whenever the target's wire `PhysicsState` carries 0x10000, and ACE sets that bit from `CSetup.HasPhysicsBSP` (`WorldObject_Networking.cs:665-668`). The row's own anchor column cites the flag it failed to notice acdream was already keying on. So this retirement is NOT a collision-response change; the live half was CELL MEMBERSHIP, which had no such guard (see AP-155). **CORRECTION 2 — "the affected primitives are small and centred at the part origin" was FALSE in both halves.** The largest is `0x02001741`'s CylSphere at **r = 6.714 m**; `0x0200086E`'s Sphere is r = 5.842 m with origin (0.759, 0.165, 5.842), nowhere near the part origin. **CORRECTION 3 — the cottage door's "~14 cm base Sphere" was the wrong field.** `0x020019FF`'s Sphere radius is **0.100 m** at origin (0, 0, 0.018); `0.141` is `Setup.Radius`, which AP-22 had just finished proving is never collision geometry. **CORRECTION 4 — the row named ONE pinning test where TWO existed.** `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` also failed under the exclusive rule; both are corrected, neither deleted. Population re-measured independently at 172 of 5,935 (73 CylSphere+BSP, 99 Sphere+BSP; 530 carry a physics-BSP part), agreeing exactly with the filing commit's separate sweep, and now pinned by an installed-DAT test with external bucket controls. | RETIRED — `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 0 gate + `EffectivePartGfxObjId`, shared with step 3 so the two can never read different identities); `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` (`FromSetup_DoorSetup_EmitsBspPartsOnly`, `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` re-hosted on `_ => false`, `FromSetup_DispatchGateReadsTheEffectivePartIdentities`); `tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs` (`CylSphereAndPhysicsBspPart_EmitsOnlyTheScaledBspShape` — no App fixture combined a primitive with a BSP part before); `tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs` (population). `Transition.BspOnlyDispatch` is deliberately KEPT: retail genuinely dispatches at the query site too, and it guards against a future additive producer. | — | — | `CPhysicsObj::FindObjCollisions` 0x0050f050 (0x0050f165 / 0x0050f16f / 0x0050f19d / 0x0050f1d6 / 0x0050f22f); `CPhysicsObj::calc_cross_cells` 0x00515230 (0x00515285 / 0x0051528f) -> `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPhysicsPart::find_obj_collisions` 0x0050d8d0; `CPartArray::CacheHasPhysicsBSP` 0x00518110; evidence `docs/research/2026-08-06-ap152-contract.md` | | ~~AP-145~~ | **RETIRED 2026-08-05 (C5a commit 1, closing #318; corrected at the architecture-review re-pass, A1/A2).** `RuntimePlacementPresentationSink.TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose(entity, entity.Position, entity.Rotation, record.FullCellId, force: true)` — the SAME publisher ordinary per-tick movement uses — instead of writing `LocalPlayerShadowState.Set` directly. `SyncPose` calls `ShadowPositionSynchronizer.Sync` → `ShadowObjectRegistry.UpdatePosition` (the real `PhysicsEngine.ShadowObjects` publish) BEFORE it records the dedup cache as its own last step, so the cache can no longer be pre-seeded ahead of the real publish. `force: true` because this is the authoritative placement commit, not an ordinary refresh — it must never be skipped by `SyncPose`'s own dedup check. **`TryPublishWithdrawal` carried the exact mirror asymmetry** (a bare `_localPlayerShadow.Clear()` with no `ShadowObjects.Suspend`, leaving a live phantom row at the park's source cell for the whole park window — the #184 shape) and is fixed in the SAME commit, same one-call shape: `_localPlayerShadowSync.Suspend(entity)`. The sink no longer holds a direct `LocalPlayerShadowState` reference at all — both halves route exclusively through the one synchronizer, which owns the cache internally. One synchronizer instance is constructed in `LivePresentationComposition.cs` (before the sink) and threaded through `LivePresentationResult` to `SessionPlayerComposition.cs`, which no longer builds its own. `#318`'s composition test (`RuntimePlacementShadowCompositionTests.cs`, 4 facts) proves: the real `ShadowObjects` registry holds a row at the destination cell (not just the cache) after a bare `Place` with no subsequent tick; the SOURCE cell's row is gone, not duplicated; a subsequent ordinary per-tick `Sync` call is a correct no-op; a `Withdraw` suspends the real registry row (not just the cache) — the source cell carries zero rows and the retained (suspendable) registration survives for a later restore; and a Place for a **registered** non-local-player entity leaves its row at the source cell and does not pollute the player's cache (route 7 P4 — the fix lives entirely inside the pre-existing player-only gate; the first version of this fact registered nothing for the child and was vacuous under the gate's own removal, corrected at the review). Sabotage-verified all four facts, both directions: reverted, each fails at its own discriminating assertion; applied, all green. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, `TryPublishWithdrawal`); `src/AcDream.App/Composition/LivePresentationComposition.cs` (`LocalPlayerShadowSynchronizer` construction + `LivePresentationResult` field); `src/AcDream.App/Composition/SessionPlayerComposition.cs` (consumes the shared instance); `tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs` | — | — | No retail analogue — retail has no separate shadow-cache/publish split; this was an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()`/`.Clear()` call could desynchronize from | | ~~AP-1~~ | **RETIRED 2026-08-05 (C5a deletion sweep).** "Production zero-delta routes deliberately remain on the legacy resolver until 4B2" is false at HEAD: the exhaustive receiver census over `src/` shows zero `PhysicsEngine.Resolve`/`.ResolvePlacement` call sites, and every production placement writer reaches canonical `PhysicsEngine.SetPosition` only through `RuntimeSetPositionState` (three call sites total). C5a deleted `Resolve`, `ResolvePlacement`, and their `HasCellSurface` helper outright — the resolver-shaped entry points this row described no longer exist, so the condition is retired structurally, not just narrowed. The narrower survivors (#276 settle-cell discard, AD-61 force-seed, AD-62 non-commit outcomes) are separately filed rows and are unaffected. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `docs/research/2026-08-05-c5a-contract.md` | — | — | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | diff --git a/docs/research/2026-08-06-334-contract.md b/docs/research/2026-08-06-334-contract.md new file mode 100644 index 00000000..9b668b16 --- /dev/null +++ b/docs/research/2026-08-06-334-contract.md @@ -0,0 +1,774 @@ +# #334 — implementation contract: port retail's BSP cell-membership path + +**Filed** 2026-08-06. **Base** `f0588725`, branch +`claude/resume-session-e0bd03e1-d5bf45`. +**Status** IMPLEMENTED 2026-08-06. S0 measured (see the ISSUES #334 entry for the +figures); the S1/S2 split was collapsed into one commit because S1 alone changes no +behaviour and its P2 golden is asserted by S2 test T6. Deviations from this contract, +all reported at the fix: (a) §2.7 / T7 — the INDOOR part-array overload is NOT ported +and is now AP-159 / issue #335, so the fix covers the outdoor half only, which is the +whole of the measured defect; (b) the outdoor rectangle runs at seed time rather than +only from find_bbox_cell_list’s residency-gated walk (AD-49); (c) §11.4 is RESOLVED — +CCellPortal::GetOtherCell @0x0053ba30 IS handed cellarray->do_not_load_cells as its +single explicit thiscall argument (0x0052cc27/0x0052cc2b); (d) §4.2’s 7×7 upper bound +is EXCEEDED in the field — the worst single-owner rectangle over all installed +landblocks is 9×9, for the reason the contract itself caveated. Everything else in +§1 was independently byte-verified by disassembly and held. + +**Original status** CONTRACT ONLY — no production code, no tests, no commit in the +session that produced this file. + +**One line.** acdream has never implemented retail's +`CPhysicsObj::find_bbox_cell_list` path at all. Every object — including +BSP-bearing ones — is routed through a port of the *other* branch, +`CObjCell::find_cell_list`, whose outdoor expansion is a fixed 3×3 land-cell +neighbourhood. The fix is to implement the missing path, not to enlarge +anything. + +--- + +## 0. Executive summary + +Retail dispatches cell membership on `HAS_PHYSICS_BSP_PS` (0x10000) into two +structurally different algorithms. acdream implements one of them and uses it +for both. + +| | retail | acdream at `f0588725` | +|---|---|---| +| BSP-bearing object | `find_bbox_cell_list` → per-part **bounding box** → filled land-cell **rectangle** | `BuildShadowCellSet` → per-part **sphere** → **3×3** neighbourhood | +| CylSphere object | `find_cell_list(cylspheres)` → 3×3 per sphere | same | +| Sorting-sphere object | `find_cell_list(sortingSphere)` → 3×3 | same shape, different source field (AP-157) | + +The outdoor 3×3 is a **hard cap of ±1 cell (±24 m)** and is independent of the +sphere's radius — see §9.3 for the proof. This is why AP-156 (which fixed the +sphere's *position*) could not fix #334, and why widening the radius, adding a +second sphere, or tuning any constant cannot fix it either. Those are not +merely disallowed by policy; they are mechanically incapable of adding a tenth +cell. + +--- + +## 1. Stage 1 — what retail actually does + +All addresses below were **disassembled from the PDB-paired binary** +`C:\Users\erikn\Downloads\acclient.exe` +(`py tools/pdb-extract/check_exe_pdb.py` → `=== MATCH ===`, linker +2013-09-06T00:17:56Z, CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`), +not taken from Binary Ninja. Every address in §1 was resolved to the construct +claimed for it. Four BN artifacts found in the process are listed in §9. + +### 1.1 The dispatch (`CPhysicsObj::calc_cross_cells` @`0x00515230`, pc:283332) + +``` +00515285 f786a800000000000100 test dword ptr [esi + 0xa8], 0x10000 +0051528f 7574 jne 0x515305 ; -> find_bbox_cell_list +00515291 8b4e10 mov ecx, [esi + 0x10] ; part_array +00515298 e8e32d0000 call 0x518080 ; GetNumCylsphere +0051529f 743b je 0x5152dc ; 0 -> sorting sphere +``` + +`0xa8` is `CPhysicsObj::state`; `0x10000` is `HAS_PHYSICS_BSP_PS` +(`acclient.h:2833`). The static twin +`CPhysicsObj::calc_cross_cells_static` @`0x00515160` (pc:283280) carries the +**identical** gate at `0x005151b0` and differs only in setting +`CELLARRAY::do_not_load_cells = 1`. Both tails are +`remove_shadows_from_cells` → `add_shadows_to_cells`. + +### 1.2 The flood driver (`CPhysicsObj::find_bbox_cell_list` @`0x00510fc0`, pc:279006) + +This function **forms no bounding box itself** — it is a worklist. That is the +grain of truth in the "no bounding box at all" claim, and it is why that claim +is misleading: the boxes are formed one and two levels down (§1.4, §1.6). + +``` +00510fd5 mov eax,[ebx+0x90] ; obj->cell +00510fe2 call 0x6b4ff0 ; CELLARRAY::add_cell(ca, cell->m_DID.id, cell) <- seed +00510ff8 mov eax,[esi+8] ; num_cells +00511012 call 0x518160 ; CPartArray::calc_cross_cells_static(pa, cell_i, ca) +00511017 mov eax,[esi+8] ; num_cells RE-READ each iteration <- the array GROWS +0051101d jb 0x511002 +``` + +Seed with the object's own cell, then walk the array while it grows. Transitive +closure over the cell graph, terminated by `CELLARRAY::add_cell`'s dedup. + +### 1.3 The per-cell dispatch (`CPartArray::calc_cross_cells_static` @`0x00518160`, pc:286228) + +Three-instruction thunk. **Not** the extent walk, despite the name: + +``` +00518176 ff527c call dword ptr [edx + 0x7c] ; cell->vtable[0x7c] +``` + +`CObjCell`'s vftable base is `0x007c8b20`; `+0x7c` = `0x007c8b9c`, which holds +`0x0052b080` — the 4-argument +`find_transit_cells(uint numParts, CPhysicsPart** parts, CELLARRAY*)` +overload, distinct from the 6-argument `(Position, uint, CSphere*, CELLARRAY*, +SPHEREPATH*)` sphere/transition overload at `0x0052b070`. + +Overrides: `CEnvCell` @`0x0052cae0` (pc:310127), `CLandCell` @`0x00533840` +(pc:317612), `CSortCell` @`0x00534080` (pc:318323), base `CObjCell` +@`0x0052b080` = `Turbine::Debug::Abort()`. + +### 1.4 Outdoors — the extent walk (`CLandCell::add_all_outside_cells` @`0x00533360`, pc:317289) + +`CLandCell::find_transit_cells` = `add_all_outside_cells` + `CSortCell`'s +building bridge. The extent walk is here. + +``` +if (cellarray.added_outside) return; # 0053336c, runs ONCE per flood +cellarray.added_outside = 1; +p0 = first non-null part; # 005333a2..005333ad +gid = adjust_to_outside(&p0->pos) ? outCellId : 0; + # 005333dd call 0x5a9bc0 + # 005333eb neg esi / sbb esi,esi / and esi,eax +cell0 = LScape::get_landcell(landscape, gid); # 0053340c +if (!cell0) return; # 00533417 je 0x53361c +if (!gid_to_lcoord(gid, &gx, &gy)) return; # 00533428, GLOBAL land-cell coords +baseX = ((gid & 0xFFFF) - 1) >> 3; # 0053343a and eax,0xffff / dec / shr 3 +baseY = (gid - 1) & 7; # 00533443 dec esi / and esi,7 +minDX = minDY = maxDX = maxDY = 0; # 00533390..0053339c +for each non-null part p: + if (!p->Always2D()): + b = BBox::LocalToGlobal(p->gfxobj->gfx_bound_box, p->pos, cell0->pos); + # 0053350e GetBoundingBox, 00533527 LocalToGlobal + a = floor(b.min.x / 24); bb = floor(b.min.y / 24); + c = floor(b.max.x / 24); d = floor(b.max.y / 24); + else: + (sphere centre -/+ radius) / 24, floored + minDX = min(minDX, a - baseX); # 005335a2 sub esi,edx / jge + minDY = min(minDY, bb - baseY); # 005335b4 + maxDX = max(maxDX, c - baseX); # 005335c2 / jle + maxDY = max(maxDY, d - baseY); # 005335d5 +add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY, cellarray); # 00533614 +``` + +**The four stack reads are byte-verified as `min.x, min.y, max.x, max.y`.** The +`fld` displacements (`[esp+0x48]`, `[esp+0x54]`, `[esp+0x5c]`, `[esp+0x60]`) +look inconsistent because `sub esp,8` at `0x533536` and `add esp,8` at +`0x533592` bracket the middle three; normalised to the entry frame they are +`+0x48, +0x4c, +0x54, +0x58`, and the out-`BBox` written by `LocalToGlobal` +(`lea ecx,[esp+0x54]` at three-pushes depth) is based at `+0x48`. A `BBox` is +`m_vMin`(0,4,8) `m_vMax`(0xc,0x10,0x14), so those four are exactly +min.x / min.y / max.x / max.y. Z is never read — land cells are a 2-D grid. + +The four accumulators are initialised to **0**, so the rectangle always +contains the base cell even when the box math contributes nothing. + +`square_length` = `0x7c920c` = `24.0f`, read from the binary +(`00 00 c0 41`). + +### 1.5 Filling the rectangle (`CLandCell::add_cell_block` @`0x005331d0`, pc:317202) + +``` +for x = x0 .. x1 inclusive: # 005331e4 / 0053324d jle + for y = y0 .. y1 inclusive: # 005331f0 / 00533246 jle + if (x >= 0 && y >= 0 && x < 0x7f8 && y < 0x7f8): # 2040 = 255*8 + id = (((x >> 3) << 8) | (y >> 3)) << 16 | ((x & 7) * 8 + (y & 7) + 1) + # 0053320a..0053322e + add_cell(ca, id, LScape::get_landcell(landscape, id)) +``` + +Three properties that matter: + +1. **The rectangle is filled, not outlined.** An L-shaped or diagonal object + claims cells its geometry never enters. Retail's coverage is deliberately + conservative. +2. **`x`/`y` are GLOBAL land-cell coordinates** over the 2040×2040 world grid + and the landblock prefix is re-derived per cell, so the rectangle **crosses + landblock boundaries freely**. +3. `add_cell` (`0x006b4ff0`) dedups by **id** via a linear scan and stores the + `LScape` pointer beside it — including `null` for a non-resident cell. + +`gid_to_lcoord` @`0x00497a90` (pc:163500) byte-verified to return global +coords: `*x = ((gid>>21) & 0x7f8) + ((cellIdx-1)>>3)`, +`*y = (lby << 3) + ((cellIdx-1) & 7)`. + +### 1.6 The box itself (`CPhysicsPart::GetBoundingBox` @`0x0050d600`, pc:274837) + +``` +0050d60a return &this->gfxobj->gfx_bound_box; +``` + +`gfx_bound_box` is filled by `CGfxObj::init_end` @`0x00534200` (pc:318480): +seed `min = max = vertices[0]`, then `BBox::AdjustBBox` over every vertex of +`vertex_array`. **It is the AABB of the GfxObj's vertex array in the GfxObj's +own frame** — the render vertex array, which is also the array the physics +polygons index into. + +`BBox::LocalToGlobal` @`0x005b2120` (pc:448440) is a proper **8-corner +re-fit**: transform `min`, seed both corners from it, transform the other +seven and `AdjustBBox` each. A rotated box therefore grows, conservatively. +The output frame is `cell0->pos`'s — i.e. landblock-local metres, which is +what makes `floor(v / 24)` comparable to `baseX`/`baseY` in 0..7. + +### 1.7 Indoors (`CEnvCell::find_transit_cells` @`0x0052cae0`, pc:310127) + +Per portal × per part: + +- centre of the part's physics sphere in cell-local space + (`Position::localtolocal`), tested against the portal plane with + `eps = 0.0002 + radius` (`0x0052cb65`) — a cheap reject; +- on pass, `BBox::LocalToLocal(partBBox, part->pos, cell->pos)` then + `Plane::intersect_box(portalPlane, box)` (`0x0052cc05`). **The admitting + test is box-vs-plane, not sphere-vs-plane.** +- if the result differs from `portal_side`: `other_cell_id == 0xFFFFFFFF` sets + a flag meaning *this portal leads outside*; otherwise + `BBox::LocalToLocal` into the destination cell and + `CCellStruct::box_intersects_cell` gates the add. +- after all portals, the outside flag runs + `CLandCell::add_all_outside_cells` (`0x0052ccea`). + +### 1.8 Answer to "is retail exact or conservative?" + +**Conservative, in four compounding ways**, all in the over-inclusive +direction: the render-mesh AABB rather than the physics hull; axis-aligned +re-fit after rotation; a *filled* rectangle rather than a per-cell test; one +rectangle unioned across all parts rather than per-part rectangles. Retail +registers the object in cells its geometry does not touch and lets the narrow +phase reject. That is the safe direction (#98 / #168 are the other one), and it +means a faithful port does not need to be clever. + +--- + +## 2. The exact change, by symbol + +### 2.1 New: `CellTransit.BuildShadowCellSetFromParts` (`src/AcDream.Core/Physics/CellTransit.cs`) + +Port of `find_bbox_cell_list` (§1.2). Signature mirrors +`BuildShadowCellSet`, taking part boxes instead of spheres: + +``` +public static IReadOnlyList BuildShadowCellSetFromParts( + PhysicsDataCache cache, + uint seedCellId, + IReadOnlyList worldParts, // new value type, §2.3 + bool isStatic) +``` + +Body: seed with `seedCellId`; walk `candidates` by index while it grows +(re-reading `Count`, §1.2); per candidate dispatch outdoor → +`AddAllOutsideCellsFromParts` + the existing building bridge, indoor → +`FindTransitCellsParts`. + +### 2.2 New: `CellTransit.AddAllOutsideCellsFromParts` + +Port of §1.4 + §1.5. Reuses the existing `AddOutsideCell` helper (already +global-lcoord and already landblock-crossing — do not touch it) inside a +double loop, with the `0 <= v < 0x7f8` clamp from §1.5. Guarded by the same +once-per-flood `added_outside` latch `BuildShadowCellSet` already models, but +note the **cardinality change**: the sphere overload runs the whole body per +sphere; the parts overload computes **one** rectangle over all parts and runs +once. + +### 2.3 New: `ShadowPartBox` (`src/AcDream.Core/Physics/`) + +`(Vector3 LocalMin, Vector3 LocalMax, Vector3 LocalPosition, Quaternion +LocalRotation, float Scale)` — the per-part input to the 8-corner re-fit. +Follow `ShadowShape`'s AP-156 precedent: **factory-only construction, with min +and max arriving as one value**, so no future call site can take one and drop +the other. + +### 2.4 Changed: `ShadowShape` — carry the box + +Add `LocalBoundsMin` / `LocalBoundsMax`, filled by the **same resolver that +already supplies `Radius` and `BoundsCenter`**. This is the AP-156 invariant +re-applied: one resolver, one value, scaled together. + +Source: `FlatGfxObjVisualBounds.Min` / `.Max`, which +`FlatCollisionAssetBuilder.FlattenGfxObj` already computes from +`PhysicsDataCache.ComputeVisualBounds(source.VertexArray)` — **the exact +`CGfxObj::init_end` computation** — and which +`FlatCollisionAssetSerializer` already writes into the prepared package. **No +bake-format change, no DAT re-read, no new parsing.** This is the single +largest de-risking fact in this contract. + +Resolvers to widen: `ShadowShapeBuilder.FromSetup`'s +`physicsBspBounds: Func` and +`FromLandblockBspParts`'s `Func getGfxObj`; +`LiveEntityCollisionBuilder._physicsBspBounds` is the single live supplier. + +### 2.5 Changed: `ShadowObjectRegistry.RegisterMultiPart` + +The dispatch, mirroring §1.1 — this is the whole fix in one place: + +``` +bool hasBsp = shapes.Any(s => s.CollisionType == ShadowCollisionType.BSP); +var cellSet = hasBsp + ? CellTransit.BuildShadowCellSetFromParts(FloodCache, seed, boxes, isStatic) + : CellTransit.BuildShadowCellSet (FloodCache, seed, spheres, spheres.Count, isStatic); +``` + +### 2.6 What happens to `BuildFloodSpheres` + +**It stays, unchanged, and keeps its cap logic** — it is a correct port of the +`!HAS_PHYSICS_BSP` branch's two arms, which retail still uses for CylSphere and +sorting-sphere objects. What changes is that its **BSP arm becomes dead**: with +the §2.5 dispatch, a shape list containing a BSP shape never reaches it. + +Delete the BSP arm rather than leaving it unreachable. That arm's XML doc +(`ShadowObjectRegistry.cs:436-442`, "A BSP part contributes its ROOT BOUNDING +SPHERE placed at its real center") becomes false the moment §2.5 lands and must +go with it. Leaving a dead-but-plausible BSP arm behind is exactly how a future +producer silently re-acquires the bug. + +Objects that legitimately are spherical are **untouched**: same function, same +cap, same 3×3, byte-identical cell sets. That is proof obligation P2. + +### 2.7 Indoor half: `CellTransit.FindTransitCellsParts` + +Port of §1.7 alongside `FindTransitCellsSphere` (which stays for the sphere +route). This is the half AP-156's row already names as its open residual. + +--- + +## 3. Interaction with what landed tonight + +### 3.1 AP-156 (`b52967de`) — **this port RETIRES its open residual** + +AP-156's row states its remainder explicitly: *"Closing it means porting the +per-cell `find_transit_cells` part-array overload, which is different work from +getting the sphere set right."* That is precisely §2.1/§2.2/§2.7. **Sequential, +not competing; AP-156 is a prerequisite and stands.** + +Two register consequences, both in the same commit as the fix: + +- **AP-156's Risk column is FALSE as written and must be corrected before it is + retired.** It records the traversal residual as *"extra broadphase + candidates, never a missed one."* #334 is a missed one. The row generalised + the **indoor** direction (sphere-vs-portal-plane, over-inclusive) to the + whole residual, and the **outdoor** direction is the opposite: a fixed 3×3 + that is under-inclusive for every object wider than one cell. Correct the row + first, then retire it — a row deleted while still carrying a false risk + statement takes the finding with it. +- `BoundsCenter` stays. It still positions the sphere for the non-BSP routes + and for the `eps = 0.0002 + radius` portal pre-reject in §1.7. + +### 3.2 AP-152 (`4abd1b5e`) — **preserved and depended on, not conflicting** + +AP-152 made shape emission BSP-exclusive: a BSP-bearing Setup emits BSP shapes +and no primitive. §2.5's `hasBsp` predicate is therefore *unambiguous* — post +AP-152 a shape list is homogeneous in practice, so "has a BSP shape" and +"is a BSP object" coincide, exactly as retail's cached `HAS_PHYSICS_BSP_PS` +does. **Without AP-152 this dispatch would be ill-defined.** Nothing to narrow +or retire; add a cross-reference from AP-152's row. + +### 3.3 AP-158 / #333 — **a blocking interaction, and the one thing that can make this fix look like it did nothing** + +The broadphase reach filter discards a candidate when +`distToCurr > sphereRadius + obj.Radius + movement + 2f`, measuring from the +**part origin**. This port's whole purpose is to register objects in cells +*further from the part origin than the sphere reaches* — which is the precise +input that makes AP-158 fire. + +Bound: a player standing at the far corner of the new rectangle is up to +`~1.73·R + |BoundsCenter|` from the part origin, against a budget of +`R + r + move + 2`. For `R = 69.471` and the measured +`|BoundsCenter| = 34.977` that is ~155 m tested against ~72 m — **rejected**. + +For the specific Neftet object the fix does still work: the player positions in +the two adjacent cells sit ~50 m from the part origin against a ~72 m budget, +so those cells pass. **But the general statement is that #334's fix is +necessary and not sufficient**, and the AP-156 fix review already recorded this +exact failure mode one layer up ("the fix may produce no visible change at all, +because the geometry now lands in the right cell and is then discarded by the +filter"). Do not let it happen twice. + +**Directive:** the §7 gate must report `rejectedReach` per scenario. A gate that +shows `inCell` rise while `rejectedReach` rises with it is a **fail**, and the +remedy is #333, not a wider budget here. + +--- + +## 4. Cost — measured where possible, and honestly bounded where not + +### 4.1 What the cost is, structurally + +Cells per object changes from **≤ 9, position-dependent** to +**(⌈Xextent/24⌉+1) × (⌈Yextent/24⌉+1)**. + +The crossover is exact and favourable: **any object whose XY extent is ≤ 24 m +yields at most 2×2 = 4 cells — fewer than today's 9.** The port is *cheaper* +for every creature, prop, door and item, and more expensive only for objects +wider than one land cell. Those are landblock-baked terrain formations and +building shells. + +### 4.2 Measured worst live case + +From the committed probe log (`334-neftet-probe.log`), the only object in the +sample above 1.4 m: `gfx=0x010046D8`, `objR = 69.471`, +`|bspCentreOffset| = 34.977`. The three other BSP objects observed are +1.075 / 1.271 / 1.370 m — i.e. 1×1 rectangles, strictly cheaper than today. + +Upper bound for the outlier: the box is contained in the mesh's extent, so +extent ≤ 2R = 138.9 m → at most `floor(138.9/24)+1 = 6` cells per axis, +1 for +straddle = **7×7 = 49 cells**, versus 9 today. Caveat stated plainly: this +bound assumes the BSP root sphere bounds the whole vertex array; it bounds the +*physics polygons'* vertices, which are a subset, so a render-only vertex +outside it would exceed the bound. + +### 4.3 Where the cost lands relative to existing budgets + +- **Not on the per-frame resolve path.** Slice I1 measured 0 B/resolve for + player, remote, projectile, camera and grounded walkable-publication + profiles; the flood is registration-time, not resolve-time. The ordinary + production profile's CPU/GPU p50 of 1.869 / 1.096 ms is not exposed to it. +- **Landblock statics** (`isStatic: true`, both hosts): once per landblock + publication, already metered by the Slice E retirement/publication budgets. +- **Live remotes:** `RuntimeRemotePhysicsUpdater` re-floods per tick, gated on + >1 cm movement / rotation / cell change. Creature extents are ≪ 24 m → ≤ 4 + cells → **strictly cheaper than the current 3×3 on the hottest path in the + system.** + +### 4.4 The real cost is memory, not CPU + +`_cells` is `Dictionary>` and +`RegisterMultiPart` writes **every shape row into every flooded cell**. Rows +per object = `shapes × cells`. For a many-part baked formation at 49 cells this +is a 5.4× row multiplication over today's 9. Landblock-baked part arrays are +the population with both the largest part counts and the largest extents, so +the two multiply. + +### 4.5 What I could NOT measure, and the measurement to run first + +**I did not enumerate the installed distribution of physics-BSP GfxObj bounding +boxes.** It is not derivable from anything in the repo: the register's existing +figures (973 physics-BSP parts, 530 BSP-bearing Setups, 477 unique physics-BSP +GfxObjs, 118 above 2.5 m offset, 46 above 5 m) are all **sphere** statistics. + +**Required before any code is written** — same route the AP-156 and #333 +figures used (an out-of-repo scratch program over the installed +`client_portal.dat`), reporting over all 477 unique physics-BSP GfxObjs: + +1. histogram of `ceil(Xextent/24)+1` × `ceil(Yextent/24)+1`; +2. the count exceeding 1×1, 2×2 and 4×4; +3. the worst case, with its gfx id; +4. total `Σ shapes × cells` over one dense landblock (Arwic) before and after. + +**Gate:** if the p99 rectangle exceeds 7×7 or the dense-Arwic row total more +than doubles, stop and report rather than proceeding. That is the point at +which "the faithful port is too expensive" becomes a real finding and the +honest alternative — retail's own `CELLARRAY` growth policy, or a shared row +rather than a per-cell copy — gets designed deliberately instead of discovered +in a profile. + +--- + +## 5. Blast radius — BOTH hosts, checked not inferred + +`ShadowObjectRegistry` and `CellTransit` are in **`AcDream.Core`**, which both +hosts reference. Project graph read from the `.csproj` files: + +``` +AcDream.Headless -> AcDream.Runtime -> {Core, Core.Net, Content, Plugin.Abstractions} +AcDream.App -> {Runtime, Core, Core.Net, Content, UI.Abstractions, Plugins.Smoke} +``` + +### 5.1 Production call sites of `RegisterMultiPart` (complete) + +| site | host reach | +|---|---| +| `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:178` | App only | +| `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:959, 1044` | App only | +| `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:619, 700` | **App AND Headless** | + +### 5.2 Headless is reached — verified by call site, not by dependency inference + +`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs` calls +`LandblockPhysicsContentBuilder.HydrateStaticEntities` (:386), +`HydrateProceduralScenery` (:392), `BuildDatBundle` (:403), +`PublishPreparedCells` (:435), `CacheBuildings` (:442), +`CachePreparedObjects` (:447). Lines 619 and 700 of that builder — the +`FromLandblockBspParts` BSP path and the `FromSetup` path — are exactly the +sites that register the landblock-baked formations #334 is about. + +**Headless registers the same objects through the same Core code and is +affected identically.** This is the survey C5b missed and the direction AP-152's +contract got wrong; it is settled here by reading the call sites. + +### 5.3 Consumers that must NOT change + +`_cells` shape is unchanged (same key, same row type), so every reader — +`TransitionTypes`, `PhysicsEngine`, `CollisionWorldState`, +`RuntimePhysicsState`, `ShadowPositionSynchronizer`, +`RuntimeCollisionReportingState`, `ProjectileController`, camera collision — +sees only a different *membership*, never a different *shape*. No consumer +signature changes. + +--- + +## 6. Proof obligations and test plan + +### 6.1 Proof obligations + +- **P1 — rectangle equality.** For a BSP object the registered outdoor set + equals `add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY)` exactly: + not a superset, not a subset, and *filled*. +- **P2 — non-BSP invariance.** Cylinder-only and Sphere-only owners register + byte-identical cell sets to `f0588725`. +- **P3 — map bounds.** No registered cell has a global lcoord outside + `[0, 0x7f8)`. +- **P4 — host agreement.** App and Headless produce the same cell set for the + same landblock and seed. +- **P5 — negative-safe floor.** `floor`, not truncation (see trap §8.5). + +### 6.2 Tests — each with the sabotage that must redden it + +Every fixture below is **non-degenerate on the axis under test**: in particular +**every BSP fixture has an extent that exceeds its own radius**, which is the +whole property at issue. A fixture whose box fits inside its sphere makes the +new path and the old path agree and proves nothing — that is the failure mode +this campaign has now hit ten times. + +| # | test | sabotage that MUST redden it | +|---|---|---| +| T1 | Part with 100 m × 100 m box and 1 m sphere → rectangle spans ≥ 5 cells per axis | swap the box for the sphere → collapses to 3×3 | +| T2 | Two parts forming an L → the notch cell **is** present (rectangle is filled) | compute per-part rectangles and union them → notch disappears | +| T3 | Box reaching past cellX 7 → cells carry the **neighbour landblock's** prefix | clamp the rectangle to the seed landblock | +| T4 | Rectangle at the map corner → no cell outside `[0, 0x7f8)` | drop the `0x7f8` clamp | +| T5 | Non-cubic box rotated 37° → rectangle grows vs unrotated | transform only min/max instead of all 8 corners | +| T6 | Cylinder-only owner → cell set identical to a `f0588725` golden | route every owner through the box path | +| T7 | Owner straddling an EnvCell portal whose **box** crosses the plane but whose **sphere** does not → destination cell present | keep `FindTransitCellsSphere` on the BSP route | +| T8 | Seed cell not resident → outdoor registration skipped, no throw (§1.4 `if (!cell0) return`) | drop the null check | +| T9 | Negative delta (box extends below the base cell) → cells with lower lcoord present | use `(int)(v/24f)` truncation instead of `MathF.Floor` | + +### 6.3 T10 — the installed-DAT replay of the measured evidence (strongest test) + +Assert that `gfx=0x010046D8` (entity `0xC8764000`, position +`(63.78, 248.29, 0.08)`, from the committed probe log) registers into +**`0x87640011` and `0x87640019`** — the two cells the probe measured EMPTY — +as well as `0x8764000A` and `0x87640012`, which it measured populated. + +Sabotage: revert `RegisterMultiPart` to `BuildFloodSpheres` → the two new cells +vanish. + +This asserts observed reality and re-encodes no constant under test. + +**Precondition that must be honoured, not assumed.** Whether the box actually +reaches those two cells is a **prediction, not a measurement**. §9.3 establishes +that the current 3×3 is centred on cell `0x87640013` (x=2, y=2) and therefore +structurally cannot reach cellY 0 — that half is proven. Whether +`0x010046D8`'s box extends ≥ 48 m in −Y is not. + +**Directive:** measure `0x010046D8`'s `FlatGfxObjVisualBounds` first and derive +the expected rectangle from it. If the measured box does **not** reach +`0x87640011` / `0x87640019`, **stop and report** — that would mean the +diagnosis is incomplete and a second mechanism is present. Do not weaken the +test to match; do not pin the cell list before the box is read. + +### 6.4 Not permitted + +No source-text pins. No test asserting `24f` or `0x7f8` by reading the +constant it is testing. No test whose expected cell set was produced by running +the new code. + +--- + +## 7. Gate design — positive evidence, named observables + +`ACDREAM_PROBE_REACH` (`b61f5fd4`, +`PhysicsDiagnostics.ProbeReachEnabled`) produces the before/after comparison +directly. Capture one log at `f0588725` and one at the fix commit, same route. + +**Per-scenario pass condition** — all three must hold: + +1. a `[reach-obj]` row with `gfx=0x010046D8` appears in cells where it did not + before; +2. that row's `disp` is `tested-*`, **not** `rejected-reach` (§3.3); +3. `[reach-q]`'s `inCell` rises **and** `rejectedReach` does **not** rise with + it. + +**Scenarios, named by the user's own report:** + +- **G1 — walk through on flat ground.** Approach a formation on level ground + and walk into its face. Observable: blocked, `blocked ≥ 1`. +- **G2 — the boundary between two formations.** Walk along the seam where two + formations meet — the exact geometry the report calls out. Observable: + continuously blocked across the seam; before the fix `inCell=2 exempt=2` with + no rock row, after it a rock row in every cell along the seam. +- **G3 — jump over and land inside.** Jump onto/over a formation. Observable: + lands **on** the geometry, does not fall through; a walkable contact plane is + reported at the landing tick. +- **G4 — a formation the fix should not change.** Any 1×1-extent prop nearby: + its cell set must be unchanged (visual P2 corroboration). + +**Regression gates:** Release build (after deleting all `bin`/`obj` — four +stale-DLL incidents this session, one under `-t:Rebuild`); complete solution +suite; the exact-binary lifecycle/reconnect route; the canonical nine-stop +route; the native-Linux Headless multi-session run, since §5.2 puts Headless in +scope. + +--- + +## 8. Traps + +1. **AP-158 masks the fix in far cells.** §3.3. The most likely way this lands + green and changes nothing the user can see. +2. **Cardinality change.** The sphere overload runs its whole body *per + sphere*; the parts overload computes **one** rectangle over all parts and + runs **once**. Reusing the sphere loop's per-item structure produces N + rectangles and silently breaks T2. +3. **`0x00518160` is not the extent walk.** It is a 3-instruction vtable thunk. + The extent walk is `0x00533360`. #334's issue body cites the former as "a + walk over the object's extent" (§9.2). +4. **Two functions named `calc_cross_cells_static`.** + `CPhysicsObj::` @`0x00515160` is a *caller* of `find_bbox_cell_list`; + `CPartArray::` @`0x00518160` is the thunk *below* it. They are not on the + same level and confusing them inverts the call graph. +5. **`floor`, not truncation.** Retail calls `floor` then `_ftol2`. C# + `(int)(v / 24f)` truncates toward zero and is wrong for every negative + block-local coordinate. T9. +6. **The base gid comes from the FIRST NON-NULL PART's + `adjust_to_outside`,** not from the object's position (`0x005333a2`). + `DeriveOutdoorSeed` clamps to the seed block; the rectangle must not + inherit that clamp. +7. **Global vs within-landblock indices in the same expression.** + `gid_to_lcoord` returns global coords; `baseX`/`baseY` are within-block + 0..7. The deltas bridge them. Mixing the two frames is the single most + likely arithmetic error, and BN's own output already drops the + `and eax,0xffff` that makes `baseX` within-block (§9.4). +8. **Do not touch `AddOutsideCell`.** It is already correct and already + landblock-crossing; the new path composes it. +9. **The `isStatic` prune is indoor-seeded only.** The outdoor rectangle is + deliberately unpruned. Extending the prune to it would re-create #334 in a + new form. +10. **Don't leave `BuildFloodSpheres`' BSP arm unreachable-but-plausible.** + §2.6. + +--- + +## 9. Claims found FALSE or STALE at `f0588725` + +### 9.1 "`find_bbox_cell_list` forms no bounding box at all" — MISLEADING; refuted as a characterisation + +Literally true of that one function (§1.2 — it is a worklist driver). False as a +description of the mechanism: the boxes are formed in +`CLandCell::add_all_outside_cells` (§1.4, `GetBoundingBox` + +`BBox::LocalToGlobal`) and `CEnvCell::find_transit_cells` (§1.7, +`BBox::LocalToLocal` + `Plane::intersect_box`), one and two levels below. The +name is accurate. Reading only the top frame and stopping is what produced the +claim. + +### 9.2 `docs/ISSUES.md` #334 — address chain imprecise + +The issue reads *"`find_bbox_cell_list` @0x00510fc0 → `calc_cross_cells_static` +@0x00518160, i.e. a walk over the object's extent."* The **routing is correct** +and the **conclusion is correct**, but `0x00518160` is `CPartArray::`'s vtable +thunk (§1.3), not an extent walk, and the similarly named +`CPhysicsObj::calc_cross_cells_static` @`0x00515160` is a *caller* of +`find_bbox_cell_list`, not a callee. Correct chain: +`0x00515230` → `0x00510fc0` → `0x00518160` → `[vtbl+0x7c]` → +`0x00533840` → `0x00533360` → `0x005331d0`. + +### 9.3 #334's stated cause is right but understates the mechanism — and this is what kills "widen the sphere" + +The issue attributes the loss to the sphere's radius (69.471 m) being smaller +than a landblock (192 m). The operative cap is not the radius at all. +`CellTransit.AddAllOutsideCells` computes `minRad = radius`, +`maxRad = 24 − radius`, and adds at most the **eight** neighbours of the +sphere's own cell. **For any radius ≥ 12 m both boundary tests are +unconditionally true and the result is exactly the 3×3 — a larger radius cannot +add a tenth cell.** Outdoor reach is hard-capped at ±24 m for every object in +the game. + +Consequence: widening the radius, adding a supplementary sphere at another +point (it would produce its own 3×3, not a joined region), or tuning any +constant is *mechanically* incapable of fixing this, not merely disallowed. + +**Independent confirmation against the measured data.** Global lcoords +(lbx=0x87, lby=0x64): present `(1081,801)`, `(1082,801)`; absent +`(1082,800)`, `(1083,800)`, `(1082,799)`. A 3×3 centred at `(1082,802)` — cell +`0x87640013`, x=2, y=2 — contains both present cells and excludes all three +absent ones. Every other candidate centre contradicts at least one observation. +The player's own logged positions (`currPos ≈ (52.4, 216.0)` while in +`0x87640012`) independently constrain the landblock origin to the same +solution. **The 3×3 hypothesis explains the measured evidence with zero +contradictions.** + +### 9.4 Binary Ninja artifacts in `acclient_2013_pseudo_c.txt` — four, all in the load-bearing function + +Anyone porting §1.4 from the pseudo-C alone gets these wrong: + +1. `add_all_outside_cells` pc:317343 renders `baseX` as + `((uint32_t)esi_4 - 1) >> 3`. **BN dropped the `and eax, 0xffff`** + (`0x0053343a`). Without it `baseX` includes the landblock bits and every + delta is garbage. +2. pc:317330 renders the base-gid select as `((esi_2 - esi_2) & var_58)`, + which is identically **zero**. The real code is the standard + `neg esi / sbb esi,esi / and esi,eax` conditional select + (`0x005333eb`) = `retval ? outsideCellId : 0`. +3. `add_cell_block` pc:317219 renders + `LScape::get_landcell(landscape, edx_2)` with `edx_2 = i & 7`. The real + argument is `esi`, the full computed cell id (`0x00533230 push esi`). + Same artifact in `add_all_outside_cells` (`..., added_outside)` where + `added_outside == 0`). +4. Both `x87` flag tests in the min/max accumulation appear as + `unimplemented {test ah, ...}` / bit-shuffled `FCMP_UO` expressions. The + real comparisons are plain integer `jge`/`jle` on `_ftol2` results + (`0x005335a6`, `0x005335b8`, `0x005335c8`, `0x005335d9`) — the fifth + confirmed instance this campaign of BN dropping flag semantics. + +### 9.5 AP-156's Risk column is FALSE for the outdoor half + +Recorded as *"extra broadphase candidates, never a missed one."* #334 is a +missed one. §3.1. + +### 9.6 `ShadowObjectRegistry.cs:436-442` XML doc becomes false on landing + +*"A BSP part contributes its ROOT BOUNDING SPHERE placed at its real center."* +True at `f0588725`; false the moment §2.5 lands. Delete with the BSP arm +(§2.6). + +### 9.7 Stale, not false + +`CellTransit.BuildShadowCellSet`'s XML calls itself *"the sphere-overlap portal +flood retail runs at SHADOW REGISTRATION time"* — accurate for the branch it +ports, but it is presented as **the** registration flood when it is one of two. +Narrow the wording when §2.1 lands. + +--- + +## 10. Size estimate and split call + +~600–800 production lines (two `CellTransit` ports, one value type, the +`ShadowShape` field and its resolvers, the `RegisterMultiPart` dispatch, the +`BuildFloodSpheres` BSP-arm deletion) plus ~400 test lines. + +**Split: THREE commits, sequential, one agent, no parallelism** (shared files — +`CellTransit.cs`, `ShadowShape.cs`, `ShadowObjectRegistry.cs` — are touched by +every slice). + +| slice | content | gate | +|---|---|---| +| **S0** | §4.5 measurement only. No repo change. | numbers reported; the §4.5 stop-gate evaluated | +| **S1** | `ShadowPartBox`, `ShadowShape` bounds + resolvers, package/serializer read-through. No behaviour change. | full suite green; P2 golden cell sets bit-identical | +| **S2** | `AddAllOutsideCellsFromParts` + `BuildShadowCellSetFromParts` + `FindTransitCellsParts` + the §2.5 dispatch + BSP-arm deletion | T1–T10; P1–P5; Release; both hosts; §7 connected gate | + +S0 is not optional. It is the slice that can still say "this is too expensive" +before anything is written, which is the only honest way to make that call. + +**Rollback:** each slice reverts independently; S2 alone restores `f0588725` +behaviour. + +--- + +## 11. What I could not establish + +1. **The installed distribution of physics-BSP GfxObj bounding boxes** — §4.5. + Not derivable from the repo; every existing figure is a sphere statistic. + S0 exists to close this. +2. **Whether `0x010046D8`'s box actually reaches `0x87640011` / `0x87640019`** + — §6.3. The *absence* is proven and its cause is proven; the *presence + after the fix* is a prediction until the box is read. The contract makes + reading it a precondition rather than an assumption, because a contract + asserting a mechanism that does not exist is how this campaign produced + three defects. +3. **Whether `0x010046D8` is one object or several instances sharing a gfx + id.** The log shows one entity id (`0xC8764000`) across all 1,356 rows, so + one instance is the working assumption; a second instance elsewhere in the + landblock would not change the diagnosis but would change T10's expected + set. +4. **Whether `CCellPortal::GetOtherCell` takes `do_not_load_cells` as a third + argument.** BN reads the field at `0x0052cc2a` but shows a two-argument + call. Only matters for indoor static registration (§2.7); resolve by + disassembly during S2 rather than porting BN's shape. diff --git a/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs index bb7907e5..59929e9c 100644 --- a/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs +++ b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs @@ -47,7 +47,7 @@ internal sealed record LiveEntityCollisionRegistration( /// internal sealed class LiveEntityCollisionBuilder { - private readonly Func _physicsBspBounds; + private readonly Func _physicsBspBounds; /// /// The dispatch gate, derived from so the /// two can never disagree (AP-156), and cached once so @@ -62,11 +62,13 @@ internal sealed class LiveEntityCollisionBuilder : this( id => { - FlatPhysicsBsp? flat = - physicsData.GetFlatGfxObj(id)?.PhysicsBsp; + FlatGfxObjCollisionAsset? asset = physicsData.GetFlatGfxObj(id); + FlatPhysicsBsp? flat = asset?.PhysicsBsp; return flat is { RootIndex: >= 0 } - ? flat.Nodes[flat.RootIndex].BoundingSphere - : (FlatCollisionSphere?)null; + ? ShadowPartGeometry.Create( + flat.Nodes[flat.RootIndex].BoundingSphere, + asset!.VisualBounds) + : (ShadowPartGeometry?)null; }, defaultPose) { @@ -74,14 +76,15 @@ internal sealed class LiveEntityCollisionBuilder } /// The part GfxObj's physics-BSP root - /// bounding sphere, or null when it has none. ONE resolver answers both - /// questions the builder asks — "does this part dispatch as BSP?" and - /// "where and how big is its flood sphere?" — so the dispatch gate and - /// the emitted geometry cannot disagree, and the sphere's radius cannot - /// be carried while its origin is dropped. That split is what produced - /// the AP-156 mis-placed flood. + /// bounding sphere AND its authored vertex-array box, or null when it has + /// no physics BSP. ONE resolver answers every question the builder asks — + /// "does this part dispatch as BSP?", "where and how big is its flood + /// sphere?", and "what is its outdoor extent?" — so the dispatch gate and + /// the emitted geometry cannot disagree, the sphere's radius cannot be + /// carried while its origin is dropped (AP-156), and the outdoor extent + /// walk cannot be left without a box (#334). internal LiveEntityCollisionBuilder( - Func physicsBspBounds, + Func physicsBspBounds, LiveEntityDefaultPoseResolver defaultPose) { _physicsBspBounds = physicsBspBounds diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 783eaf35..09d6f4d0 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -372,6 +372,146 @@ public static class CellTransit } } + /// + /// Outdoor extent walk for a physics-BSP part array — the OTHER outdoor + /// expansion retail has, and the one #334 was missing entirely. Verbatim + /// port of CLandCell::add_all_outside_cells @0x00533360 (pc:317289) + /// plus CLandCell::add_cell_block @0x005331d0 (pc:317202), + /// disassembled from the PDB-paired 2013-09-06 binary rather than read + /// from Binary Ninja's pseudo-C, which mis-renders four separate + /// constructs inside this one function + /// (docs/research/2026-08-06-334-contract.md §9.4). + /// + /// + /// Shape, byte-verified: + /// + /// + /// The base landcell comes from the FIRST part's own + /// adjust_to_outside (0x005333a2-0x005333dd), + /// NOT from the object's position; a failed adjust selects gid 0 + /// through the neg/sbb/and conditional select at + /// 0x005333eb, whose get_landcell then returns null + /// and the walk returns (0x00533417). + /// baseX = ((gid & 0xFFFF) - 1) >> 3 + /// (0x0053343a and eax,0xffff — the mask BN drops) and + /// baseY = (gid - 1) & 7 (0x00533443): WITHIN-BLOCK + /// 0..7, bridged to the GLOBAL lcoords from + /// gid_to_lcoord (0x00533428) by the four deltas. + /// Per part: GetBoundingBox @0x0050d600 → + /// BBox::LocalToGlobal @0x005b2120 (0x00533527), then + /// floor(v / square_length) on min.x, min.y, max.x, max.y + /// (stack slots +0x48/+0x4c/+0x54/+0x58 in the entry frame — + /// the raw displacements differ only because sub esp,8 at + /// 0x00533536 brackets the middle three). Z is never read: + /// land cells are a 2-D grid. square_length is + /// 0x7c920c = 00 00 c0 41 = 24.0f, read from the + /// binary. + /// The four accumulators are seeded to ZERO + /// (0x00533390-0x0053339c), so the rectangle always + /// contains the base cell, and are combined with plain integer + /// jge/jle (0x005335a6, 0x005335b8, + /// 0x005335c8, 0x005335d9) — BN renders these as + /// unimplemented {test ah} / FCMP_UO. + /// ONE rectangle over ALL parts, filled — not outlined, not a + /// per-part union (0x00533614 + /// add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY, + /// cellarray), argument order recovered from the five pushes + /// at 0x005335f2-0x00533613). An L-shaped object + /// claims the notch; retail's coverage is deliberately + /// conservative and lets the narrow phase reject. + /// + /// + /// + /// Retail also has an Always2D() arm (0x0053346b) that falls + /// back to the part's sphere. It is unreachable here: this overload is + /// only ever handed physics-BSP parts, and a 2-D sprite part carries no + /// physics BSP. + /// + /// + /// The object's physics-BSP parts, world-placed. + /// The flood seed cell — supplies the landblock + /// base adjust_to_outside measures part 0's position against. + /// World origin of the seed cell's + /// landblock (#106 frame convention); when the + /// seed block IS the anchor. + /// False when adjust_to_outside or gid_to_lcoord + /// rejects the base position (map edge / invalid id) — retail returns + /// without adding anything. + public static bool AddAllOutsideCellsFromParts( + IReadOnlyList worldParts, + uint currentCellId, + Vector3 currentBlockOrigin, + ICollection candidates) + { + if (worldParts is null || worldParts.Count == 0) + return false; + + // 0x005333a2-0x005333dd: the base gid is the FIRST part's landcell. + // DeriveOutdoorSeed clamps its own result to the seed block; this + // deliberately does not inherit that clamp — a part array whose first + // part sits over the neighbour block anchors there, as retail does. + Vector3 seedFramePos = worldParts[0].WorldPosition - currentBlockOrigin; + Vector3 baseFramePos = seedFramePos; + uint baseCellId = currentCellId; + if (!LandDefs.AdjustToOutside(ref baseCellId, ref baseFramePos)) + return false; // gid 0 → get_landcell null → return + if (!LandDefs.GidToLcoord(baseCellId, out int gx, out int gy)) + return false; // 0x00533432 je 0x53361c + + int baseX = (int)(((baseCellId & 0xFFFFu) - 1u) >> 3); // 0x0053343a + int baseY = (int)((baseCellId - 1u) & 7u); // 0x00533443 + + // adjust_to_outside re-based part 0's position into the ADJUSTED + // block's local frame; retail's BBox::LocalToGlobal writes every part + // box into that same frame (cell0->pos). The re-basing is a pure + // translation, so applying it to the world origin is exact. + Vector3 frameOrigin = + currentBlockOrigin - (baseFramePos - seedFramePos); + + int minDX = 0, minDY = 0, maxDX = 0, maxDY = 0; // 0x00533390 + + for (int i = 0; i < worldParts.Count; i++) + { + worldParts[i].RefitTo(frameOrigin, out Vector3 boxMin, out Vector3 boxMax); + + // floor, then _ftol2 — NOT truncation. C#'s (int)(v / 24f) + // truncates toward zero and is wrong for every negative + // block-local coordinate, which is precisely the case a part + // hanging off the block's SW corner produces. + int a = (int)MathF.Floor(boxMin.X / LandDefs.CellLength); + int b = (int)MathF.Floor(boxMin.Y / LandDefs.CellLength); + int c = (int)MathF.Floor(boxMax.X / LandDefs.CellLength); + int d = (int)MathF.Floor(boxMax.Y / LandDefs.CellLength); + + if (a - baseX < minDX) minDX = a - baseX; // 0x005335a2 + if (b - baseY < minDY) minDY = b - baseY; // 0x005335b4 + if (c - baseX > maxDX) maxDX = c - baseX; // 0x005335c2 + if (d - baseY > maxDY) maxDY = d - baseY; // 0x005335d5 + } + + AddCellBlock(gx + minDX, gy + minDY, gx + maxDX, gy + maxDY, candidates); + return true; + } + + /// + /// CLandCell::add_cell_block @0x005331d0 (pc:317202): both loops are + /// INCLUSIVE (0x0053324d / 0x00533246 jle) and the rectangle + /// is FILLED. Coordinates are GLOBAL lcoords, so the landblock prefix is + /// re-derived per cell and the rectangle crosses landblock boundaries + /// freely — that re-derivation is 's + /// , whose + /// rejection IS retail's + /// 0 <= v < 0x7f8 clamp at 0x005331f0-0x00533206. + /// + private static void AddCellBlock( + int x0, int y0, int x1, int y1, + ICollection candidates) + { + for (int x = x0; x <= x1; x++) + for (int y = y0; y <= y1; y++) + AddOutsideCell(candidates, x, y); + } + private static void AddOutsideCell(ICollection candidates, int lx, int ly) { // CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check, @@ -500,8 +640,13 @@ public static class CellTransit } /// - /// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder — the - /// sphere-overlap portal flood retail runs at SHADOW REGISTRATION time. + /// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder for an + /// object with NO physics BSP — the sphere-overlap portal flood retail + /// runs at SHADOW REGISTRATION time for the cylsphere and sorting-sphere + /// branches. It is ONE of TWO registration floods: a BSP-bearing object + /// takes instead + /// (CPhysicsObj::calc_cross_cells @0x00515230 dispatches on + /// HAS_PHYSICS_BSP_PS at 0x00515285). /// Verbatim port of CObjCell::find_cell_list (Ghidra 0x0052b4e0, /// pc:308742) as invoked by CPhysicsObj::calc_cross_cells / /// calc_cross_cells_static (Ghidra 0x00515230 / 0x00515160): @@ -657,6 +802,184 @@ public static class CellTransit return candidates.OrderedIds; } + /// + /// #334 (2026-08-06). Registration-side cell-set builder for a + /// PHYSICS-BSP-BEARING object — retail's OTHER cross-cell algorithm, which + /// acdream had never implemented. Port of + /// CPhysicsObj::find_bbox_cell_list @0x00510fc0 (pc:279006), the + /// branch CPhysicsObj::calc_cross_cells @0x00515230 takes at + /// 0x00515285 test dword [esi+0xa8],0x10000 / + /// 0x0051528f jne 0x515305HAS_PHYSICS_BSP_PS + /// (acclient.h:2833). ports the + /// branches BELOW that jump (cylspheres, then the sorting sphere) and + /// remains correct for them. + /// + /// + /// find_bbox_cell_list forms no bounding box itself — it is a + /// worklist. It seeds the array with the object's OWN cell + /// (0x00510fe2 CELLARRAY::add_cell) and walks it while it grows, + /// re-reading num_cells each iteration + /// (0x00510ff8 / 0x00511017 / 0x0051101d jb), + /// dispatching each array cell through + /// CPartArray::calc_cross_cells_static @0x00518160 — a forwarding + /// thunk to cell->vtable[0x7c] (0x00518176; + /// CObjCell's vftable base 0x007c8b20 + 0x7c = + /// 0x007c8b9c, holding 0x0052b080, the four-argument + /// part-array find_transit_cells). The boxes are formed one and two + /// levels down: outdoors in + /// + /// (CLandCell::find_transit_cells @0x00533840 = + /// add_all_outside_cells @0x00533360 + the CSortCell + /// @0x00534080 building bridge), indoors in + /// CEnvCell::find_transit_cells @0x0052cae0. + /// + /// + /// + /// Note the difference from 's seed: the + /// sphere overload calls add_all_outside_cells AT SEED TIME for an + /// outdoor id (CObjCell::find_cell_list 0x0052b53f); + /// find_bbox_cell_list does not — the outdoor expansion happens + /// only when the walk reaches a landcell, under the same once-per-flood + /// CELLARRAY::added_outside latch (0x0053336c). And it is + /// ONE rectangle over all parts, run ONCE, not a per-part loop. + /// + /// + /// + /// DIVERGENCE (registered, AP-159): the INDOOR half of retail's part-array + /// overload — box-vs-portal-plane + /// (BBox::LocalToLocal @0x005b1e60 + Plane::intersect_box + /// @0x005aa170 at 0x0052cbf9/0x0052cc05) and + /// CCellStruct::box_intersects_cell @0x00533910 — is NOT ported + /// here. Indoor candidates keep the sphere-vs-portal traversal + /// already runs, from the same + /// per-part BSP root spheres, which is byte-for-byte the behaviour every + /// BSP object had before #334. AP-156's row already names that port as its + /// open residual; #334 is the OUTDOOR half of it. + /// + /// + /// Per-part world-placed authored boxes — the + /// outdoor extent walk's input. + /// Per-part world-placed BSP root spheres — + /// the indoor residual's and the building bridge's input. Same parts, same + /// order, from the same values. + public static IReadOnlyList BuildShadowCellSetFromParts( + PhysicsDataCache cache, + uint seedCellId, + IReadOnlyList worldParts, + IReadOnlyList worldPartSpheres, + bool isStatic) + { + var candidates = new CellArray(); + if (seedCellId == 0u || worldParts is null || worldParts.Count == 0) + return candidates.OrderedIds; + + int sphereCount = + EffectiveSphereCount(worldPartSpheres, worldPartSpheres?.Count ?? 0); + + uint seedLow = seedCellId & 0xFFFFu; + cache.CellGraph.TryGetTerrainOrigin(seedCellId, out var blockOrigin); + + // SEED. 0x00510fd5-0x00510fe2 adds the object's own cell by id; + // 0x00510fed / 0x00510ff6 then skip the walk when the cell or the part + // array is null. + // + // DEVIATION (registered, AD-40): the outdoor rectangle runs at seed + // time here, not only from the walk. Retail can gate everything on + // obj->cell because a placed CPhysicsObj always has a resident + // CObjCell; acdream's CellGraph residency is transiently false during + // streaming (#168 / #169), and deferring the rectangle to the walk + // would drop a static or a live entity to a single cell for the window + // before its landblock publishes. This is the SAME residency policy + // BuildShadowCellSet already applies to its outdoor seed + // (CObjCell::find_cell_list 0x0052b53f, ahead of the arg4 walk gate), + // so the two registration floods differ only in sphere-vs-box — which + // is the whole of #334 — and the direction is over-inclusive. + bool outdoorAdded = false; // CELLARRAY::added_outside + bool seedLoaded; + if (seedLow >= 0x0100u) + { + candidates.Add(seedCellId); + seedLoaded = cache.GetCellStruct(seedCellId) is not null; + } + else + { + candidates.Add(seedCellId); + outdoorAdded = AddAllOutsideCellsFromParts( + worldParts, seedCellId, blockOrigin, candidates); + seedLoaded = cache.CellGraph.GetVisible(seedCellId) is not null; + } + + if (!seedLoaded) + return candidates.OrderedIds; + + for (int i = 0; i < candidates.Count; i++) + { + uint cellId = candidates.OrderedIds[i]; + if ((cellId & 0xFFFFu) >= 0x0100u) + { + var cell = cache.GetCellStruct(cellId); + if (cell is null) continue; // 0x00511009 null cell pointer + + if (sphereCount == 0) continue; + FindTransitCellsSphere( + cache, cell, cellId, worldPartSpheres!, sphereCount, + candidates, out bool exitStraddle); + + if (exitStraddle && !outdoorAdded) + { + outdoorAdded = AddAllOutsideCellsFromParts( + worldParts, seedCellId, blockOrigin, candidates); + } + } + else + { + if (cache.CellGraph.GetVisible(cellId) is null) + continue; + + // CLandCell::find_transit_cells @0x00533840: + // add_all_outside_cells (added_outside-guarded) then the + // CSortCell building bridge for this landcell's building. + if (!outdoorAdded) + { + outdoorAdded = AddAllOutsideCellsFromParts( + worldParts, seedCellId, blockOrigin, candidates); + } + + var building = cache.GetBuilding(cellId); + if (building is not null && sphereCount > 0) + { + CheckBuildingTransit( + cache, building, worldPartSpheres!, sphereCount, + candidates, out _); + } + } + } + + // Static prune (do_not_load_cells, 0x0052b66e) — indoor-seeded ONLY. + // The outdoor rectangle is deliberately unpruned: pruning it would + // re-create #334 in a new form. + if (isStatic && seedLow >= 0x0100u) + { + var seedCell = cache.GetCellStruct(seedCellId); + if (seedCell is not null) + { + var keep = new List(candidates.Count); + foreach (uint id in candidates.OrderedIds) + { + if (id == seedCellId || seedCell.VisibleCellIds.Contains(id)) + keep.Add(id); + } + if (keep.Count != candidates.Count) + { + candidates.Clear(); + foreach (uint id in keep) candidates.Add(id); + } + } + } + + return candidates.OrderedIds; + } + /// /// Verbatim port of CEnvCell::find_visible_child_cell /// (acclient_2013_pseudo_c.txt:311397). Returns the cell whose cell-BSP diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 9e58e7e1..af4f3e87 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -197,6 +197,10 @@ public sealed class PhysicsDataCache { _visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray); } + GfxObjVisualBounds? parsedBounds = + _visualBounds.TryGetValue(gfxObjId, out var cachedBounds) + ? cachedBounds + : null; if (_gfxObj.TryGetValue(gfxObjId, out GfxObjPhysics? existing)) { @@ -217,6 +221,10 @@ public sealed class PhysicsDataCache Vertices = gfxObj.VertexArray, Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray), FlatPhysicsBsp = prepared?.PhysicsBsp, + VisualBounds = prepared?.VisualBounds ?? (parsedBounds is { } pb + ? new FlatGfxObjVisualBounds( + pb.Min, pb.Max, pb.Center, pb.Radius, pb.HalfExtents) + : null), }; _gfxObj[gfxObjId] = physics; @@ -285,6 +293,7 @@ public sealed class PhysicsDataCache Radius = root.Radius, }, FlatPhysicsBsp = prepared.PhysicsBsp, + VisualBounds = prepared.VisualBounds, }); } @@ -1426,6 +1435,16 @@ public sealed class GfxObjPhysics /// omit it. /// public FlatPhysicsBsp? FlatPhysicsBsp { get; internal set; } + + /// + /// Retail CGfxObj::gfx_bound_box — the AABB of this GfxObj's vertex + /// array, filled by CGfxObj::init_end @0x00534200 and returned by + /// CPhysicsPart::GetBoundingBox @0x0050d600. Cached beside + /// so the single + /// ShadowShapeBuilder.FromLandblockBspParts resolver answers both of + /// retail's cell-membership questions from one lookup (#334). + /// + public FlatGfxObjVisualBounds? VisualBounds { get; init; } } /// Cached collision shape data for a Setup (character/creature capsule). diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index d4f0bb2a..701704ea 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -430,12 +430,29 @@ public sealed class ShadowObjectRegistry /// /// /// BR-7: the cell set is ONE flood for the whole entity (retail floods - /// per OBJECT with its full sphere set, not per part). The flood spheres - /// follow CPhysicsObj::calc_cross_cells' own EXCLUSIVE priority — - /// physics-BSP parts, else CylSpheres, else the remaining shapes — see - /// for the disassembly. A BSP part - /// contributes its ROOT BOUNDING SPHERE placed at its real center - /// (), not at the part origin. + /// per OBJECT, not per part). WHICH flood is retail's own exclusive + /// dispatch on HAS_PHYSICS_BSP_PS + /// (CPhysicsObj::calc_cross_cells @0x00515230, + /// 0x00515285 test dword [esi+0xa8],0x10000 / + /// 0x0051528f jne 0x515305): + /// + /// + /// BSP-bearing → find_bbox_cell_list @0x00510fc0, ported as + /// . Each part + /// contributes its authored BOUNDING BOX + /// (/Max), and the + /// outdoor expansion is the FILLED CELL RECTANGLE that box spans — + /// crossing landblock boundaries freely. Before #334 these objects + /// were routed through the sphere flood below, whose outdoor reach + /// is a fixed 3×3 (±24 m) regardless of radius, so any formation + /// wider than one land cell simply was not registered in its outer + /// cells. + /// otherwise → + + /// , retail's + /// cylsphere and sorting-sphere branches, byte-identical to before + /// #334 for every object that legitimately is spherical. + /// + /// /// Every shape row is then written into every flooded cell, mirroring /// add_shadows_to_cells (0x00514ae0) + CPartArray::AddPartsShadow. /// @@ -460,9 +477,35 @@ public sealed class ShadowObjectRegistry : DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId); if (seed == 0u) return; - var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes); - var cellSet = CellTransit.BuildShadowCellSet( - FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic); + // Retail's exclusive dispatch, mirrored: CPartArray::CacheHasPhysicsBSP + // (0x00518110) ORs 0x10000 on the first part whose gfxobj carries a + // physics BSP, and calc_cross_cells (0x00515285) branches on that bit. + // AP-152 made shape emission BSP-exclusive, so "has a BSP shape" and + // "is a BSP object" coincide exactly as the cached retail flag does. + bool hasBsp = false; + for (int i = 0; i < shapes.Count; i++) + { + if (shapes[i].CollisionType == ShadowCollisionType.BSP) + { + hasBsp = true; + break; + } + } + + IReadOnlyList cellSet; + if (hasBsp) + { + var partBoxes = BuildFloodPartBoxes(entityWorldPos, entityWorldRot, shapes); + var partSpheres = BuildBspPartSpheres(entityWorldPos, entityWorldRot, shapes); + cellSet = CellTransit.BuildShadowCellSetFromParts( + FloodCache, seed, partBoxes, partSpheres, isStatic); + } + else + { + var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes); + cellSet = CellTransit.BuildShadowCellSet( + FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic); + } if (cellSet.Count == 0) return; DeregisterCore(entityId, publishMutation: false); @@ -598,25 +641,13 @@ public sealed class ShadowObjectRegistry } /// - /// Retail cross-cell dispatch, CPhysicsObj::calc_cross_cells - /// @0x00515230, in retail's own priority order: + /// Flood spheres for an object with NO physics BSP — retail's cylsphere + /// and sorting-sphere branches of CPhysicsObj::calc_cross_cells + /// @0x00515230, both of which sit BELOW the HAS_PHYSICS_BSP_PS jump + /// at 0x0051528f jne 0x515305 and are unreachable from it: /// /// - /// BSP-bearing (0x00515285 test dword [esi+0xa8],0x10000 / - /// 0x0051528f jne 0x515305) → CPhysicsObj::find_bbox_cell_list - /// @0x00510fc0. The cylsphere and sorting-sphere branches are BOTH below - /// that jump and unreachable from it. find_bbox_cell_list adds the - /// object's own cell and then walks the PART ARRAY through - /// CPartArray::calc_cross_cells_static @0x00518160's - /// [vtbl+0x7c] dispatch, whose EnvCell body - /// (CEnvCell::find_transit_cells @0x0052cae0) tests each part's - /// CGfxObj::physics_sphere — the BSP root bounding sphere, center - /// transformed through the part's own Position — against the cell's - /// portal planes. acdream floods from those same per-part spheres - /// ( + ) - /// rather than walking portal planes per part; the sphere set is exact, - /// the traversal is the sphere-vs-portal one (AP-156). - /// else cylspheres (0x00515298 GetNumCylsphere non-zero) → + /// cylspheres (0x00515298 GetNumCylsphere non-zero) → /// CObjCell::find_cell_list @0x0052b9f0 over the cylsphere array; /// each contributes one sphere at its world BASE point with the cylinder /// radius, capped at 10. @@ -626,15 +657,17 @@ public sealed class ShadowObjectRegistry /// /// /// - /// The BSP-first rule is redundant for every shape list acdream produces - /// today — dispatches at - /// emission (AP-152) and both landblock-static publishers emit - /// homogeneous lists — exactly as - /// Transition.BspOnlyDispatch is redundant at the query site. It is - /// kept because retail genuinely dispatches here, and because a producer - /// that handed this method a mixed list would otherwise flood a - /// BSP-bearing object from its primitive and silently place it in the - /// wrong shadow cells (the #98 / #168 symptom class). + /// #334: there is no BSP arm here any more, and there must not be one. + /// The BSP branch is a structurally different algorithm over BOXES + /// (), and + /// routes to it before this method is + /// reached. The arm this method used to carry — "a BSP part contributes + /// its ROOT BOUNDING SPHERE placed at its real center" — described + /// retail's INDOOR portal reject, not its outdoor expansion, and using it + /// for both is what capped every BSP object's outdoor reach at a 3×3 + /// neighbourhood. A BSP shape reaching this method would be a dispatch + /// bug; it is skipped rather than flooded from, so it cannot silently + /// produce the wrong cells (the #98 / #168 symptom class). /// /// private static List BuildFloodSpheres( @@ -645,21 +678,16 @@ public sealed class ShadowObjectRegistry const int RetailSphereCap = 10; var spheres = new List(); - bool anyBsp = false; bool anyCyl = false; foreach (var s in shapes) { - if (s.CollisionType == ShadowCollisionType.BSP) anyBsp = true; - else if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true; + if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true; } - // Retail's branch, chosen once: BSP-bbox, else cylspheres, else the - // sorting sphere (which acdream approximates with the remaining - // shapes' bounding spheres — AP-157). - ShadowCollisionType? only = - anyBsp ? ShadowCollisionType.BSP - : anyCyl ? ShadowCollisionType.Cylinder - : null; + // Retail's branch, chosen once: cylspheres, else the sorting sphere + // (which acdream approximates with the Sphere shapes — AP-157). + ShadowCollisionType only = + anyCyl ? ShadowCollisionType.Cylinder : ShadowCollisionType.Sphere; // The 10-sphere clamp belongs to the CYLSPHERE branch alone. // CObjCell::find_cell_list @0x0052b9f0 clamps the cylsphere count at @@ -667,41 +695,29 @@ public sealed class ShadowObjectRegistry // fixed static-buffer capacity (the destination array at // 0x844838..0x8448d8 is exactly ten 16-byte entries), not a policy. // - // BSP branch: NO CAP, and this is a retail port. find_bbox_cell_list - // @0x00510fc0 -> CPartArray::calc_cross_cells_static @0x00518160 -> - // CEnvCell::find_transit_cells @0x0052cae0 walks every part, bounded - // only by num_parts. Clamping it dropped parts 11..N out of the flood - // entirely: 7 installed Setups carry more than 10 physics-BSP parts - // (max 49, Setup 0x02001A91), and landblock-baked part arrays — stair - // runs, fences, rock clusters — routinely do. - // - // only == null (the sorting-sphere branch): int.MaxValue is NOT a - // retail port and the addresses above do not justify it. Retail's - // overload @0x0052b990 pushes a literal 1 (0x0052b9d6 push 1) and - // floods from ONE authored CSetup::sorting_sphere. acdream floods from - // every Sphere shape instead — a different DAT field with a different - // cardinality, which is AP-157, filed and open. Capping at 1 HERE would - // not move toward retail: it would take Spheres[0], which is not the - // sorting sphere. int.MaxValue keeps the substitution in its safe - // (over-inclusive) direction until AP-157 ports the real field. Inert - // over installed data — max 5 Spheres on any Setup (0x020016F7). + // Sorting-sphere branch: int.MaxValue is NOT a retail port and the + // addresses above do not justify it. Retail's overload @0x0052b990 + // pushes a literal 1 (0x0052b9d6 push 1) and floods from ONE authored + // CSetup::sorting_sphere. acdream floods from every Sphere shape + // instead — a different DAT field with a different cardinality, which + // is AP-157, filed and open. Capping at 1 HERE would not move toward + // retail: it would take Spheres[0], which is not the sorting sphere. + // int.MaxValue keeps the substitution in its safe (over-inclusive) + // direction until AP-157 ports the real field. Inert over installed + // data — max 5 Spheres on any Setup (0x020016F7). int cap = only == ShadowCollisionType.Cylinder ? RetailSphereCap : int.MaxValue; foreach (var s in shapes) { - if (only is { } required && s.CollisionType != required) + if (s.CollisionType != only) continue; if (spheres.Count >= cap) break; - // Place the sphere where the GEOMETRY is, not where the part - // origin is. Composed exactly as the ShadowEntry rows below are - // (partWorldPos / partWorldRot), then offset by the shape's own - // BoundsCenter — retail's CEnvCell::find_transit_cells @0x0052cae0 - // transforms CGfxObj::physics_sphere's center through the part's - // Position at [part+0x30] before reading its radius at - // 0x0052cb65. Primitives carry BoundsCenter == Zero because their - // LocalPosition already is their center. + // A primitive's LocalPosition already IS its centre, so + // BoundsCenter is Zero; the composition is kept identical to the + // emitted ShadowEntry rows so the flood and the geometry can never + // disagree about where the shape is. var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot); var partWorldRot = entityWorldRot * s.LocalRotation; var world = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot); @@ -715,6 +731,66 @@ public sealed class ShadowObjectRegistry return spheres; } + /// + /// #334: the per-part world-placed authored boxes retail's + /// CLandCell::add_all_outside_cells @0x00533360 divides by + /// square_length. Composed exactly as the emitted + /// rows are, so the flood rectangle and the + /// collision geometry describe the same placement. + /// + private static List BuildFloodPartBoxes( + Vector3 entityWorldPos, + Quaternion entityWorldRot, + System.Collections.Generic.IReadOnlyList shapes) + { + var boxes = new List(shapes.Count); + foreach (var s in shapes) + { + if (s.CollisionType != ShadowCollisionType.BSP) + continue; + boxes.Add(ShadowPartBox.FromShape(s, entityWorldPos, entityWorldRot)); + } + return boxes; + } + + /// + /// The per-part BSP ROOT bounding spheres retail's part-array + /// CEnvCell::find_transit_cells @0x0052cae0 loads at + /// 0x0052cb36 mov esi,[ecx+0x74], transforms through the part's own + /// Position (0x0052cb4c / Position::localtolocal) and reads + /// the radius from at 0x0052cb65 fadd [esi+0xc]. + /// + /// + /// These drive ONLY the indoor half of the BSP flood and the outdoor + /// building bridge (CEnvCell::check_building_transit @0x0052c5d0), + /// which still use the sphere traversal — the AP-159 residual. The + /// outdoor expansion uses and never + /// these. No cap: find_bbox_cell_list walks every part, bounded + /// only by num_parts (7 installed Setups carry more than 10 + /// physics-BSP parts, max 49 on Setup 0x02001A91). + /// + /// + private static List BuildBspPartSpheres( + Vector3 entityWorldPos, + Quaternion entityWorldRot, + System.Collections.Generic.IReadOnlyList shapes) + { + var spheres = new List(shapes.Count); + foreach (var s in shapes) + { + if (s.CollisionType != ShadowCollisionType.BSP) + continue; + var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot); + var partWorldRot = entityWorldRot * s.LocalRotation; + spheres.Add(new DatReaderWriter.Types.Sphere + { + Origin = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot), + Radius = s.Radius, + }); + } + return spheres; + } + /// /// Derive the outdoor landcell id under a world position — the implicit /// seed for landblock-baked statics registered without a cell id diff --git a/src/AcDream.Core/Physics/ShadowPartBox.cs b/src/AcDream.Core/Physics/ShadowPartBox.cs new file mode 100644 index 00000000..d3de6972 --- /dev/null +++ b/src/AcDream.Core/Physics/ShadowPartBox.cs @@ -0,0 +1,171 @@ +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// One physics-BSP part's flood geometry, resolved as ONE value: the part +/// GfxObj's physics-BSP root bounding sphere AND the axis-aligned box of its +/// vertex array, both in the GfxObj's own unscaled frame. +/// +/// +/// The pairing is the AP-156 invariant applied a second time. Retail's cell +/// membership reads BOTH — CEnvCell::find_transit_cells @0x0052cae0 +/// takes CGfxObj::physics_sphere ([gfxobj+0x74]) for its cheap +/// portal-plane reject, and CLandCell::add_all_outside_cells @0x00533360 +/// takes CPhysicsPart::GetBoundingBox @0x0050d600 +/// (&gfxobj->gfx_bound_box) for the outdoor extent walk. A resolver +/// that answered only one of the two would leave the other call site to +/// synthesize a substitute, which is exactly how the sphere came to be placed +/// at the part origin (AP-156) and how #334's outdoor rectangle came to be a +/// fixed 3×3. +/// +/// +/// +/// / come from +/// FlatGfxObjVisualBounds, which +/// FlatCollisionAssetBuilder.FlattenGfxObj computes with +/// PhysicsDataCache.ComputeVisualBounds(source.VertexArray) — the exact +/// CGfxObj::init_end @0x00534200 computation (seed min=max=vertices[0], +/// then BBox::AdjustBBox over every vertex of the render vertex array, +/// which is also the array the physics polygons index into). +/// +/// +public readonly record struct ShadowPartGeometry +{ + private ShadowPartGeometry( + FlatCollisionSphere sphere, + Vector3 boxMin, + Vector3 boxMax) + { + Sphere = sphere; + BoxMin = boxMin; + BoxMax = boxMax; + } + + /// Retail CGfxObj::physics_sphere — the physics BSP root + /// bounding sphere, origin included, unscaled. + public FlatCollisionSphere Sphere { get; } + + /// Retail CGfxObj::gfx_bound_box.m_vMin, unscaled. + public Vector3 BoxMin { get; } + + /// Retail CGfxObj::gfx_bound_box.m_vMax, unscaled. + public Vector3 BoxMax { get; } + + /// + /// Pairs the two. is the prepared + /// package's FlatGfxObjVisualBounds; when it is absent (graph-only + /// fixtures, and prepared assets baked without the field) the box falls + /// back to the sphere's own axis-aligned bound, which contains every + /// physics polygon vertex the sphere contains and keeps the substitution + /// in the over-inclusive direction retail itself uses (§1.8 of + /// docs/research/2026-08-06-334-contract.md). The fallback lives + /// HERE so no call site can observe a half-populated value. + /// + public static ShadowPartGeometry Create( + FlatCollisionSphere sphere, + FlatGfxObjVisualBounds? visualBounds) + { + if (visualBounds is { } bounds) + return new ShadowPartGeometry(sphere, bounds.Min, bounds.Max); + + var extent = new Vector3(sphere.Radius); + return new ShadowPartGeometry( + sphere, + sphere.Origin - extent, + sphere.Origin + extent); + } +} + +/// +/// One physics-BSP part's world-placed bounding box — the per-part input to +/// retail's outdoor extent walk. +/// +/// +/// Retail's CLandCell::add_all_outside_cells @0x00533360 calls +/// BBox::LocalToGlobal(part->gfxobj->gfx_bound_box, part->pos, +/// cell0->pos) (@0x00533527) per part, so the box it divides by +/// square_length is the part's authored box re-fit through the part's +/// own placement. / are that +/// authored box (entity-scaled); / +/// are the part placement +/// composes for the +/// ShadowEntry rows, so the flood and the geometry can never disagree +/// about where the part is. +/// +/// +/// +/// CONSTRUCTION IS BY FACTORY ONLY: min and max arrive together, from one +/// , so no future producer can carry one and drop the +/// other. +/// +/// +public readonly record struct ShadowPartBox +{ + private ShadowPartBox( + Vector3 localMin, + Vector3 localMax, + Vector3 worldPosition, + Quaternion worldRotation) + { + LocalMin = localMin; + LocalMax = localMax; + WorldPosition = worldPosition; + WorldRotation = worldRotation; + } + + /// Authored box minimum in the part's own frame, entity-scaled. + public Vector3 LocalMin { get; } + + /// Authored box maximum in the part's own frame, entity-scaled. + public Vector3 LocalMax { get; } + + /// The part's world placement — retail CPhysicsPart::pos. + public Vector3 WorldPosition { get; } + + /// The part's world orientation — retail CPhysicsPart::pos. + public Quaternion WorldRotation { get; } + + /// + /// Composes the part's world placement exactly as + /// composes the + /// emitted ShadowEntry's. + /// + public static ShadowPartBox FromShape( + in ShadowShape shape, + Vector3 entityWorldPosition, + Quaternion entityWorldRotation) + => new( + shape.LocalBoundsMin, + shape.LocalBoundsMax, + entityWorldPosition + + Vector3.Transform(shape.LocalPosition, entityWorldRotation), + entityWorldRotation * shape.LocalRotation); + + /// + /// Retail BBox::LocalToGlobal @0x005b2120 — a proper EIGHT-CORNER + /// re-fit, not a min/max transform: transform min, seed both + /// corners from it, transform the other seven and AdjustBBox each. + /// A rotated box therefore GROWS, conservatively, which is the direction + /// retail deliberately errs in. + /// + /// World origin of the destination frame — + /// retail's cell0->pos, i.e. the landblock the extent walk + /// anchors on. + public void RefitTo(Vector3 frameOrigin, out Vector3 min, out Vector3 max) + { + Vector3 offset = WorldPosition - frameOrigin; + min = new Vector3(float.MaxValue); + max = new Vector3(float.MinValue); + for (int corner = 0; corner < 8; corner++) + { + var local = new Vector3( + (corner & 1) == 0 ? LocalMin.X : LocalMax.X, + (corner & 2) == 0 ? LocalMin.Y : LocalMax.Y, + (corner & 4) == 0 ? LocalMin.Z : LocalMax.Z); + Vector3 world = Vector3.Transform(local, WorldRotation) + offset; + min = Vector3.Min(min, world); + max = Vector3.Max(max, world); + } + } +} diff --git a/src/AcDream.Core/Physics/ShadowShape.cs b/src/AcDream.Core/Physics/ShadowShape.cs index b559d88c..77685ca1 100644 --- a/src/AcDream.Core/Physics/ShadowShape.cs +++ b/src/AcDream.Core/Physics/ShadowShape.cs @@ -20,12 +20,13 @@ namespace AcDream.Core.Physics; /// CONSTRUCTION IS BY FACTORY ONLY (, , /// ) and the constructor is private. That is the AP-156 /// invariant expressed at the type rather than only at the producer: a BSP -/// shape's radius and its bounding-sphere CENTRE arrive as one -/// value and are scaled together inside -/// , so no call site — present or future — can take the -/// radius while dropping the origin. That split is exactly what produced -/// AP-156, and with the old public 7-argument constructor a new BSP producer -/// could have reintroduced it silently and green. +/// shape's radius, its bounding-sphere CENTRE, and its authored bounding BOX +/// arrive as one value and are scaled together +/// inside , so no call site — present or future — can take one +/// while dropping another. Those splits are exactly what produced AP-156 (the +/// centre dropped) and #334 (the box never resolved at all); with a public +/// positional constructor a new BSP producer could reintroduce either silently +/// and green. /// /// public readonly record struct ShadowShape @@ -38,7 +39,9 @@ public readonly record struct ShadowShape ShadowCollisionType collisionType, float radius, float cylHeight, - Vector3 boundsCenter) + Vector3 boundsCenter, + Vector3 localBoundsMin, + Vector3 localBoundsMax) { GfxObjId = gfxObjId; LocalPosition = localPosition; @@ -48,6 +51,8 @@ public readonly record struct ShadowShape Radius = radius; CylHeight = cylHeight; BoundsCenter = boundsCenter; + LocalBoundsMin = localBoundsMin; + LocalBoundsMax = localBoundsMax; } /// Source GfxObj id, for the BSP walk and for diagnostics. @@ -107,27 +112,51 @@ public readonly record struct ShadowShape public Vector3 BoundsCenter { get; } /// - /// One physics-BSP part. is the part - /// GfxObj's physics-BSP ROOT bounding sphere in the GfxObj's OWN frame, - /// unscaled — retail's CGfxObj::physics_sphere. Radius and centre - /// are scaled together here, which is the whole point of taking them as - /// one value. + /// The shape's AXIS-ALIGNED BOX in the same local frame as + /// , already entity-scaled. For a BSP shape this + /// is retail's CGfxObj::gfx_bound_box — the AABB of the GfxObj's + /// vertex array, which CPhysicsPart::GetBoundingBox @0x0050d600 + /// returns and which CLandCell::add_all_outside_cells @0x00533360 + /// divides by square_length to build the outdoor cell rectangle + /// (#334). Primitive shapes carry their own radius/height box; they never + /// reach that path, because CPhysicsObj::calc_cross_cells + /// @0x00515230 routes only HAS_PHYSICS_BSP_PS objects to + /// find_bbox_cell_list. + /// + public Vector3 LocalBoundsMin { get; } + + /// + public Vector3 LocalBoundsMax { get; } + + /// + /// One physics-BSP part. carries the part + /// GfxObj's physics-BSP ROOT bounding sphere AND its vertex-array box in + /// the GfxObj's OWN frame, unscaled — retail's + /// CGfxObj::physics_sphere and CGfxObj::gfx_bound_box. + /// Sphere radius, sphere centre, and both box corners are scaled together + /// here, which is the whole point of taking them as one value: retail + /// reads the sphere for the indoor portal reject and the box for the + /// outdoor extent walk, and a producer that supplied one without the + /// other would silently force a substitute at the other call site + /// (AP-156, then #334). /// public static ShadowShape Bsp( uint gfxObjId, Vector3 localPosition, Quaternion localRotation, float scale, - FlatCollisionSphere localBounds) + ShadowPartGeometry localGeometry) => new( gfxObjId, localPosition, localRotation, scale, ShadowCollisionType.BSP, - localBounds.Radius * scale, + localGeometry.Sphere.Radius * scale, 0f, - localBounds.Origin * scale); + localGeometry.Sphere.Origin * scale, + localGeometry.BoxMin * scale, + localGeometry.BoxMax * scale); /// /// One Setup CylSphere. already IS the @@ -148,7 +177,9 @@ public readonly record struct ShadowShape ShadowCollisionType.Cylinder, radius, cylHeight, - Vector3.Zero); + Vector3.Zero, + new Vector3(-radius, -radius, 0f), + new Vector3(radius, radius, cylHeight)); /// /// One Setup Sphere. already IS the @@ -168,5 +199,7 @@ public readonly record struct ShadowShape ShadowCollisionType.Sphere, radius, 0f, - Vector3.Zero); + Vector3.Zero, + new Vector3(-radius), + new Vector3(radius)); } diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs index f121e481..35b532fd 100644 --- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs +++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs @@ -94,21 +94,25 @@ public static class ShadowShapeBuilder /// index and pose, but reads PhysicsBSP from the installed replacement. /// Null or short lists fall back to the Setup identity. /// The part GfxObj's physics-BSP ROOT - /// bounding sphere — retail's CGfxObj::physics_sphere, which is - /// literally BSPTREE::GetSphere(physics_bsp) @0x005397e0. Supplies - /// BOTH the emitted and its - /// , from one call, so the sphere's - /// size can never be carried while its position is dropped. Null (or a - /// null result) falls back to the loose-but-safe 2 m placeholder at the - /// part origin — a fixture-only configuration; production always supplies - /// it (LiveEntityCollisionBuilder). + /// bounding sphere AND its authored vertex-array box — retail's + /// CGfxObj::physics_sphere (BSPTREE::GetSphere(physics_bsp) + /// @0x005397e0) and CGfxObj::gfx_bound_box + /// (CPhysicsPart::GetBoundingBox @0x0050d600), as ONE + /// . Supplies the emitted + /// , + /// and /Max from one call, + /// so no part of the flood geometry can be carried while another is + /// dropped (AP-156, then #334). Null (or a null result) falls back to the + /// loose-but-safe 2 m placeholder at the part origin — a fixture-only + /// configuration; production always supplies it + /// (LiveEntityCollisionBuilder). public static IReadOnlyList FromSetup( Setup setup, float entScale, Func hasPhysicsBsp, IReadOnlyList? partPoseOverride = null, IReadOnlyList? effectivePartGfxObjIds = null, - Func? physicsBspBounds = null) + Func? physicsBspBounds = null) { if (setup is null) throw new ArgumentNullException(nameof(setup)); if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp)); @@ -210,17 +214,27 @@ public static class ShadowShapeBuilder // supplies both so one cannot be taken without the other. // Absent bounds keep the loose-but-safe 2 m placeholder, centred // on the part origin because nothing better is known. - // ShadowShape.Bsp scales radius and centre together. - FlatCollisionSphere bounds = + // ShadowShape.Bsp scales radius, centre and box together. + // + // #334: the SAME resolver also supplies the authored vertex-array + // box. Retail's outdoor cell membership + // (CLandCell::add_all_outside_cells @0x00533360, reached from + // find_bbox_cell_list @0x00510fc0) divides that box — never the + // sphere — by square_length to build its cell rectangle, so a + // resolver that answered only the sphere would leave that walk + // with nothing to walk. + ShadowPartGeometry geometry = physicsBspBounds?.Invoke(gfxId) - ?? new FlatCollisionSphere(Vector3.Zero, 2f); + ?? ShadowPartGeometry.Create( + new FlatCollisionSphere(Vector3.Zero, 2f), + null); result.Add(ShadowShape.Bsp( gfxObjId: gfxId, localPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale, localRotation: partFrame.Orientation, scale: entScale, - localBounds: bounds)); + localGeometry: geometry)); } return result; @@ -308,12 +322,21 @@ public static class ShadowShapeBuilder phys.BoundingSphere?.Origin ?? Vector3.Zero, phys.BoundingSphere?.Radius ?? 1f); + // #334: the same cached record carries the authored vertex-array + // box (CGfxObj::gfx_bound_box), which retail's outdoor extent walk + // — CLandCell::add_all_outside_cells @0x00533360 — divides by + // square_length. Landblock-baked part arrays are exactly the + // population whose extent exceeds one 24 m land cell, so the + // sphere alone cannot describe their membership. + ShadowPartGeometry geometry = + ShadowPartGeometry.Create(localBounds, phys.VisualBounds); + shapes.Add(ShadowShape.Bsp( gfxObjId: meshRef.GfxObjId, localPosition: pPos, localRotation: pRot, scale: partScale, - localBounds: localBounds)); + localGeometry: geometry)); } return shapes; diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs index 3f9ddd80..320034d5 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs @@ -412,8 +412,10 @@ public sealed class LiveEntityCollisionBuilderTests /// radius). A fixture pinned at Vector3.Zero cannot observe the /// centre at all — which is how AP-156's discarded origin stayed green. /// - private static FlatCollisionSphere? Bsp(float radius, float centerZ = 1.25f) - => new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius); + private static ShadowPartGeometry? Bsp(float radius, float centerZ = 1.25f) + => ShadowPartGeometry.Create( + new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius), + null); private static LiveEntityDefaultPoseResolver PoseResolver() => new( _ => null, diff --git a/tests/AcDream.App.Tests/Physics/PvpBitfieldSurvivesAppearanceRebuildTests.cs b/tests/AcDream.App.Tests/Physics/PvpBitfieldSurvivesAppearanceRebuildTests.cs index b2f23b66..40136a30 100644 --- a/tests/AcDream.App.Tests/Physics/PvpBitfieldSurvivesAppearanceRebuildTests.cs +++ b/tests/AcDream.App.Tests/Physics/PvpBitfieldSurvivesAppearanceRebuildTests.cs @@ -143,8 +143,10 @@ public sealed class PvpBitfieldSurvivesAppearanceRebuildTests setup.Parts.Add(0x0100AB01u); var builder = new LiveEntityCollisionBuilder( id => id == 0x0100AB01u - ? new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 1f) - : null, + ? ShadowPartGeometry.Create( + new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 1f), + null) + : (ShadowPartGeometry?)null, new LiveEntityDefaultPoseResolver( _ => null, new NullAnimationLoader(), diff --git a/tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs b/tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs index 460c8ae7..7343f4d7 100644 --- a/tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs +++ b/tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs @@ -339,7 +339,9 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests setup, EntScale, id => Bounds(id) is not null, - physicsBspBounds: Bounds); + physicsBspBounds: id => Bounds(id) is { } sphere + ? ShadowPartGeometry.Create(sphere, null) + : (ShadowPartGeometry?)null); // ShadowObjectRegistry.BuildFloodSpheres' composition, at an // entity placed at the world origin with identity rotation. diff --git a/tests/AcDream.Content.Tests/Issue334NeftetFormationCellMembershipTests.cs b/tests/AcDream.Content.Tests/Issue334NeftetFormationCellMembershipTests.cs new file mode 100644 index 00000000..a430c835 --- /dev/null +++ b/tests/AcDream.Content.Tests/Issue334NeftetFormationCellMembershipTests.cs @@ -0,0 +1,142 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; + +namespace AcDream.Content.Tests; + +/// +/// #334, replayed against the installed DATs from the user's own live +/// evidence (334-neftet-probe.log, 8,401 lines, 2026-08-06). +/// +/// +/// Standing inside the Neftet rock formation the broadphase reported +/// inCell=2 exempt=2 reached=0 — the formation was not in the player's +/// cell at all. The one object that DID block, GfxObj 0x010046D8 +/// (root bounding sphere radius 69.471 m, bounds centre 34.977 m off the part +/// origin), was measured PRESENT in cells 0x8764000A and +/// 0x87640012 and ABSENT from 0x87640011, 0x87640019 and +/// 0x87630018. Those five observations have exactly one explanation: +/// a 3×3 land-cell neighbourhood centred on 0x87640013 — the cell +/// under the object's own position — which is what +/// CellTransit.AddAllOutsideCells produces for ANY sphere, because its +/// minRad = radius / maxRad = 24 - radius boundary tests are +/// unconditionally true above 12 m and it only ever adds the eight +/// neighbours. +/// +/// +/// +/// This test asserts the observed reality rather than a constant: the two +/// cells the probe measured EMPTY must be occupied, and the two it measured +/// POPULATED must stay occupied. It fails at f0588725 (the sphere route +/// cannot reach cellY 0 from a centre at cellY 2) and passes with the box +/// route. The expected rectangle was derived from the object's own +/// CGfxObj::gfx_bound_box read out of client_portal.dat, not +/// from running the code under test: the box is 96 m × 96 m about a part +/// origin at block-local (63.78, 56.29), i.e. cell (2,2), so it spans cell +/// columns 0..4 on both axes. +/// +/// +public sealed class Issue334NeftetFormationCellMembershipTests +{ + private const uint NeftetLandblock = 0x87640000u; + private const uint NeftetLandblockInfo = 0x8764FFFEu; + private const uint FormationGfxObj = 0x010046D8u; + + // The four cells named in the probe log, by their measured disposition. + private const uint MeasuredPresentA = 0x8764000Au; // lcoord (1081, 801) + private const uint MeasuredPresentB = 0x87640012u; // lcoord (1082, 801) + private const uint MeasuredAbsentA = 0x87640011u; // lcoord (1082, 800) + private const uint MeasuredAbsentB = 0x87640019u; // lcoord (1083, 800) + + [Fact] + public void NeftetFormation_RegistersInTheCellsTheProbeMeasuredEmpty() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + Assert.True( + dats.Cell.TryGet(NeftetLandblockInfo, out LandBlockInfo? info) + && info is not null, + "Neftet landblock info 0x8764FFFE is absent from the installed cell dat."); + + // The probe's entity 0xC8764000 is stab index 0 of this landblock + // (LandblockStaticEntityIdAllocator's 0xCXXYYIII packing). + Stab formation = info!.Objects.First(o => o.Id == FormationGfxObj); + + Assert.True( + dats.Portal.TryGet(FormationGfxObj, out GfxObj? gfx) && gfx is not null, + "GfxObj 0x010046D8 is absent from the installed portal dat."); + + // Control: the fixture must be non-degenerate on the axis under test. + // A box that fits inside its own bounding sphere makes the box route + // and the sphere route agree, and proves nothing. + var cache = new PhysicsDataCache(); + cache.CacheGfxObj(FormationGfxObj, gfx!); + GfxObjPhysics? phys = cache.GetGfxObj(FormationGfxObj); + Assert.NotNull(phys); + Assert.NotNull(phys!.VisualBounds); + FlatGfxObjVisualBounds box = phys.VisualBounds!.Value; + float extentX = box.Max.X - box.Min.X; + float extentY = box.Max.Y - box.Min.Y; + float rootRadius = phys.BoundingSphere!.Radius; + Assert.True( + extentX > rootRadius && extentY > rootRadius, + $"Fixture is degenerate: extent ({extentX:F2}, {extentY:F2}) does not " + + $"exceed the root sphere radius {rootRadius:F3}."); + Assert.True( + extentX > 48f && extentY > 48f, + $"Fixture cannot reach two cells away: extent ({extentX:F2}, {extentY:F2})."); + + IReadOnlyList shapes = + ShadowShapeBuilder.FromLandblockBspParts( + new[] { new MeshRef(FormationGfxObj, Matrix4x4.Identity) }, + isBuildingShell: false, + cache.GetGfxObj); + ShadowShape only = Assert.Single(shapes); + Assert.Equal(ShadowCollisionType.BSP, only.CollisionType); + + var registry = new ShadowObjectRegistry { DataCache = cache }; + const uint ownerId = 0xC8764000u; + registry.RegisterMultiPart( + ownerId, + formation.Frame.Origin, + formation.Frame.Orientation, + shapes, + 0u, + EntityCollisionFlags.None, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: NeftetLandblock, + seedCellId: 0u, + isStatic: true); + + var held = new List(); + for (uint index = 1u; index <= 64u; index++) + { + uint cellId = NeftetLandblock | index; + if (registry.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId)) + held.Add(cellId); + } + + // The measured-populated pair must stay populated. + Assert.Contains(MeasuredPresentA, held); + Assert.Contains(MeasuredPresentB, held); + + // The measured-EMPTY pair is the #334 fact. + Assert.Contains(MeasuredAbsentA, held); + Assert.Contains(MeasuredAbsentB, held); + + // And the rectangle is the 5×5 the 96 m box spans about cell (2,2), + // clipped to this landblock's own 8×8 grid (the two columns below 0 + // land in the neighbour blocks 0x8763 / 0x8664 and are counted there). + Assert.Equal(25, held.Count); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs index f9d6ea7f..875afd23 100644 --- a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs @@ -926,7 +926,9 @@ public class DoorBugTrajectoryReplayTests s.LocalPosition, s.LocalRotation, s.Scale, - new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale))); + ShadowPartGeometry.Create( + new FlatCollisionSphere(Vector3.Zero, bspR / s.Scale), + null))); } else { @@ -1113,7 +1115,7 @@ public class DoorBugTrajectoryReplayTests localPosition: Vector3.Zero, localRotation: Quaternion.Identity, scale: 1f, - localBounds: new FlatCollisionSphere(Vector3.Zero, BspRadius)); + localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, BspRadius), null)); var cylShape = ShadowShape.Cylinder( gfxObjId: 0u, diff --git a/tests/AcDream.Core.Tests/Physics/Issue334BspBoxCellMembershipTests.cs b/tests/AcDream.Core.Tests/Physics/Issue334BspBoxCellMembershipTests.cs new file mode 100644 index 00000000..5b28d686 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue334BspBoxCellMembershipTests.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #334: a physics-BSP object's outdoor cell membership is the FILLED +/// RECTANGLE of land cells its authored bounding box spans, not a fixed 3×3 +/// neighbourhood. +/// +/// +/// Retail chain, disassembled from the PDB-paired 2013-09-06 binary: +/// CPhysicsObj::calc_cross_cells @0x00515230 +/// (0x00515285 test dword [esi+0xa8],0x10000) → +/// find_bbox_cell_list @0x00510fc0 → +/// CPartArray::calc_cross_cells_static @0x00518160 → +/// [vtbl+0x7c]CLandCell::find_transit_cells @0x00533840 → +/// add_all_outside_cells @0x00533360 → add_cell_block +/// @0x005331d0. +/// +/// +/// +/// EVERY fixture here has an XY extent that EXCEEDS its own bounding-sphere +/// radius. That is the axis under test: a box that fits inside its sphere +/// makes the new path and the old 3×3 agree, and proves nothing. The sphere +/// radius is deliberately kept at 1 m so no assertion below can be satisfied +/// by the sphere route — retail's outdoor sphere reach is hard-capped at ±1 +/// cell for ANY radius (check_add_cell_boundary compares against +/// radius and 24 - radius, both unconditionally true above +/// 12 m, and only ever adds the eight neighbours). +/// +/// +public sealed class Issue334BspBoxCellMembershipTests +{ + // Landblock (0xA9, 0xB4). Global lcoord origin = (0xA9*8, 0xB4*8). + private const uint LbId = 0xA9B40000u; + private const int GxBase = 0xA9 * 8; // 1352 + private const int GyBase = 0xB4 * 8; // 1440 + + /// Full outdoor cell id from a GLOBAL lcoord, hand-derived from + /// retail's add_cell_block packing at 0x0053320a-0x0053322e: + /// (((x>>3)<<8) | (y>>3)) << 16 | ((x&7)*8 + (y&7) + 1). + /// Written out here rather than calling LandDefs so the expectation does + /// not re-encode the code under test. + private static uint Cell(int gx, int gy) + => (uint)(((((gx >> 3) << 8) | (gy >> 3)) << 16) | ((gx & 7) * 8 + (gy & 7) + 1)); + + private static ShadowShape BspPart( + Vector3 boxMin, + Vector3 boxMax, + float sphereRadius = 1f, + Vector3 sphereCentre = default, + Vector3 localPosition = default, + Quaternion localRotation = default) + => ShadowShape.Bsp( + gfxObjId: 0x010046D8u, + localPosition: localPosition, + localRotation: localRotation == default ? Quaternion.Identity : localRotation, + scale: 1f, + localGeometry: ShadowPartGeometry.Create( + new FlatCollisionSphere(sphereCentre, sphereRadius), + new FlatGfxObjVisualBounds( + boxMin, + boxMax, + (boxMin + boxMax) * 0.5f, + ((boxMax - boxMin) * 0.5f).Length(), + (boxMax - boxMin) * 0.5f))); + + /// The sphere-only configuration the port replaced: box collapses + /// to the sphere's own AABB. Used as the in-test control that the fixture + /// is non-degenerate. + private static ShadowShape SphereOnlyPart( + float sphereRadius, + Vector3 sphereCentre = default, + Vector3 localPosition = default) + => ShadowShape.Bsp( + gfxObjId: 0x010046D8u, + localPosition: localPosition, + localRotation: Quaternion.Identity, + scale: 1f, + localGeometry: ShadowPartGeometry.Create( + new FlatCollisionSphere(sphereCentre, sphereRadius), + null)); + + private static List Rectangle( + Vector3 entityWorldPos, + uint seedCellId, + params ShadowShape[] shapes) + { + var boxes = shapes + .Select(s => ShadowPartBox.FromShape(s, entityWorldPos, Quaternion.Identity)) + .ToList(); + var candidates = new CellArray(); + CellTransit.AddAllOutsideCellsFromParts( + boxes, seedCellId, Vector3.Zero, candidates); + return candidates.OrderedIds.ToList(); + } + + // ── T1 ──────────────────────────────────────────────────────────────── + /// + /// A 100 m × 100 m box on a 1 m sphere spans five land cells per axis. + /// Sabotage: drop the box and flood from the sphere + /// () → one cell. The 5-per-axis span is + /// unreachable from ANY sphere, of any radius, through the 3×3 path. + /// + [Fact] + public void T1_HundredMetreBox_SpansFiveCellsPerAxis() + { + // Entity centred on cell (1,1): world (36, 36). Box ±50 m → world + // -14..86 per axis → floor(-14/24) = -1 .. floor(86/24) = 3, i.e. + // block-local cell columns -1..3, five per axis. + var shape = BspPart(new Vector3(-50f, -50f, -3f), new Vector3(50f, 50f, 3f)); + List cells = Rectangle(new Vector3(36f, 36f, 0f), LbId | 10u, shape); + + var expected = new List(); + for (int x = GxBase - 1; x <= GxBase + 3; x++) + for (int y = GyBase - 1; y <= GyBase + 3; y++) + expected.Add(Cell(x, y)); + + Assert.Equal(25, cells.Count); + Assert.Equal(expected.OrderBy(v => v), cells.OrderBy(v => v)); + + // Control: the same part described only by its 1 m sphere collapses. + List sphereOnly = Rectangle( + new Vector3(36f, 36f, 0f), LbId | 10u, SphereOnlyPart(1f)); + Assert.Single(sphereOnly); + Assert.Equal(Cell(GxBase + 1, GyBase + 1), sphereOnly[0]); + } + + // ── T2 ──────────────────────────────────────────────────────────────── + /// + /// The rectangle is FILLED and unioned ACROSS PARTS, not per part. Retail + /// combines the four DELTA accumulators over every part and calls + /// add_cell_block ONCE (0x00533614), so an L-shaped object + /// registers in the cells that close its L — cells its geometry never + /// enters. + /// + /// + /// The fixture is an L on purpose: one arm along +X, one along +Y. A + /// DIAGONAL fixture cannot detect the per-part sabotage, because retail + /// seeds the accumulators to ZERO (0x00533390), so each part's own + /// rectangle already spans from the base cell to that part — and for a + /// diagonal pair the two per-part rectangles union back to the same square. + /// Sabotage: emit one rectangle per part → the corner (3,3) disappears. + /// + /// + [Fact] + public void T2_LShapedPartArray_ClaimsTheCornerThatClosesTheL() + { + var box = (Min: new Vector3(-5f, -5f, -2f), Max: new Vector3(5f, 5f, 2f)); + var anchor = BspPart(box.Min, box.Max); + var eastArm = BspPart(box.Min, box.Max, localPosition: new Vector3(48f, 0f, 0f)); + var northArm = BspPart(box.Min, box.Max, localPosition: new Vector3(0f, 48f, 0f)); + + var entity = new Vector3(36f, 36f, 0f); + List cells = Rectangle(entity, LbId | 10u, anchor, eastArm, northArm); + + uint corner = Cell(GxBase + 3, GyBase + 3); + Assert.Contains(corner, cells); + Assert.Equal(9, cells.Count); + + // Control: the corner is not reachable from any part's own rectangle, + // so the containment above cannot be satisfied by a per-part union. + Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor)); + Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor, eastArm)); + Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor, northArm)); + } + + // ── T3 ──────────────────────────────────────────────────────────────── + /// + /// The rectangle crosses landblock boundaries freely: add_cell_block + /// works in GLOBAL lcoords and re-derives the block prefix per cell + /// (0x0053320a), so cells beyond column 7 carry the NEIGHBOUR + /// landblock's id. Sabotage: clamp the rectangle to the seed landblock → + /// the 0xAAB4 / 0xA9B5 rows vanish. + /// + [Fact] + public void T3_BoxPastTheBlockEdge_ProducesNeighbourLandblockCellIds() + { + // Entity on cell (7,7): world (180, 180). Box ±30 m → world 150..210 + // → cell columns 6..8; column 8 is the neighbour block's column 0. + var shape = BspPart(new Vector3(-30f, -30f, -2f), new Vector3(30f, 30f, 2f)); + List cells = Rectangle(new Vector3(180f, 180f, 0f), LbId | 64u, shape); + + Assert.Equal(9, cells.Count); + Assert.Contains(Cell(GxBase + 7, GyBase + 7), cells); // 0xA9B40040 + Assert.Contains(Cell(GxBase + 8, GyBase + 7), cells); // 0xAAB4xxxx + Assert.Contains(Cell(GxBase + 7, GyBase + 8), cells); // 0xA9B5xxxx + Assert.Contains(Cell(GxBase + 8, GyBase + 8), cells); // 0xAAB5xxxx + + Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xAAB40000u); + Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xA9B50000u); + Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xAAB50000u); + } + + // ── T4 ──────────────────────────────────────────────────────────────── + /// + /// Map bounds. add_cell_block rejects any coordinate outside + /// [0, 0x7f8) (0x005331f0-0x00533206). Sabotage: drop + /// the clamp → cells wrap into the far corner of the map or produce id 0. + /// + [Fact] + public void T4_RectangleAtTheMapCorners_EmitsNothingOutsideTheMap() + { + // SW corner: landblock (0,0), entity on cell (0,0), box ±50 m reaches + // three cells into negative lcoords on both axes. + var box = BspPart(new Vector3(-50f, -50f, -2f), new Vector3(50f, 50f, 2f)); + List sw = Rectangle(new Vector3(12f, 12f, 0f), 0x00000001u, box); + Assert.All(sw, id => Assert.NotEqual(0u, id)); + Assert.Equal(9, sw.Count); // x 0..2 × y 0..2 survive + Assert.Contains(0x00000001u, sw); + + // NE corner: landblock (254,254) — lcoords 2032..2039, the last legal + // row before 0x7f8 = 2040. + const uint neLb = 0xFEFE0000u; + int neGx = 254 * 8, neGy = 254 * 8; + List ne = Rectangle(new Vector3(180f, 180f, 0f), neLb | 64u, box); + Assert.All(ne, id => Assert.NotEqual(0u, id)); + Assert.Equal(9, ne.Count); // x 2035..2037+ y likewise + Assert.Contains(Cell(neGx + 7, neGy + 7), ne); + Assert.DoesNotContain(Cell(2040 & 0x7FF, 2040 & 0x7FF), ne); + } + + // ── T5 ──────────────────────────────────────────────────────────────── + /// + /// BBox::LocalToGlobal @0x005b2120 re-fits through ALL EIGHT + /// corners, so a rotated box grows. Sabotage: transform only + /// min and max → the −X overhang of a yawed asymmetric box + /// is lost and its westernmost cell disappears. + /// + [Fact] + public void T5_RotatedAsymmetricBox_KeepsTheCornerOverhangMinMaxWouldLose() + { + // Asymmetric box: X 0..60, Y 0..4. Yawed 37 degrees about Z the four + // XY corners land at (0,0), (47.92,36.11), (-2.41,3.19), (45.52,39.30); + // the true AABB therefore starts at x = -2.41, which is the corner a + // min/max-only transform (which sees only (0,0) and (45.52,39.30)) + // cannot produce. + Quaternion yaw37 = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, 37f * MathF.PI / 180f); + var shape = BspPart( + new Vector3(0f, 0f, 0f), new Vector3(60f, 4f, 2f), + localRotation: yaw37); + + // Entity at world x = 48 → the true box spans 45.59..95.92, crossing + // into cell column 1; the min/max-only box starts at exactly 48.0, + // which is column 2. + List cells = Rectangle(new Vector3(48f, 12f, 0f), LbId | 17u, shape); + + Assert.Contains(Cell(GxBase + 1, GyBase + 0), cells); + Assert.Contains(Cell(GxBase + 3, GyBase + 2), cells); + + // Control: unrotated, the same box starts at exactly x = 48 and never + // reaches column 1 — so the containment above is the rotation's doing. + var unrotated = BspPart(new Vector3(0f, 0f, 0f), new Vector3(60f, 4f, 2f)); + List flat = Rectangle(new Vector3(48f, 12f, 0f), LbId | 17u, unrotated); + Assert.DoesNotContain(Cell(GxBase + 1, GyBase + 0), flat); + } + + // ── T9 ──────────────────────────────────────────────────────────────── + /// + /// floor, not truncation: retail calls floor then + /// _ftol2 (0x0053353c / 0x00533542). Sabotage: + /// (int)(v / 24f) → for a box overhanging the block's SW corner, + /// -8/24 truncates to 0 and the previous landblock's column 7 is + /// silently dropped. + /// + [Fact] + public void T9_BoxOverhangingTheBlockOrigin_ReachesTheNegativeColumn() + { + var shape = BspPart(new Vector3(-20f, -20f, -2f), new Vector3(20f, 20f, 2f)); + // Entity at world (12, 12): the box spans -8..32, whose floor is -1. + List cells = Rectangle(new Vector3(12f, 12f, 0f), LbId | 1u, shape); + + Assert.Contains(Cell(GxBase - 1, GyBase - 1), cells); // 0xA8B3, cell 64 + Assert.Contains(Cell(GxBase - 1, GyBase + 0), cells); + Assert.Contains(Cell(GxBase + 0, GyBase - 1), cells); + // -8..32 → floor gives columns -1..1, three per axis. + Assert.Equal(9, cells.Count); + Assert.Contains(Cell(GxBase + 1, GyBase + 1), cells); + } + + // ── T8 ──────────────────────────────────────────────────────────────── + /// + /// adjust_to_outside failing (map edge / invalid id) makes retail + /// return before get_landcell and add nothing + /// (0x005333eb select → gid 0 → 0x00533417 je). Sabotage: + /// drop the null check → an exception or a bogus rectangle at lcoord 0. + /// + [Fact] + public void T8_BasePositionOffTheMap_AddsNothingAndDoesNotThrow() + { + var shape = BspPart(new Vector3(-5f, -5f, -2f), new Vector3(5f, 5f, 2f)); + var boxes = new List + { + ShadowPartBox.FromShape( + shape, new Vector3(-100000f, -100000f, 0f), Quaternion.Identity), + }; + var candidates = new CellArray(); + + bool added = CellTransit.AddAllOutsideCellsFromParts( + boxes, 0x00000001u, Vector3.Zero, candidates); + + Assert.False(added); + Assert.Empty(candidates.OrderedIds); + } + + // ── P2 / T6 ─────────────────────────────────────────────────────────── + /// + /// Non-BSP invariance. A cylinder-only owner must still take retail's + /// cylsphere branch — CObjCell::find_cell_list @0x0052b9f0 — and + /// produce exactly the sphere flood's cell set. Sabotage: route every + /// owner through the box path → the sets diverge (the cylinder's box is + /// its own ±radius extent, which spans a different rectangle). + /// + [Fact] + public void T6_CylinderOnlyOwner_MatchesTheUntouchedSphereFlood() + { + var cylinder = ShadowShape.Cylinder( + gfxObjId: 0u, + localPosition: Vector3.Zero, + localRotation: Quaternion.Identity, + scale: 1f, + // r = 12 at the exact centre of cell (1,1) is the configuration in + // which the two routes DISAGREE: check_add_cell_boundary's tests + // are STRICT (pointX > 24-r, pointX < r), so 12 > 12 and 12 < 12 + // both fail and the sphere claims exactly one cell — while the + // same extent as a BOX spans 24..48, whose floor is columns 1 AND + // 2. A fixture at any other radius makes the routes agree and + // proves nothing. + radius: 12f, + cylHeight: 24f); + + var reg = new ShadowObjectRegistry(); + const uint ownerId = 0x334001u; + var worldPos = new Vector3(36f, 36f, 50f); + reg.RegisterMultiPart( + ownerId, worldPos, Quaternion.Identity, + new[] { cylinder }, 0u, EntityCollisionFlags.None, + 0f, 0f, LbId); + + IReadOnlyList expected = CellTransit.BuildShadowCellSet( + new PhysicsDataCache(), + LbId | 10u, + new[] + { + new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = 12f }, + }, + 1, + isStatic: false); + + // Control: the golden must be the SINGLE cell only the sphere route + // produces, so the equality below cannot be satisfied by the box route. + Assert.Equal(new[] { LbId | 10u }, expected); + + var actual = new List(); + foreach (uint id in expected) + { + if (reg.GetObjectsInCell(id).Any(e => e.EntityId == ownerId)) + actual.Add(id); + } + + Assert.NotEmpty(expected); + Assert.Equal(expected.OrderBy(v => v), actual.OrderBy(v => v)); + + // And nothing outside it: the cylinder claims no cell the sphere + // flood did not. + for (uint index = 1u; index <= 64u; index++) + { + uint cellId = LbId | index; + bool held = reg.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId); + Assert.Equal(expected.Contains(cellId), held); + } + } + + // ── P1 / dispatch ───────────────────────────────────────────────────── + /// + /// The dispatch itself: a BSP-bearing owner registered through + /// lands in EVERY cell + /// of its box rectangle — not the nine of the sphere neighbourhood. This + /// is the end-to-end #334 fact at the production entry point. + /// + [Fact] + public void RegisterMultiPart_BspBearingOwner_OccupiesTheFullBoxRectangle() + { + var shape = BspPart(new Vector3(-50f, -50f, -3f), new Vector3(50f, 50f, 3f)); + var reg = new ShadowObjectRegistry(); + const uint ownerId = 0x334002u; + + reg.RegisterMultiPart( + ownerId, new Vector3(36f, 36f, 0f), Quaternion.Identity, + new[] { shape }, 0u, EntityCollisionFlags.None, + 0f, 0f, LbId, seedCellId: LbId | 10u); + + int held = 0; + for (int x = GxBase - 1; x <= GxBase + 3; x++) + for (int y = GyBase - 1; y <= GyBase + 3; y++) + { + uint cellId = Cell(x, y); + Assert.Contains( + reg.GetObjectsInCell(cellId), + e => e.EntityId == ownerId); + held++; + } + + Assert.Equal(25, held); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs index ea9864a5..b70a4f94 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs @@ -26,19 +26,19 @@ public class ShadowObjectRegistryMultiPartTests localPosition: Vector3.Zero, localRotation: Quaternion.Identity, scale: 1.0f, - localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)), + localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)), ShadowShape.Bsp( gfxObjId: 0x010044B6u, localPosition: Vector3.Zero, localRotation: Quaternion.Identity, scale: 1.0f, - localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)), + localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)), ShadowShape.Bsp( gfxObjId: 0x010044B6u, localPosition: Vector3.Zero, localRotation: Quaternion.Identity, scale: 1.0f, - localBounds: new FlatCollisionSphere(Vector3.Zero, 2.0f)) + localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 2.0f), null)) }; [Fact] @@ -260,9 +260,11 @@ public class ShadowObjectRegistryMultiPartTests localPosition: localPosition, localRotation: localRotation == default ? Quaternion.Identity : localRotation, scale: 1f, - localBounds: new FlatCollisionSphere( - boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter, - radius)); + localGeometry: ShadowPartGeometry.Create( + new FlatCollisionSphere( + boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter, + radius), + null)); private static List FloodCellsFor(params ShadowShape[] shapes) { @@ -444,7 +446,9 @@ public class ShadowObjectRegistryMultiPartTests setup, entScale: 1f, hasPhysicsBsp: id => id == part, - physicsBspBounds: id => id == part ? bounds : null); + physicsBspBounds: id => id == part + ? ShadowPartGeometry.Create(bounds, null) + : (ShadowPartGeometry?)null); ShadowShape only = Assert.Single(shapes); Assert.Equal(ShadowCollisionType.BSP, only.CollisionType); diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index 8542e130..d26b0735 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -973,7 +973,7 @@ public class ShadowObjectRegistryTests Vector3.Zero, Quaternion.Identity, scale: 1f, - localBounds: new FlatCollisionSphere(Vector3.Zero, radius)); + localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, radius), null)); private static CellPhysics BuildShadowCellSetTests_MakeLeafCell(Matrix4x4 worldTransform) { diff --git a/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs index 52dd5292..04fc10ef 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs @@ -467,5 +467,5 @@ public sealed class ShadowSetPositionCommitTests local, Quaternion.Identity, scale: 1f, - localBounds: new FlatCollisionSphere(Vector3.Zero, 0.25f)); + localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 0.25f), null)); }