diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 4244b49b..3268e31e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -187,7 +187,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-151 | **Filed 2026-08-06 at the #280 retail-conformance review (finding F3).** The reveal gate is materially STRICTER than retail's prefetch predicate on the mesh-build/GPU-upload axis, over an equally large square. Retail's `LScape::PreFetchCells` @0x00505660 requires, per member, only that the DAT records be resident in memory (`DBObj::PreFetch` -> `IN_MEMORY` or `IN_FILE` -> `DBObj::Get` non-null); no geometry construction, no vertex arrays and no GPU upload are part of the blocking predicate — that work happens lazily at draw. acdream's gate requires, for every member of the derived window (25x25 at the shipped High preset): a worker-thread DAT read, a terrain mesh build, a render-thread `TerrainModernRenderer.AddLandblock` upload, a spatial commit, a physics collision-generation admission, and a spawn-adapter activation, all metered at `MaxCompletionsPerFrame`. The hold is therefore systematically longer than retail's for identical content, and nothing currently bounds it. Note this is the OPPOSITE asymmetry from AP-149, which records where the outer ring is WEAKER than retail; both are live simultaneously, on different axes. | `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Streaming/StreamingWorkBudget.cs` | It is what makes "no visible assembly after reveal" true at all: acdream draws through a bindless/MDI pipeline whose landblock slots must exist before the viewport opens, where retail can begin drawing a landblock the frame its DAT record lands. Weakening the predicate to DAT residency would restore retail's hold duration and reintroduce the visible-assembly artifact #280 exists to remove. AD-2's blanket "async readiness gates replace retail's synchronous destination cell load" pre-dates the window being 625 members wide and does not name this axis. | Portal/recall holds of several seconds where retail (warm cache) is near-instant, on EVERY transit rather than only on cold DAT. No upper bound is enforced and no progress readout is shown (#327). A slow disk or a saturated upload budget lengthens the hold without limit. | `LScape::PreFetchCells` 0x00505660; `DBObj::PreFetch`/`DBObj::Get` call sites @0x0050575C, @0x0050579C; `CellManager::PreFetchCells` 0x00455820 | | 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-155 | **NARROWED AGAIN 2026-08-07 (Campaign S S2) — the Sphere-as-Cylinder emission half is FIXED; what survives is ONLY the has-BSP source split.** Both static publication sites now emit an authored Setup Sphere as `ShadowShape.Sphere`, mirroring the live path's emission exactly (route-independence asserted shape-for-shape incl. CylHeight; dispatch discriminated by a graze/through pair whose cylinder counterfactual verdicts differ numerically; both sites sabotage-reddened independently; population 3,506 of 5,935 installed Setups, structurally equal to AP-157's third-branch count). The flood centre rises by exactly r for this population — outdoor membership unaffected (XY rectangle), indoor EnvCell membership covered by the Session-B dungeon gate. **What remains:** the static paths derive has-BSP from `entity.MeshRefs` where the live path derives it from `setup.Parts` plus post-AnimPartChanged identities; the two sources can disagree, and that half keeps this row ACTIVE. A shared primitive-emitter refactor (compile-time route independence instead of empirical parity tests) is the filed follow-up. Original text: **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. **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** (`Ap157SortingSphereFloodMeasurementTests`): 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 | diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index c835091c..aac5c804 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -1011,6 +1011,16 @@ public sealed class LandblockPhysicsPublisher if (setup.Cylinders.Length == 0) { + // AP-155: mirror ShadowShapeBuilder.FromSetup's step-2 Sphere + // emission EXACTLY (same origin/scale composition, same + // Radius<=0 guard) — a true Sphere, not a height-capped + // Cylinder. Retail's CPhysicsObj::FindObjCollisions dispatches + // an authored Setup Sphere to CSphere::intersects_sphere; + // there is no cylinder substitution for this branch. Emitting + // Cylinder here (base = origin - r*ẑ, height = 2r) made a + // static object's narrow phase disagree with the SAME object + // arriving as a live spawn via FromSetup — the route- + // dependence this fix closes. for (int sphereIndex = 0; sphereIndex < setup.Spheres.Length; sphereIndex++) @@ -1022,17 +1032,15 @@ public sealed class LandblockPhysicsPublisher float radius = sphere.Radius * scale; Vector3 localOffset = sphere.Origin * scale; - Vector3 localBaseOffset = localOffset - + Vector3.Transform( - -Vector3.UnitZ * radius, - Quaternion.Inverse(entity.Rotation)); - setupShapes.Add(ShadowShape.Cylinder( + setupShapes.Add(// (Review F3: the deleted Quaternion.Inverse base composition was + // internally coherent for the old CYLINDER's world-Z axis — the + // defect was the shape TYPE, not that rotation math.) + ShadowShape.Sphere( gfxObjId: entity.SourceGfxObjOrSetupId, - localPosition: localBaseOffset, + localPosition: localOffset, localRotation: Quaternion.Identity, scale: scale, - radius: radius, - cylHeight: radius * 2f)); + radius: radius)); } } @@ -1080,8 +1088,11 @@ public sealed class LandblockPhysicsPublisher for (int index = 0; index < shapes.Count; index++) { + // AP-155: the Setup-derived shape can now be Cylinder OR Sphere + // (never both — see ShadowShapeBuilder.FromSetup step 1/2). Read + // the actual emitted type rather than assuming Cylinder. Console.WriteLine(FormattableString.Invariant( - $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{shapes[index].GfxObjId:X8} lb=0x{landblock.LandblockId:X8} type=Cylinder note=setup-part{index} state=0x{0u:X8} flags={EntityCollisionFlags.None}")); + $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{shapes[index].GfxObjId:X8} lb=0x{landblock.LandblockId:X8} type={shapes[index].CollisionType} note=setup-part{index} state=0x{0u:X8} flags={EntityCollisionFlags.None}")); } } diff --git a/src/AcDream.Content/LandblockPhysicsContentBuilder.cs b/src/AcDream.Content/LandblockPhysicsContentBuilder.cs index 74b50ef0..e485a164 100644 --- a/src/AcDream.Content/LandblockPhysicsContentBuilder.cs +++ b/src/AcDream.Content/LandblockPhysicsContentBuilder.cs @@ -666,6 +666,11 @@ public static class LandblockPhysicsContentBuilder if (setup.Cylinders.Length == 0) { + // AP-155: mirror ShadowShapeBuilder.FromSetup's step-2 Sphere + // emission EXACTLY (same origin/scale composition, same + // Radius<=0 guard) — a true Sphere, not a height-capped + // Cylinder. See LandblockPhysicsPublisher's identical fix for + // the full retail-anchor note. for (int index = 0; index < setup.Spheres.Length; index++) @@ -675,17 +680,15 @@ public static class LandblockPhysicsContentBuilder continue; float radius = sphere.Radius * scale; Vector3 localOffset = sphere.Origin * scale; - Vector3 localBaseOffset = localOffset - + Vector3.Transform( - -Vector3.UnitZ * radius, - Quaternion.Inverse(entity.Rotation)); - setupShapes.Add(ShadowShape.Cylinder( + setupShapes.Add(// (Review F3: the deleted Quaternion.Inverse base composition was + // internally coherent for the old CYLINDER's world-Z axis — the + // defect was the shape TYPE, not that rotation math.) + ShadowShape.Sphere( entity.SourceGfxObjOrSetupId, - localBaseOffset, + localOffset, Quaternion.Identity, scale, - radius, - radius * 2f)); + radius)); } } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index a3287ea4..b39e81cb 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -462,6 +462,186 @@ public sealed class LandblockPhysicsPublisherTests Assert.Equal(0, fixture.Publisher.Diagnostics.StaticCylinderOwnerCount); } + /// + /// AP-155 parity (route independence). A Setup carrying only authored + /// Spheres (no CylSpheres) must register the SAME shapes — type, local + /// position, radius, scale — through the static publication path as + /// through , the LIVE path. + /// Before the fix this site emitted a height-capped Cylinder instead + /// (base = origin - r*ẑ, height = 2r): same object, different narrow + /// phase depending on how it arrived. + /// + [Fact] + public void CompletePublication_SphereOnlySetup_MatchesFromSetupShapeForShape() + { + var fixture = Fixture(); + var setup = new Setup(); + setup.Spheres.Add(new Sphere + { + Origin = new Vector3(0.3f, -0.2f, 0.9f), + Radius = 0.55f, + }); + setup.Spheres.Add(new Sphere + { + Origin = new Vector3(-0.1f, 0.4f, 1.4f), + Radius = 0.25f, + }); + fixture.Cache.CacheSetup(SetupId, setup); + + const float entScale = 1.3f; + WorldEntity entity = new() + { + Id = 0x80A9B401u, + SourceGfxObjOrSetupId = SetupId, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + Scale = entScale, + MeshRefs = Array.Empty(), + }; + + Publish(fixture.Publisher, Build(FirstLandblock, [entity])); + + // The LIVE-path oracle for the SAME Setup/scale. + IReadOnlyList expected = + ShadowShapeBuilder.FromSetup(setup, entScale, _ => false); + Assert.Equal(2, expected.Count); + Assert.All( + expected, + shape => Assert.Equal(ShadowCollisionType.Sphere, shape.CollisionType)); + + ShadowShape[] expectedOrdered = + expected.OrderBy(shape => shape.Radius).ToArray(); + // Entity registered at world origin with identity rotation, so + // RegisterMultiPart's world composition (entityWorldPos + + // Transform(shape.LocalPosition, entityWorldRot)) reduces to exactly + // shape.LocalPosition — the static entry's world Position is directly + // comparable to FromSetup's LocalPosition. + ShadowEntry[] entries = fixture.Engine.ShadowObjects.AllEntriesForDebug() + .Where(entry => entry.EntityId == entity.Id) + .OrderBy(entry => entry.Radius) + .ToArray(); + + Assert.Equal(expectedOrdered.Length, entries.Length); + for (int i = 0; i < entries.Length; i++) + { + Assert.Equal(ShadowCollisionType.Sphere, entries[i].CollisionType); + Assert.Equal(expectedOrdered[i].LocalPosition, entries[i].Position); + Assert.Equal(expectedOrdered[i].Radius, entries[i].Radius); + Assert.Equal(expectedOrdered[i].Scale, entries[i].Scale); + // Review F4 (2026-08-07): assert, don't infer — the C4 lesson. + Assert.Equal(expectedOrdered[i].CylHeight, entries[i].CylHeight); + } + } + + /// + /// AP-155 dispatch. A statically-published Sphere must actually run the + /// Sphere narrow phase (CSphere::intersects_sphere — true 3-D + /// distance) at query time, not the Cylinder narrow phase (XY distance + + /// flat Z-band). Geometry: a 1 m-radius "boulder" Setup Sphere centred at + /// world Z=2.0 (spans Z=[1,3]). A tall, narrow capsule mover's HEAD sphere + /// grazes the boulder's shoulder at Z~2.85 (0.85 m above the boulder's own + /// centre, out of its 1 m radius) with a 1.05 m lateral offset; its FOOT + /// sphere (Z~0.15) never comes close. At that height/offset the true + /// sphere's surface has tapered well inside the mover's path (curve-hit — + /// clean miss, ~0.2 m 3-D clearance), while the retired flat-capped + /// Cylinder never tapers and still fills the same footprint the whole + /// height of its cap (cap-hit — the 1.05 m offset sits ~0.10 m inside its + /// XY radius). The two verdicts differ; this asserts the Sphere one — + /// unobstructed straight-line arrival at the far side. + /// + [Fact] + public void CompletePublication_SphereOnlySetup_MoverGrazesShoulderAndPassesThroughUnobstructed() + { + var fixture = Fixture(); + var setup = new Setup(); + setup.Spheres.Add(new Sphere + { + Origin = new Vector3(0f, 0f, 2.0f), + Radius = 1.0f, + }); + fixture.Cache.CacheSetup(SetupId, setup); + + WorldEntity entity = new() + { + Id = 0x80A9B401u, + SourceGfxObjOrSetupId = SetupId, + Position = new Vector3(12f, 12f, 0f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + Publish(fixture.Publisher, Build(FirstLandblock, [entity])); + + // Sanity: the fix registered a Sphere, not a Cylinder — the dispatch + // this test exercises depends on it. + ShadowEntry registered = Assert.Single( + fixture.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(ShadowCollisionType.Sphere, registered.CollisionType); + + var body = MakeGroundedBody(new Vector3(10.7f, 13.05f, 0f)); + Vector3 target = new(13.3f, 13.05f, 0f); + + ResolveResult result = fixture.Engine.ResolveWithTransition( + body.Position, target, 0xA9B40001u, + sphereRadius: 0.15f, sphereHeight: 3.0f, + stepUpHeight: 0.60f, stepDownHeight: 0.04f, + isOnGround: true, body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0u); + + Assert.True( + MathF.Abs(result.Position.X - target.X) < 0.05f + && MathF.Abs(result.Position.Y - target.Y) < 0.05f, + "Mover must clear the boulder unobstructed (true-sphere curve-hit " + + $"clears at this height/offset); got {result.Position}, wanted {target}."); + + // Review F8 (2026-08-07): the POSITIVE control. Without it, the graze + // assertion above also passes when the shadow object is absent from + // the queried cell entirely (a membership/seed/flood regression) — + // the discriminating power lived only in an unreproducible sabotage + // run. Same publication, same cell, but aimed straight THROUGH the + // boulder's centre: the head sphere's closest approach to the centre + // is (0, 0, 0.85), |d| = 0.85 < radsum 1.15 — the true sphere MUST + // block this one. The pair proves reachable AND correctly shaped. + var throughBody = MakeGroundedBody(new Vector3(10.7f, 12f, 0f)); + ResolveResult blockedResult = fixture.Engine.ResolveWithTransition( + throughBody.Position, new Vector3(13.3f, 12f, 0f), 0xA9B40001u, + sphereRadius: 0.15f, sphereHeight: 3.0f, + stepUpHeight: 0.60f, stepDownHeight: 0.04f, + isOnGround: true, body: throughBody, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0u); + Assert.True( + MathF.Abs(blockedResult.Position.X - 13.3f) >= 0.05f, + "Aiming straight through the boulder's centre must NOT arrive at " + + $"the far side; got {blockedResult.Position} — the sphere is " + + "either unregistered in cell 0xA9B40001 or not colliding."); + } + + private static PhysicsBody MakeGroundedBody(Vector3 position) + { + var floorPlane = new Plane(Vector3.UnitZ, 0f); + var floorVerts = new[] + { + new Vector3(-100f, -100f, 0f), + new Vector3( 100f, -100f, 0f), + new Vector3( 100f, 100f, 0f), + new Vector3(-100f, 100f, 0f), + }; + return new PhysicsBody + { + Position = position, + Orientation = Quaternion.Identity, + ContactPlaneValid = true, + ContactPlane = floorPlane, + ContactPlaneCellId = 0xA9B40001u, + WalkablePolygonValid = true, + WalkablePlane = floorPlane, + WalkableVertices = floorVerts, + WalkableUp = Vector3.UnitZ, + TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + }; + } + [Fact] public void CompletePublication_BuildingShellNeverRegistersSetupCollision() { diff --git a/tests/AcDream.Content.Tests/Ap155StaticSpherePopulationMeasurementTests.cs b/tests/AcDream.Content.Tests/Ap155StaticSpherePopulationMeasurementTests.cs new file mode 100644 index 00000000..00896777 --- /dev/null +++ b/tests/AcDream.Content.Tests/Ap155StaticSpherePopulationMeasurementTests.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; + +namespace AcDream.Content.Tests; + +/// +/// AP-155 population MEASUREMENT ONLY (contract +/// docs/research/2026-08-07-s2-static-sphere-contract.md, "Measurement" +/// section) — not a gate. Counts, over the installed client_portal.dat +/// Setups (the same dats.GetAllIdsOfType<Setup>() enumeration +/// and +/// already sweep), how +/// many actually REACH the fixed static-publication Sphere emission — i.e. +/// 's production dispatch, at +/// scale 1, emits ONLY shapes for +/// them. That dispatch (not a raw "has Spheres, no CylSpheres" field read) is +/// the correct population: a Setup can carry authored Spheres and STILL never +/// reach the sphere branch if a physics-BSP part suppresses it (AP-152) — 99 +/// of the 3,605 "sphere-only, no cylinder" Setups the reachability sweep +/// already counted fall into exactly that trap. +/// +public sealed class Ap155StaticSpherePopulationMeasurementTests +{ + [Fact] + public void InstalledSetups_SphereOnlyStaticPopulation_Measured() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + { + Console.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // Production physics-BSP predicate — same as + // InstalledSetupBspPrimitiveDispatchTests / FlatCollisionAssetBuilder.cs:377-380. + var physicsBspCache = new Dictionary(); + bool HasPhysicsBsp(uint gfxObjId) + { + if (physicsBspCache.TryGetValue(gfxObjId, out bool cached)) + return cached; + bool result = + dats.Portal.TryGet(gfxObjId, out GfxObj? gfx) + && gfx is not null + && gfx.Flags.HasFlag(GfxObjFlags.HasPhysics) + && gfx.PhysicsBSP?.Root is not null + && gfx.VertexArray is not null; + physicsBspCache[gfxObjId] = result; + return result; + } + + int totalSetups = 0; + int sphereOnlyReachable = 0; + var samples = new List(); + + foreach (uint id in dats.GetAllIdsOfType()) + { + if (!dats.Portal.TryGet(id, out Setup? setup) || setup is null) + continue; + totalSetups++; + + // The classifier: production FromSetup output, not raw Setup + // fields — this is what a landblock static entity built from + // this Setup actually registers. + var shapes = ShadowShapeBuilder.FromSetup(setup, 1f, HasPhysicsBsp); + if (shapes.Count == 0) + continue; + + bool sphereOnly = true; + foreach (var shape in shapes) + { + if (shape.CollisionType != ShadowCollisionType.Sphere) + { + sphereOnly = false; + break; + } + } + if (!sphereOnly) + continue; + + sphereOnlyReachable++; + if (samples.Count < 3) + samples.Add(id); + } + + Console.WriteLine("===== AP-155 static-sphere population measurement ====="); + Console.WriteLine($"Total installed Setups: {totalSetups}"); + Console.WriteLine($"Reach the static Sphere emission (post-fix, production dispatch): {sphereOnlyReachable}"); + Console.WriteLine("Three example object ids:"); + foreach (uint sample in samples) + Console.WriteLine($" 0x{sample:X8}"); + Console.WriteLine("========================================================="); + + // Structural sanity only — this is a measurement, not a gate. + Assert.True(totalSetups > 0, "Expected the installed DAT to enumerate at least one Setup."); + Assert.True( + sphereOnlyReachable > 0, + "Expected at least one Setup to reach the static Sphere emission in the installed DAT."); + } +} diff --git a/tests/AcDream.Content.Tests/LandblockPhysicsContentBuilderStaticSphereTests.cs b/tests/AcDream.Content.Tests/LandblockPhysicsContentBuilderStaticSphereTests.cs new file mode 100644 index 00000000..7eb48dff --- /dev/null +++ b/tests/AcDream.Content.Tests/LandblockPhysicsContentBuilderStaticSphereTests.cs @@ -0,0 +1,111 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Numerics; +using AcDream.Content; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Content.Tests; + +/// +/// AP-155 parity (route independence) for the second static publication site, +/// . A +/// Setup carrying only authored Spheres (no CylSpheres) must register the +/// SAME shapes — type, local position, radius, scale — as +/// , the LIVE path. Before the fix +/// this site emitted a height-capped Cylinder instead (base = origin - r*ẑ, +/// height = 2r), identically to LandblockPhysicsPublisher's copy of +/// the same bug — see that class's tests +/// (LandblockPhysicsPublisherTests.CompletePublication_SphereOnlySetup_*) +/// for the dispatch-level (Cylinder-vs-Sphere narrow phase) proof; the two +/// sites share the SAME ShadowObjectRegistry/TransitionTypes +/// query code; this class only needs to prove ITS emission is correct. +/// +public sealed class LandblockPhysicsContentBuilderStaticSphereTests +{ + private const uint LandblockId = 0xA9B40000u; + private const uint SetupId = 0x02000042u; + + [Fact] + public void PublishStaticCollision_SphereOnlySetup_MatchesFromSetupShapeForShape() + { + var setup = new Setup(); + setup.Spheres.Add(new Sphere + { + Origin = new Vector3(0.3f, -0.2f, 0.9f), + Radius = 0.55f, + }); + setup.Spheres.Add(new Sphere + { + Origin = new Vector3(-0.1f, 0.4f, 1.4f), + Radius = 0.25f, + }); + FlatSetupCollision flatSetup = FlatCollisionAssetBuilder.FlattenSetup(setup); + + const float entScale = 1.3f; + var entity = new WorldEntity + { + Id = 0x80A9B401u, + SourceGfxObjOrSetupId = SetupId, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + Scale = entScale, + MeshRefs = Array.Empty(), + }; + var landblock = new LoadedLandblock( + LandblockId, + new LandBlock { Terrain = new TerrainInfo[81], Height = new byte[81] }, + new[] { entity }); + var collisions = new LandblockCollisionBuild( + ImmutableDictionary.Empty, + ImmutableDictionary.Empty.Add(SetupId, flatSetup), + ImmutableDictionary.Empty, + ImmutableDictionary.Empty, + ImmutableArray.Empty, + ImmutableArray.Create(SetupId), + ImmutableArray.Empty); + + var engine = new PhysicsEngine(); + var cache = new PhysicsDataCache(); + LandblockPhysicsContentBuilder.CachePreparedObjects(cache, collisions); + + LandblockPhysicsContentBuilder.StaticCollisionPublication publication = + LandblockPhysicsContentBuilder.PublishStaticCollision( + engine, cache, landblock, collisions, origin: Vector3.Zero); + + Assert.Equal(1, publication.SetupOwnerCount); + Assert.Equal(0, publication.BspOwnerCount); + Assert.Equal(0, publication.NoCollisionCount); + + // The LIVE-path oracle for the SAME Setup/scale. + var expected = ShadowShapeBuilder.FromSetup(setup, entScale, _ => false); + Assert.Equal(2, expected.Count); + Assert.All( + expected, + shape => Assert.Equal(ShadowCollisionType.Sphere, shape.CollisionType)); + + ShadowShape[] expectedOrdered = expected.OrderBy(shape => shape.Radius).ToArray(); + // Entity registered at world origin with identity rotation, so + // RegisterMultiPart's world composition (entityWorldPos + + // Transform(shape.LocalPosition, entityWorldRot)) reduces to exactly + // shape.LocalPosition — the static entry's world Position is directly + // comparable to FromSetup's LocalPosition. + ShadowEntry[] entries = engine.ShadowObjects.AllEntriesForDebug() + .Where(entry => entry.EntityId == entity.Id) + .OrderBy(entry => entry.Radius) + .ToArray(); + + Assert.Equal(expectedOrdered.Length, entries.Length); + for (int i = 0; i < entries.Length; i++) + { + Assert.Equal(ShadowCollisionType.Sphere, entries[i].CollisionType); + Assert.Equal(expectedOrdered[i].LocalPosition, entries[i].Position); + Assert.Equal(expectedOrdered[i].Radius, entries[i].Radius); + Assert.Equal(expectedOrdered[i].Scale, entries[i].Scale); + // Review F4 (2026-08-07): assert, don't infer — the C4 lesson. + Assert.Equal(expectedOrdered[i].CylHeight, entries[i].CylHeight); + } + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs index b70a4f94..d1070cc6 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs @@ -241,6 +241,14 @@ public class ShadowObjectRegistryMultiPartTests radius: radius, cylHeight: radius * 2f); + private static ShadowShape Sph(float radius, Vector3 localPosition = default) + => ShadowShape.Sphere( + gfxObjId: 0u, + localPosition: localPosition, + localRotation: Quaternion.Identity, + scale: 1f, + radius: radius); + /// /// A physics-BSP part shape. defaults /// OFF-CENTRE because that is the DAT-real configuration: a GfxObj's @@ -389,6 +397,40 @@ public class ShadowObjectRegistryMultiPartTests Assert.DoesNotContain(farCell, cylCells); } + /// + /// AP-155 (S2 contract, 2026-08-07). Retail's 10-sphere clamp + /// (CObjCell::find_cell_list @0x0052b9f0, 0x0052ba21 cmp + /// eax,0xa) is a fixed-size destination-buffer limit on the CYLSPHERE + /// overload alone — it is not read by the Sphere-shape branch + /// BuildFloodSpheres takes as acdream's substitute for retail's + /// sorting-sphere overload (@0x0052b990, AP-157, still open): that branch + /// caps at int.MaxValue, i.e. not at all. Before this fix a static + /// Setup-Sphere entity registered as Cylinder shapes and so WAS subject to + /// this cap; after it, the same entity registers as Sphere shapes and + /// reaches this uncapped branch instead — verified here directly rather + /// than inferred from the source comment, matching + /// 's + /// eleventh-shape technique one branch over. + /// + [Fact] + public void BuildFloodSpheres_SphereBranchIsNeverCappedAtTen() + { + var far = new Vector3(0f, 72f, 0f); + uint ownCell = LbId | (uint)(1 * 8 + 1 + 1); // (x=1, y=1) + uint farCell = LbId | (uint)(1 * 8 + 4 + 1); // (x=1, y=4) + + var spheres = new ShadowShape[11]; + for (int i = 0; i < 10; i++) + spheres[i] = Sph(1f); + spheres[10] = Sph(1f, far); + List sphereCells = FloodCellsFor(spheres); + + Assert.Contains(ownCell, sphereCells); + // The eleventh Sphere still floods — unlike the eleventh CylSphere in + // BuildFloodSpheres_CapsCylSpheresAtTenButNeverTheBspParts above. + Assert.Contains(farCell, sphereCells); + } + /// /// The BoundsCentre is expressed in the SHAPE's own frame, so the part's /// LocalRotation must carry it — exactly as retail transforms the sphere