diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index bddd36427..c7fc73327 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -1143,12 +1143,20 @@ composition/pass facades. The superseded `PortalVisibilityBuilder`, OVERHAUL S5-c5; production has one visibility graph and one walk owner. **Campaign OVERHAUL S2 (chunks 1-5, closed 2026-09-03) gave render and physics cross-cell membership one canonical owner:** -`ShadowObjectRegistry` computes retail's exact CELLARRAY +`ShadowObjectRegistry` computes the retail CELLARRAY (with the source and +boundary residuals tracked in AD-117/AP-159) (`calc_cross_cells_static`/`add_shadows_to_cells`, Contract A/B) once per registration/move transaction and retains it both as collision rows (`GetOwnerCells`) and per-cell `RetailPartEntry` render rows (`GetRetailPartEntriesInCell`) — one flood drives both, never two -independent ones. The equipped-child controller publishes every DAT-resolved +independent ones. The part-array outdoor building bridge uses the distinct +retail reciprocal-plane sphere/box and destination-containment gates, then +immediately expands each admitted destination using the complete part array +before the next building portal, even for a duplicate destination. It shares +the existing growing worklist and outside latch; ordinary sphere/player +building transit remains separate. Positive malformed reciprocal indices +fail at the managed content-integrity boundary (AP-159), not a second owner. +The equipped-child controller publishes every DAT-resolved effective Setup part (after `AnimPartChanged`) through the same strict prepared-collision publisher used by ordinary live hydration before it calls `AttachChild`; children still inherit the parent's CELLARRAY and acquire only diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 21ebb171c..bc13c02a8 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -323,7 +323,7 @@ reducing the current active total to **159**. | AP-156 | **SCALE RESIDUAL DECIDED 2026-08-08 by the user: KEEP OURS — permanent, deliberate divergence in the SAFE direction.** acdream sizes the flood bubble to the object's actual placed scale; retail ignores the resize and floods at authored size, which under-registers ENLARGED objects (their real geometry pokes into neighbouring cells retail never lists them in — a walk-through edge case at cell boundaries). Copying retail would import that bug for byte-fidelity; the user chose not to. This row's scale question is CLOSED and must not be re-opened as a faithfulness cleanup. **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 | **MEASURED AND RE-SCOPED 2026-08-07 (Campaign S S1A) — one half RETIRED as a non-divergence, the other half CONFIRMED against retail's registration set but PROVEN collision-unreachable; fix deferred.** **CylHeight half: RETIRED.** `CObjCell::find_cell_list`'s cylsphere overload @0x0052b9f0 (pseudo-C 309107) copies `Position::localtoglobal(low_pt)` + `radius` per cylsphere, capped at 10, and NEVER reads height — retail itself collapses a cylsphere to a base-point sphere of the cylinder radius. acdream's cylinder flood is exactly retail's behaviour; the row's implication that height matters was wrong. **Sorting-sphere half: measured over the installed DAT** (`SortingSphereFloodMeasurementTests`): third-branch population 3,506 of 5,935 Setups (cross-checked: 3,605 sphere-only-no-cylinder minus 99 BSP-dispatched, matching the independently-committed dispatch-test constants); 163 with a zero authored SortingSphere; of the 3,343 evaluated, **1,812 (54%) fail containment at 1 mm** (1,722 at 1 cm), worst shortfall 18.135 m (Setups 0x02000D7D / 0x020015B3), while max overshoot is only 1.900 m — overwhelmingly the under-inclusive direction relative to RETAIL'S REGISTRATION SET. **BUT: no collision outcome can differ.** For this branch the flood spheres and the collision-test geometry are the SAME per-part Sphere list, so every cell acdream omits is a cell the entity's test geometry cannot reach; retail's sorting-sphere flood is wider than ITS OWN per-sphere tests too, so its extra registrations are narrow-phase rejects. The divergence is a registration-set fidelity gap with a perf sign in acdream's favour, not a walk-through. **Fix deferred deliberately:** flooding from the authored sorting sphere needs `SortingSphere` plumbed through `FlatSetupCollision` and the bake schema (Slice I3 version protocol) — real risk for zero behavioural delta. Take it opportunistically at the next bake-schema revision. Original text: **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~~ | **RETIRED 2026-08-06 — the filter is DELETED, not re-centred, and this row's own disassembly is why.** The minimal fix this row proposed (carry `BoundsCenter` on `ShadowEntry` and measure from the true centre) was deliberately NOT taken: it would have preserved an invention retail does not have, kept a `+ 2f` slack and a `movement.Length()` term with no retail counterpart, and left a second reach budget to be tuned forever. `Transition.FindObjCollisionsInCell` now walks the cell's shadow list with no distance pre-check at all, as `CObjCell::find_obj_collisions` @0x0052b750 does. Cell membership is retail's broad phase, and the BSP walk's own root-node bounding-sphere test — centred correctly, which is precisely what this filter was not — is the early-out that made a second one unnecessary. **This retirement closes #333 and #337** (the Neftet plateau: wedged at the top, jumps sinking into the mesh, corpses falling through), whose mechanism it was. **The row's predicted symptom was observed live before it was fixed**, which is the strongest confirmation a register row gets: it predicted a tall prop AP-156 had just placed correctly would still not block, and the user reported exactly that at Neftet. **PERF, MEASURED rather than assumed** (Release, synthetic all-BSP cell, per `ResolveWithTransition`): at 38 candidates — the live maximum — 10.61 µs → 16.68 µs (+6.07, 1.57×); at a deliberately unreachable 200, 17.34 µs → 39.48 µs (2.28×); ≈ 0.16 µs per additional candidate tested. Over 19,701 live `[reach-q]` samples the in-cell candidate count is p50 = 9, p99 = 32, max 38, so the first row is the bound that matters. **Original text, retained for the record:** **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`; **RETIRED:** the pre-check is gone from `FindObjCollisionsInCell` and `ShadowEntry` needs no `BoundsCenter`. Tests `Issue333BroadphaseReachFilterTests.OffCentreBspFloorStopsAFallingMover` (production path end-to-end, DAT-free, sabotage-verified against its `CentredBspFloorStopsAFallingMover` control — restore the pre-check and the mover falls straight through to the unobstructed 37.800 while the control still blocks) and `Issue337NeftetRockGeometryInspectionTests.TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn` (installed-DAT evidence, both halves of the diagnosis) | 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. | **RETIRED — no residual.** The `rejectedReach` column of the `ACDREAM_PROBE_REACH` family is kept and is now structurally 0, precisely so a post-fix capture is directly comparable with the pre-fix one that recorded 7,225 rejections on a single owner, every one with `wouldAcceptAtCenter=True`. Original risk text, retained for the record: **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 | **NARROWED 2026-08-07 (Campaign S S1B) — the INDOOR part-array arm is PORTED; what remains is the BUILDING BRIDGE plus two distinct boundary ties (second tie confirmed2026-09-05).** `CellTransit.FindTransitCellsBox` now runs retail's per-portal x per-part walk (`CEnvCell::find_transit_cells` @0x0052cae0): sphere cheap-reject at F_EPSILON+radius, BOX admit via the 8-corner classification, leads-outside after the admit, destination `box_intersects_cell` gate through the flat-authoritative dispatcher with a graph referee (20,000 installed comparisons pinned by assertion, zero mismatch). Dual-review PASS; sabotage discriminating; installed direction sweep: rigged population shrinks 978 cells/0 added across 1,520 placements; production-ratio population (box >= sphere, the real relationship — the box is the whole-vertex AABB while the sphere bounds only physics polygons) measured 1 ADD through the loaded-neighbour gate in 950 placements, which is RETAIL-CORRECT direction, so the old 'over-inclusive only, never a missed one' severity line is retired with the port. **REMAINDER 1 — the building bridge:** `CheckBuildingTransit` still admits on the sphere test; its retail counterpart is the part-array `check_building_transit` @0x0052c680 (NOT @0x0052c5d0 — D0 disentangled the function boundaries), whose portal_side convention is INVERTED relative to find_transit_cells (byte table in the retail review §6) and whose admit accepts `eax == 3 || eax == side`; the in-plane early exit of `Plane::intersect_box` is byte-confirmed to return CROSSING(3) (jp @0x005aa1bc -> mov eax,3 @0x005aa2e2), so that porter inherits both traps settled. **2026-09-05 native recovery:**0052C802 appends the destination, then0052C812 immediately invokes its part-array transit before the next building portal; virtual+7C resolves to0052CAE0 at007C8D14. Deferring expansion to the outer growing-array loop is not equivalent ordering; `c1a-bridge-native-contract.md` records paired evidence. **REMAINDER 2 — one-ULP tie:** our `WhichSide` returns Positive at exactly dist==eps where retail's strict `>` says IN_PLANE (byte-decoded @0x00444720); measure-zero, float-exact-equality only. **REMAINDER 3 — indoor cheap-reject equality (confirmed2026-09-05):** current `FindTransitCellsBox` admits side1 only at d > -(r+eps), side0 only at d < r+eps; native0052CB5F–0052CBD2 admits BOTH equalities (side1 >= negative pad, side0 <= positive pad). Paired +rad/-rad stores0052CB7A/0052CB83 and comparison/status branches0052CBA9–0052CBC8 establish this independently of remainder2's `WhichSide` tie. It can omit an otherwise admitted cell at exact equality. No installed-scene occurrence or cause of the cathedral leak/AMD reset is claimed, and no production change is made in this diagnostic round. `c1a-authored-input-diagnostic-contract.md` retains the finding without changing shared predicates. Original text: **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-159 | **NARROWED 2026-09-05 — indoor and building part-array traversal are ported; two distinct boundary ties remain.** The indoor box path landed in Campaign S S1B2026-08-07. The building bridge now has its own `CheckBuildingTransitFromParts` at the `BuildShadowCellSetFromParts` outdoor caller: reciprocal-plane sphere gate with inclusive building-side comparisons, CROSSING-or-SAME authored-box admission, destination containment, then immediate complete-array destination transit before the next building portal, including duplicate destinations. Named/paired006B52A0→0052C680 and0052C802/0052C812→0052CAE0 are recorded in `c1a-bridge-native-contract.md`; `c1a-building-bridge-verification.md` retains focused52/52 and the discriminating ordering mutation. Ordinary sphere/player overloads and residency ownership are unchanged. **REMAINDER 2 — WhichSide equality:** managed returns Positive at exactly dist==eps while native00444720 uses strict `>` and returns IN_PLANE. **REMAINDER 3 — indoor cheap equality:** managed `FindTransitCellsBox` side1 admits only d > -(r+eps), side0 only d < r+eps; native0052CB5F–0052CBD2 admits both equalities (side1 >= negative pad, side0 <= positive pad). These are separate predicates and remain unchanged. **Managed integrity adaptation:** for an otherwise loaded/rooted destination, a nonnegative reciprocal index >= destination.Portals.Count explicitly throws InvalidDataException with destination/index/count. Native trusts this authored metadata unchecked. Existing negative/unavailable/rootless skips precede this guard; no clamp, fallback or load is introduced. Original #334/#335 history: the outdoor authored-box rectangle landed2026-08-06, then the indoor box gates replaced the sphere approximation; the historical strictly-looser/never-missed rationale is not a current equivalence claim. | `src/AcDream.Core/Physics/CellTransit.cs` (`FindTransitCellsBox`, `CheckBuildingTransitFromParts`, `BuildShadowCellSetFromParts`); `BSPQuery.WhichSide` | Only exact boundary comparisons remain approximated here; the malformed-index guard follows the existing managed content-integrity boundary. AD-117 separately retains non-physics sphere-source substitution; no guaranteed sphere/box superset or full-scene equivalence is inferred. | Either boundary tie can alter a cell admission at exact equality. Corrupt positive reciprocal metadata now fails explicitly instead of following an unchecked native access. No installed-scene occurrence of the ties, known-retail-leak cause, AMD cause or G4 clearance is claimed. | `CEnvCell::find_transit_cells`0052CAE0; part-array `check_building_transit`0052C680 (not sphere0052C5D0); `Plane::intersect_box`005AA170; `CCellStruct::box_intersects_cell`00533910; `WhichSide`00444720. | | ~~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/plans/2026-09-01-campaign-overhaul-world-solidity.md b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md index 9c48d54b2..cab302fc5 100644 --- a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md +++ b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md @@ -952,6 +952,19 @@ source approximation remains open. Verification in part-array building bridge and immediate transit ordering, ordinary sphere callers unchanged. No geometry fix or C1a/G4 PASS yet; FPS deferred. +**C1a/AP159 production bridge review-closed (packet§67):** distinct building +part-array box/containment gates and immediate full-array transit now replace +only the wrong sphere-only caller. Focused52/52, ordering omission mutation +fails as intended, exact restoration52/52; six Release builds0W0E. Two +explicit test-fixture corrections, no production repair after its first patch; +narrow independent retail/ordering and production/coverage PASS. AP159 narrows +in this implementation commit and records the malformed-positive-reciprocal +InvalidDataException adaptation. Its two boundary ties and AD117 remain. +Next: integrate the original nonempty-shell-guarded witness, establish actual +three-cell/21-entry installed membership and deterministic product, then the +remaining final gates. No original golden relaxed, no known retail leak/FPS +change, no C1a/G4 pass yet. Evidence: `c1a-building-bridge-verification.md`. + ## 14. Risk register (condensed) - **R1 second scene graph.** Graph records own only topology/membership facts; diff --git a/docs/research/2026-09-01-overhaul/c1a-building-bridge-verification.md b/docs/research/2026-09-01-overhaul/c1a-building-bridge-verification.md new file mode 100644 index 000000000..bef99951c --- /dev/null +++ b/docs/research/2026-09-01-overhaul/c1a-building-bridge-verification.md @@ -0,0 +1,98 @@ +# C1a / AP159 part-array building bridge verification + +2026-09-05, campaign base4e4e8e825. Implementation contract: +`c1a-building-bridge-repair-contract.md`. This is the separate production +repair supported by the preserved red authored-input diagnostic, not a +third fix to that diagnostic. No known retail cathedral leak, drawing-sphere +source, shared epsilon predicate, ordinary player sphere route or FPS change. + +## Candidate and discriminating evidence + +One new private helper in CellTransit and one changed caller in +BuildShadowCellSetFromParts. Ordered building portals resolve loaded +destinations and their reciprocal planes through existing graph/prepared +access. The supplied matching parts use the inclusive building sphere-side +gate, reciprocal CROSSING-or-SAME box gate and destination containment. +An admitted destination receives immediate complete-array indoor transit +even if its candidate ID already existed; the existing outer walk and +outside-expansion latch remain the only traversal owners. + +The positive out-of-range reciprocal guard throws InvalidDataException only +after existing negative/unavailable/rootless skips. That is the explicitly +authorized managed corruption policy, not native trusted-metadata behavior; +it must remain recorded with AP159 in the production commit. + +Lead independently read the complete source/test delta, the recovered +named/paired native contract, and raw test outputs. Paired executable SHA +006FFEADC5D679C871497112A5BD1F87714D0E273E2166BAE5052DDE369297B1. +The key bridge is006B52A0→0052C680, not sphere0052C5D0; immediate append/call +0052C802/0052C812 resolves virtual+7C to0052CAE0. Predicate and raw authored +evidence are retained in `c1a-bridge-native-contract.md` and +`c1a-authored-input-diagnostic-verification.md`. + +Candidate production SHA256: +B4AFA5FE2654E8AEA2961E2008DC253F368D153180B73B2D1D4A7B5003040591. +Candidate Core test SHA256: +EC835A9865DDD6CBFC046EF5B3C02739395348336888C445655E5AC94DDF4EAA. +Ordinary CheckBuildingTransit overloads and existing tests are unchanged. + +Artifacts: `artifacts/s5-c1a-building-parts-20260905-r1/` in the campaign +worktree. Lead independently parsed these TRX counters: + +| Run | Passed / failed / skipped | Meaning | +|---|---|---| +| pre-fix |12/15/0|Original production; includes one incorrect outside-origin fixture.| +| pre-fix-validated |13/14/0|Corrected fixture; original production still fails bridge/ordering/side/guard checks.| +| fixed-affected |51/1/0|Production repair; one cheap-equality fixture also reached the separately registered WhichSide tie.| +| fixed-isolated-affected |52/0/0|Both cheap equalities and adjacent representable rejects now isolated from that unchanged tie.| +| ordering-mutation |0/1/0|Omitting only immediate transit produces A,B,C rather than expected A,C,B.| +| restored-affected |52/0/0|Exact production restoration, same source hash, final affected lane PASS.| + +The two harness corrections are explicit and exhausted: move the test part +origin itself to the outside-cell boundary while retaining a local box; +then place the cheap-equality point-box strictly within the existing epsilon +band, at the supplied float-stored sphere's nearest point. Sphere centers +remain exactly at both signed padded-radius limits, and adjacent +representable outside values still reject. No production correction was +needed after its first patch. Any further unexpected source/test failure +returns a finding rather than another hidden correction. + +The27 new cases cover graph and prepared containment, decoy polygon ID7 at +index0 versus reciprocal ID42 at index1, both sides and opposite box reject, +both cheap equalities, later-part admission, complete-array immediate +expansion, duplicates/shared outside latch, first-hit break, malformed +reciprocals, empty/unloaded/rootless guards. The25 existing sphere, indoor-box +and registration cases remain in the affected lane. The ordering mutation +was a deliberate discriminator, not an unexpected failure or fix round. + +## Frozen return and narrow review + +Implementer returned execution ownership with no live build/test handle. +All six Release builds exit0,0W0E. Lead independently checked final TRX52/52, +zero skipped, and exact restored production/test hashes above. Independent +retail/ordering and production/coverage review returned PASS with no findings; +reviewer re-read named bodies and rehashed paired executable/windows. Its +return preceded the last restored test; lead separately verified that final +result. Two absent-private-helper failures in the pre-fix baseline are +structural only; separate public-entry geometry/order failures establish the +behavioral discrepancy. No further source/test correction followed the two +explicit harness corrections. + +| Sealed artifact | SHA256 | +|---|---| +|pre-fix-validated.trx|FED5D47A793D35BF8C13408AFD5A5941C2BD7ADE5237A2824334F636EF5ADDF2| +|ordering-mutation.trx|913A96897126B6EDDD55A063838BC24A9E8DDF14084A292667F1D37FF4AB37E4| +|restored-build.log|09E89BFCEBCDF0AA181076C4B20A94D39D7BA2781788B2506D6E3F4A62F405F4| +|restored-affected.trx|8A4E12C61C45FB4F4A000EC1B13A3D9E8C575D9C7E67AF05DE5708DAADEF97F1| + +AP159's building clause is narrowed and its managed corruption guard recorded +in the same implementation commit; both boundary ties and AD117 remain open. +Architecture retains one canonical membership owner. No full-suite or +installed-DAT membership result is claimed here yet. The separate original +geometry witness retains the three-cell/21-entry assertions and unpinned +placeholder; its later execution needs the explicit lead release. + +Clients/debugger were absent and ACE UDP9000/PID13340 present at the lead's +20:24 precheck. No graphical launch belongs to this chunk. G4 remains +unpassed; real installed membership, final lifecycle/stability and owner +acceptance remain required. No main merge. diff --git a/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md b/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md index 069465500..a6ea04d5c 100644 --- a/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md +++ b/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md @@ -5216,3 +5216,30 @@ Exact inputs, raw hashes, all edges and limits: `c1a-authored-input-diagnostic-verification.md`. No production geometry or graphical gate changed; clients/debugger absent, ACE up. C1a/G4 unpassed, known retail leakage unchanged, FPS deferred, no main merge. + +## 67. C1a/AP159 distinct building bridge review-closed — 2026-09-05 + +The separately bounded production repair replaces only the part-array +outdoor caller's sphere helper. Reciprocal-side inclusive sphere, authored +box and destination containment gates now precede immediate complete-array +destination transit, including duplicates, before the next building portal. +Existing sphere/player overloads, indoor ties, shared primitives, residency +and single registry ownership remain unchanged. AP159's building clause is +narrowed in the same commit; its positive malformed-reciprocal integrity guard +is explicitly recorded as a managed adaptation, not native trusted metadata. + +Original production gives13/14 on the corrected27-case baseline; the fixed +affected lane52/52 passes. One deliberate immediate-transit omission fails +expected A,C,B versus actual A,B,C. Exact restored source and final52/52 +confirmed by lead; all six Release builds0W0E. Two test-harness corrections +are preserved and documented, not hidden green retries. Independent narrow +retail/ordering and production/coverage review PASS, no source/test findings. +Full evidence and hashes: `c1a-building-bridge-verification.md`. + +This closes the bridge repair's focused review, not the actual installed +geometry witness or G4. AD117 and both AP159 boundary ties remain open. +The original witness's three-cell/21-entry assertions and placeholder remain; +only a nonempty actual F4180104 mesh guard was prepared under its separate +write-only contract. Lead owns its explicit capture/mutation release and +final campaign gates. No client launch, known retail leakage/FPS change or +main merge in this chunk. diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 8847fdbb6..e3663419a 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -947,6 +947,76 @@ public static class CellTransit return candidates.OrderedIds; } + /// + /// AP-159: the distinct part-array building bridge, CBuildingObj 006B52A0 + /// to CEnvCell 0052C680. Unlike the sphere/player helper, this requires + /// BOTH box gates and immediately expands each admitted destination. + /// + private static void CheckBuildingTransitFromParts( + PhysicsDataCache cache, + BuildingPhysics building, + IReadOnlyList worldParts, + IReadOnlyList worldPartSpheres, + ICollection candidates, + uint seedCellId, + Vector3 blockOrigin, + ref bool outdoorAdded) + { + int partCount = Math.Min(worldParts.Count, worldPartSpheres.Count); + if (partCount == 0) return; + + foreach (BldPortalInfo buildingPortal in building.Portals) + { + int reciprocalIndex = buildingPortal.OtherPortalId; + if (reciprocalIndex < 0) continue; + + RecordUnionOnlyProbe(candidates, buildingPortal.OtherCellId); + CellPhysics? destination = cache.GetCellStruct(buildingPortal.OtherCellId); + if (destination is null || !CollisionTraversal.HasCellContainment(cache, destination)) + continue; // GetVisible only: no unloaded-cell hint or load request. + + // Managed content-integrity guard; native indexes trusted metadata. + if (reciprocalIndex >= destination.Portals.Count) + throw new InvalidDataException( + $"Building portal to cell 0x{buildingPortal.OtherCellId:X8} references reciprocal portal {reciprocalIndex}, but the destination has {destination.Portals.Count} portals."); + + PortalInfo reciprocal = destination.Portals[reciprocalIndex]; + if (!TryGetPortalPlane(destination, reciprocalIndex, reciprocal, out Plane plane)) + continue; + + for (int i = 0; i < partCount; i++) + { + Sphere sphere = worldPartSpheres[i]; + float paddedRadius = sphere.Radius + FEpsilon; + Vector3 center = Vector3.Transform(sphere.Origin, destination.InverseWorldTransform); + float distance = Vector3.Dot(center, plane.Normal) + plane.D; + // Inclusive BUILDING gate: opposite direction to the indoor exit gate. + bool passesCheapReject = reciprocal.PortalSide + ? distance <= paddedRadius + : distance >= -paddedRadius; + if (!passesCheapReject) continue; + + worldParts[i].RefitToLocal(destination.InverseWorldTransform, out Vector3 min, out Vector3 max); + BSPQuery.PlaneSide side = BSPQuery.ClassifyBox(plane, min, max); + BSPQuery.PlaneSide sameSide = reciprocal.PortalSide + ? BSPQuery.PlaneSide.Negative : BSPQuery.PlaneSide.Positive; + if (side != BSPQuery.PlaneSide.Straddle && side != sameSide) + continue; + if (!CollisionTraversal.BoxIntersectsCell(cache, destination, min, max)) + continue; + + candidates.Add(buildingPortal.OtherCellId); + // 0052C802 -> 0052C812: this call follows even a deduplicated Add, + // uses every part, and precedes the next authored building portal. + FindTransitCellsBox(cache, destination, buildingPortal.OtherCellId, + worldParts, worldPartSpheres, candidates, out bool exitOutside); + if (exitOutside && !outdoorAdded) + outdoorAdded = AddAllOutsideCellsFromParts(worldParts, seedCellId, blockOrigin, candidates); + break; + } + } + } + /// /// #334 (2026-08-06). Registration-side cell-set builder for a /// PHYSICS-BSP-BEARING object — retail's OTHER cross-cell algorithm, which @@ -1095,9 +1165,9 @@ public static class CellTransit var building = cache.GetBuilding(cellId); if (building is not null && sphereCount > 0) { - CheckBuildingTransit( - cache, building, worldPartSpheres!, sphereCount, - candidates, out _); + CheckBuildingTransitFromParts( + cache, building, worldParts, worldPartSpheres!, + candidates, seedCellId, blockOrigin, ref outdoorAdded); } } } diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitBuildingPartsTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitBuildingPartsTests.cs new file mode 100644 index 000000000..95170a68b --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellTransitBuildingPartsTests.cs @@ -0,0 +1,309 @@ +using System.Collections; +using System.Collections.Immutable; +using System.Numerics; +using System.Reflection; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public sealed class CellTransitBuildingPartsTests +{ + private const uint Prefix = 0xA9B40000u; + private const uint Outside = Prefix | 1u; + private const uint A = Prefix | 0x100u; + private const uint B = Prefix | 0x101u; + private const uint C = Prefix | 0x102u; + private const float Epsilon = 0.000199999995f; + private static readonly Vector3 Origin = new(12, 12, 0); + private static readonly Plane Permissive = new(Vector3.UnitZ, 100); + private static readonly PortalSpec DefaultPortal = new(0xFFFF, new Plane(Vector3.UnitX, 0), 2); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CathedralLikeSphereHit_BoxBelowDestinationPlane_RejectsOnlyPartRoute(bool prepared) + { + PhysicsDataCache cache = Cache(prepared); + CellPhysics cell = Cell(A, prepared, new Plane(Vector3.UnitZ, -21.8948f), DefaultPortal); + cache.RegisterCellStructForTest(A, cell); + BuildingPhysics building = Building(new BldPortalInfo(A, 0, 0)); + cache.RegisterBuildingForTest(Outside, building); + ShadowPartBox[] boxes = [Box(new(-1, -1, 16.86699f), new(1, 1, 21.894302f))]; + Sphere[] spheres = [SphereAt(new(0, 0, 19.380646f), 3.93215f)]; + + var sphereCells = new CellArray(); + CellTransit.CheckBuildingTransit(cache, building, spheres, 1, sphereCells, out bool hits); + Assert.True(hits); + Assert.Equal(new[] { A }, sphereCells.OrderedIds); + Assert.Equal(new[] { Outside }, Flood(cache, boxes, spheres)); + if (prepared) + { + Assert.Null(cell.PortalPolygons); + Assert.Equal((ushort)42, cell.Portals[0].PolygonId); + Assert.Equal(1, cell.FlatTopology!.Portals[0].PolygonIndex); + Assert.Equal((ushort)7, cell.FlatPortalPolygons!.Polygons[0].Id); + } + } + + [Theory] + [InlineData(false, -1, false)] + [InlineData(false, 0, true)] + [InlineData(false, 1, true)] + [InlineData(true, -1, true)] + [InlineData(true, 0, true)] + [InlineData(true, 1, false)] + public void ReciprocalSide_AdmitsOnlyCrossingOrSameSide(bool negativeSide, int boxSide, bool admitted) + { + PhysicsDataCache cache = Cache(true); + ushort flags = negativeSide ? (ushort)0 : (ushort)2; + cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, + new PortalSpec(0xFFFF, new Plane(Vector3.UnitZ, 0), flags))); + // Opposite building flags must not replace the destination's reciprocal side. + cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, (ushort)(flags ^ 2)))); + float center = boxSide * 0.5f; + ShadowPartBox[] boxes = [Box(new(-0.1f, -0.1f, center - 0.1f), new(0.1f, 0.1f, center + 0.1f))]; + IReadOnlyList result = Flood(cache, boxes, [SphereAt(Vector3.Zero, 1)]); + Assert.Equal(admitted ? new[] { Outside, A } : new[] { Outside }, result); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void BuildingCheapGate_EqualityAdmits_AdjacentOutsideFloatRejects(bool negativeSide, bool beyond) + { + PhysicsDataCache cache = Cache(true); + cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, + new PortalSpec(0xFFFF, new Plane(Vector3.UnitZ, 0), negativeSide ? (ushort)0 : (ushort)2))); + cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0))); + float padded = 0.5f + Epsilon; + float z = negativeSide ? padded : -padded; + if (beyond) z = negativeSide ? MathF.BitIncrement(z) : MathF.BitDecrement(z); + // Nearest point of the float-stored equality sphere. It is strictly inside + // the box epsilon band, isolating cheap equality from the open WhichSide +epsilon tie. + float boxZ = negativeSide ? padded - 0.5f : 0.5f - padded; + Assert.InRange(MathF.Abs(boxZ), 0f, MathF.BitDecrement(Epsilon)); + ShadowPartBox[] boxes = [Box(new(0, 0, boxZ), new(0, 0, boxZ))]; + Assert.Equal(beyond ? new[] { Outside } : new[] { Outside, A }, + Flood(cache, boxes, [SphereAt(new(0, 0, z), 0.5f)])); + } + + [Fact] + public void EarlierPartRejects_LaterPartAdmits_WithoutChangingTheirOrder() + { + PhysicsDataCache cache = Cache(true); + cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, + new PortalSpec(0xFFFF, new Plane(Vector3.UnitZ, 0), 2))); + cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0))); + ShadowPartBox[] boxes = [Box(new(-0.1f, -0.1f, -2), new(0.1f, 0.1f, -1)), + Box(new(-0.1f, -0.1f, 1), new(0.1f, 0.1f, 2))]; + Sphere[] spheres = [SphereAt(Vector3.Zero, 3), SphereAt(Vector3.Zero, 3)]; + Assert.Equal(new[] { Outside }, Flood(cache, boxes[..1], spheres[..1])); + Assert.Equal(new[] { Outside, A }, Flood(cache, boxes, spheres)); + } + + [Fact] + public void BuildingDestination_ExpandsBeforeNextAuthoredPortal() + { + PhysicsDataCache cache = Cache(true); + cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal, + new PortalSpec(0x102, new Plane(Vector3.UnitX, 0), 0))); + cache.RegisterCellStructForTest(B, Cell(B, true, Permissive, DefaultPortal)); + cache.RegisterCellStructForTest(C, Cell(C, true, Permissive)); + cache.RegisterBuildingForTest(Outside, Building(new(A, 0, 0), new(B, 0, 0))); + Assert.Equal(new[] { Outside, A, C, B }, Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)])); + } + + [Fact] + public void ImmediateDestinationExpansion_ReceivesAllParts_NotJustTheBuildingHit() + { + PhysicsDataCache cache = Cache(true); + cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, + new PortalSpec(0xFFFF, new Plane(Vector3.UnitZ, 0), 2), + new PortalSpec(0x102, new Plane(Vector3.UnitX, 0), 0))); + cache.RegisterCellStructForTest(B, Cell(B, true, Permissive, DefaultPortal)); + cache.RegisterCellStructForTest(C, Cell(C, true, Permissive)); + cache.RegisterBuildingForTest(Outside, Building(new(A, 0, 0), new(B, 0, 0))); + ShadowPartBox[] boxes = [Box(new(-1, -0.1f, 0.1f), new(-0.5f, 0.1f, 0.2f)), + Box(new(0.5f, -0.1f, 0.1f), new(1, 0.1f, 0.2f))]; + Assert.Equal(new[] { Outside, A, C, B }, Flood(cache, boxes, + [SphereAt(Vector3.Zero, 2), SphereAt(Vector3.Zero, 2)])); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DuplicateDestination_StillExpandsImmediately_WithSharedOutsideLatch(bool alreadyOutside) + { + PhysicsDataCache cache = Cache(true); + cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal, + new PortalSpec(0x102, new Plane(Vector3.UnitX, 0), 0))); + cache.RegisterCellStructForTest(B, Cell(B, true, Permissive, DefaultPortal)); + cache.RegisterCellStructForTest(C, Cell(C, true, Permissive)); + var candidates = new CellArray(); + candidates.Add(A); + // This exact prefilled worklist cannot be supplied through the public entry. + MethodInfo helper = Assert.IsAssignableFrom(typeof(CellTransit).GetMethod( + "CheckBuildingTransitFromParts", BindingFlags.Static | BindingFlags.NonPublic)); + object[] arguments = [cache, Building(new(A, 0, 0), new(B, 0, 0)), + new[] { SmallBox() }, new[] { SphereAt(Vector3.Zero, 1) }, candidates, + Outside, Vector3.Zero, alreadyOutside]; + helper.Invoke(null, arguments); + Assert.True((bool)arguments[7]); + Assert.Equal(alreadyOutside ? new[] { A, C, B } : new[] { A, C, Outside, B }, candidates.OrderedIds); + } + + [Fact] + public void FirstHitBreaksPartLoop_ImmediateAndOuterDestinationVisitsRemainDistinct() + { + PhysicsDataCache cache = Cache(true); + cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal)); + cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0))); + var spheres = new ReadTrackingSpheres([SphereAt(Vector3.Zero, 1), SphereAt(Vector3.Zero, 1)]); + Assert.Equal(new[] { Outside, A }, Flood(cache, [SmallBox(), SmallBox()], spheres)); + // Building hit, immediate destination portal, then growing-array destination portal. + Assert.Equal(new[] { 0, 0, 0 }, spheres.Reads); + } + + [Theory] + [InlineData(-1)] + [InlineData(short.MinValue)] + public void NegativeReciprocal_IsSkipped(short reciprocal) + { + PhysicsDataCache cache = Cache(true); + cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal)); + cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, reciprocal, 0))); + Assert.Equal(new[] { Outside }, Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)])); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UnavailableOrRootlessDestination_IsSkippedBeforeReciprocalValidation(bool rootless) + { + PhysicsDataCache cache = Cache(true); + if (rootless) cache.RegisterCellStructForTest(A, Cell(A, true, null, DefaultPortal)); + cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 300, 0))); + Assert.Equal(new[] { Outside }, Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)])); + } + + [Theory] + [InlineData(1)] + [InlineData(300)] + public void MalformedPositiveReciprocal_ThrowsExplicitContentIntegrityFailure(short reciprocal) + { + PhysicsDataCache cache = Cache(true); + cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal)); + cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, reciprocal, 0))); + InvalidDataException failure = Assert.Throws(() => + Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)])); + Assert.Equal($"Building portal to cell 0xA9B40100 references reciprocal portal {reciprocal}, but the destination has 1 portals.", failure.Message); + } + + [Fact] + public void EmptyInputs_PreserveExistingSeedAndPartGuards() + { + PhysicsDataCache cache = Cache(true); + cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0))); + Assert.Empty(Flood(cache, [], [])); + Assert.Equal(new[] { Outside }, Flood(cache, [SmallBox()], [])); + Assert.Empty(CellTransit.BuildShadowCellSetFromParts(cache, 0, [SmallBox()], [SphereAt(Vector3.Zero, 1)], false)); + } + + [Fact] + public void UnloadedActiveOutdoorCell_PreservesOutsideIdsWithoutWalkingCachedBuilding() + { + PhysicsDataCache cache = Cache(true, terrain: false); + cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal)); + cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0))); + Assert.Equal(new[] { Outside }, Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)])); + } + + [Fact] + public void UnloadedLaterOutdoorCandidate_DoesNotWalkItsCachedBuilding() + { + PhysicsDataCache cache = Cache(true); + const uint adjacentOutside = 0xAAB40001u; + cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal)); + cache.RegisterBuildingForTest(adjacentOutside, Building(new BldPortalInfo(A, 0, 0))); + ShadowPartBox[] boxes = [Box(new(-1, -1, -0.1f), new(1, 1, 0.1f), new Vector3(192, 12, 0))]; + IReadOnlyList result = CellTransit.BuildShadowCellSetFromParts( + cache, Prefix | 57u, boxes, [SphereAt(new(180, 0, 0), 2)], false); + Assert.Equal(new[] { Prefix | 57u, adjacentOutside }, result); + } + + private static PhysicsDataCache Cache(bool prepared, bool terrain = true) + { + PhysicsDataCache cache = prepared ? PhysicsDataCache.CreateProduction() : new PhysicsDataCache(); + if (terrain) cache.CellGraph.RegisterTerrain(Prefix, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero); + return cache; + } + + private static IReadOnlyList Flood(PhysicsDataCache cache, IReadOnlyList boxes, IReadOnlyList spheres) => + CellTransit.BuildShadowCellSetFromParts(cache, Outside, boxes, spheres, isStatic: true); + + private static BuildingPhysics Building(params BldPortalInfo[] portals) => new() + { + WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Portals = portals, + }; + + private readonly record struct PortalSpec(ushort Other, Plane Plane, ushort Flags); + + private static CellPhysics Cell(uint id, bool prepared, Plane? containment, params PortalSpec[] specs) + { + CellBSPNode? root = containment is { } plane ? new CellBSPNode + { + Type = BSPNodeType.BPOL, SplittingPlane = plane, PosNode = null, NegNode = null, + } : null; + // Polygon ID 42 is deliberately at flat table index1, not index0 or42. + var polygons = new Dictionary + { + [7] = new() { Id = 7, Plane = new Plane(Vector3.UnitZ, -1000), Vertices = [], NumPoints = 0, SidesType = CullMode.None }, + }; + var portals = new List(); + var flatPortals = ImmutableArray.CreateBuilder(); + for (int i = 0; i < specs.Length; i++) + { + ushort polygonId = checked((ushort)(42 + i)); + PortalSpec spec = specs[i]; + polygons.Add(polygonId, new ResolvedPolygon { Id = polygonId, Plane = spec.Plane, Vertices = [], NumPoints = 0, SidesType = CullMode.None }); + portals.Add(new PortalInfo(spec.Other, polygonId, spec.Flags)); + flatPortals.Add(new FlatEnvCellPortal(spec.Other, polygonId, spec.Flags, i + 1)); + } + return new CellPhysics + { + SourceId = id, WorldTransform = Matrix4x4.CreateTranslation(Origin), + InverseWorldTransform = Matrix4x4.CreateTranslation(-Origin), Resolved = [], + CellBSP = prepared ? null : new CellBSPTree { Root = root }, + FlatContainmentBsp = FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root), + Portals = portals, PortalPolygons = prepared ? null : polygons, + FlatPortalPolygons = new FlatPolygonTable(polygons.Values.OrderBy(p => p.Id) + .Select(p => new FlatCollisionPolygon(p.Id, p.Plane, p.SidesType, 0, new FlatIndexRange(0, 0))).ToImmutableArray(), []), + FlatTopology = new FlatEnvCellTopology(flatPortals.ToImmutable(), [], true), + }; + } + + private static Sphere SphereAt(Vector3 localCenter, float radius) => new() { Origin = Origin + localCenter, Radius = radius }; + private static ShadowPartBox SmallBox() => Box(new(-0.1f), new(0.1f)); + + private static ShadowPartBox Box(Vector3 min, Vector3 max, Vector3? worldPosition = null) + { + var geometry = ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 1), + new FlatGfxObjVisualBounds(min, max, (min + max) * 0.5f, ((max - min) * 0.5f).Length(), (max - min) * 0.5f)); + ShadowShape shape = ShadowShape.Bsp(0x01000001, Vector3.Zero, Quaternion.Identity, 1, geometry); + return ShadowPartBox.FromShape(shape, worldPosition ?? Origin, Quaternion.Identity); + } + + private sealed class ReadTrackingSpheres(Sphere[] values) : IReadOnlyList + { + public List Reads { get; } = []; + public int Count => values.Length; + public Sphere this[int index] { get { Reads.Add(index); return values[index]; } } + public IEnumerator GetEnumerator() => ((IEnumerable)values).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +}