From 52aea775b93b3f278c5b16a57dea653847ec5c7f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 7 Aug 2026 00:57:36 +0200 Subject: [PATCH] =?UTF-8?q?test(physics):=20AP-157=20measured=20=E2=80=94?= =?UTF-8?q?=20CylHeight=20half=20retired,=20sorting-sphere=20half=20proven?= =?UTF-8?q?=20collision-unreachable;=20AD-55=20byte-decoded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign S S1A, both outcomes the measure-first rule exists for. AP-157's CylHeight half is RETIRED as a non-divergence: retail's own cylsphere overload (CObjCell::find_cell_list @0x0052b9f0) copies localtoglobal(low_pt) + radius per cylsphere, capped at 10, and never reads height — retail collapses a cylsphere to a base-point sphere exactly as acdream does. The sorting-sphere half measured REAL against retail's registration set — 1,812 of 3,343 evaluated Setups (54%) fail containment at 1 mm, worst shortfall 18.135 m — and then PROVEN collision-unreachable: for this branch the flood spheres and the collision-test geometry are the same per-part Sphere list, so every omitted cell is one the entity's test geometry cannot reach, and retail's wider sorting-sphere registrations are narrow-phase rejects on retail too. Fix deferred to the next bake-schema revision rather than performing Slice I3 surgery for zero behavioural delta. The measurement test stays in the tree as the permanent record (population cross-checked against the dispatch test's independently-committed constants: 3,506 = 3,605 - 99). AD-55 is byte-decoded and RESOLVED against our constant: the binary loads qword [0x007c6b28] = pi/18 exactly and executes FCOS — retail's Sledding flatness threshold is cos(10 deg) = 0.984808. Our 0.99999536f is cos(0.17453 DEGREES): the radian literal misread as degrees, which makes the object-friction arm unreachable on real terrain (nothing is flatter than 0.175 deg). Evidence note carries the full instruction listing and the polarity of the test ah,0x41 / jp idiom; the one-line fix + conformance test is S5, queued behind the running implementation slice for build-slot reasons. Co-Authored-By: Claude Opus 5 --- .../retail-divergence-register.md | 2 +- ...8-07-ad55-sledding-constant-byte-decode.md | 66 ++++ ...Ap157SortingSphereFloodMeasurementTests.cs | 285 ++++++++++++++++++ 3 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 docs/research/2026-08-07-ad55-sledding-constant-byte-decode.md create mode 100644 tests/AcDream.Core.Tests/Physics/Ap157SortingSphereFloodMeasurementTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 50b32ab7..a2f653a0 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-154 | **Filed 2026-08-06 at the AP-152 retirement (contract §11.6) — an undeclared dependency on a specific server implementation.** Retail COMPUTES `HAS_PHYSICS_BSP_PS` itself from its own part array (AP-153's anchors). acdream's query-time guard `Transition.BspOnlyDispatch` reads it out of the SERVER's wire `PhysicsState`: `LiveEntityCollisionBuilder.cs:161` copies `exactRecord.FinalPhysicsState` into `ShadowEntry.State`, and a repo-wide grep for `PhysicsStateFlags.HasPhysicsBsp` in `src/` returns only that predicate and one unrelated mover-state read. acdream never ORs the bit in client-side. It happens to be correct because ACE derives the same DAT bit (`WorldObject_Networking.cs:665-668` from `SetupFlags.HasPhysicsBSP`), overriding the weenie's authored value — which is why a 2018 weenie dump showing `PhysicsState = 0x8` for the cottage door does not contradict our own live capture of `0x10008`. | `src/AcDream.Core/Physics/TransitionTypes.cs:1348` (`BspOnlyDispatch`), call sites `:3911` / `:3954`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:161` | Narrowed, not closed, by the AP-152 fix: the shape list no longer contains a primitive for a BSP-bearing object, so the guard has nothing left to skip and the OUTCOME is now independent of the wire. The guard itself still keys on the wire. Not bundled — changing `registration.State` touches every consumer of `FinalPhysicsState` (Hidden, Missile, ethereal layer 2, the `[setstate]` log) and needs its own gate. | Against a server that does not derive the bit from the DAT, a BSP-bearing object built by a producer other than `FromSetup` would have its primitive tested where retail tests only the BSP. | `CPartArray::CacheHasPhysicsBSP` 0x00518110 (derives) vs `LiveEntityCollisionBuilder.cs:161` (copies); `HAS_PHYSICS_BSP_PS` acclient.h:2833 | | AP-155 | **Filed 2026-08-06 at the AP-152 retirement; NARROWED 2026-08-06 to its static-publication half alone.** Its flood half was bundled here with a different code path, a different population and a different gate — the exact fault the C4 handoff warns about — and its direction was recorded BACKWARDS; both are now split out as AP-156. **Static paths emit a Setup Sphere as a height-capped CYLINDER.** `LandblockPhysicsPublisher.cs:1030-1037` and `LandblockPhysicsContentBuilder.cs:683-690` both convert a Setup Sphere to `ShadowCollisionType.Cylinder` with `CylHeight = radius * 2f` and the origin shifted down by one radius; the live path emits a true `ShadowCollisionType.Sphere`, produced at exactly ONE site in `src/` (`ShadowShapeBuilder.cs`). Retail tests a Setup Sphere with `CSphere::intersects_sphere` @0x00537a80 / @0x00537fd0 (two overloads) in both cases — 3-D distance, no height clamp. The static paths also derive "has BSP" from `entity.MeshRefs` (the render mesh list) where the live path derives it from `setup.Parts` plus the effective post-`AnimPartChanged` identities; the two sources can disagree. | `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:1030-1037`; `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:683-690` | Affects static props only and changes their collision geometry over a much larger population than AP-152's 172, so it needs its own count and its own gate. Deliberately not folded into the AP-152 or AP-156 commits. | A static prop whose Setup carries a Sphere blocks over a height-clamped cylinder instead of a true sphere, and rests one radius lower than the authored origin. | `CSphere::intersects_sphere` 0x00537a80 / 0x00537fd0 | | AP-156 | **Filed 2026-08-06, split out of AP-155(b) at the AP-152 retail-conformance review, WITH ITS DIRECTION CORRECTED — and its worst half FIXED in the same commit.** **CORRECTION.** AP-155(b) recorded the flood approximation as *over*-inclusive ("a sphere contains the box's inscribed extent but is larger in the diagonal"), and that recorded direction was the stated reason the residual was safe to defer. It was empirically inverted. `BuildFloodSpheres` took each physics-BSP part's ROOT BOUNDING SPHERE RADIUS (`FlatCollisionAssetBuilder.cs:393` -> `LiveEntityCollisionBuilder.cs:137`) and centred it on the PART ORIGIN (`ShadowShapeBuilder.cs:194`), discarding the root sphere's own `Origin`. Measured over the installed `client_portal.dat`, independently twice: 376 of 973 physics-BSP parts have `|origin| > radius/2`, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD, Setup 0x0200129A); **POPULATION CORRECTED 2026-08-06 at the fix review (finding R2).** The row as filed said the flood failed to contain the object's own BSP sphere for '170 of the 172 AP-152 Setups'. That understates it: 172 is AP-152's DISPATCH population (Setups carrying BOTH a primitive and a physics-BSP part). After AP-152 EVERY BSP-bearing Setup floods from its BSP shapes alone, so the discarded origin mis-placed the flood across all 530 of them. Re-measured against PHYSICS-POLYGON VERTICES — a different DAT field from the sphere, so the measurement is not circular — by an independent scratch program outside the repo: **525 of the 530** BSP-bearing Setups have at least one flood sphere move; **428** fail vertex-level containment at a 1 mm tolerance (412 at 1 cm, the figure the fix review quotes); **0** fail after the fix, at any tolerance down to zero. Worst shortfall 35.869 m at entity scale 1.75 on Setup 0x0200129A. The old figures — 170 of 172, worst 9.911 m on 0x02000255 — remain correct for what they measured (root-sphere containment over the 172), and 43 of them had a post-AP-152 flood strictly SMALLER than the pre-AP-152 one. Indoor floods are 3-D (`CellTransit.cs:601` routes every `id & 0xFFFF >= 0x0100` candidate through `FindTransitCellsSphere`), so a tall prop or door slab was simply absent from EnvCells it occupies and never a broadphase candidate there — UNDER-inclusive membership, the #98 / #168 class. **FIXED HERE.** `ShadowShape.BoundsCenter` carries the root sphere's own centre in the shape's local frame; `FromSetup` and `FromLandblockBspParts` fill it from the SAME resolver that supplies the radius, and `BuildFloodSpheres` places the sphere at `partWorldPos + rotate(BoundsCenter, partWorldRot)`. Retail does exactly this: `CGfxObj::physics_sphere` (`[gfxobj+0x74]`) is assigned `BSPTREE::GetSphere(physics_bsp)` @0x005397e0 (`mov eax,[ecx]; add eax,4` — the root `BSPNODE`'s `CSphere`, past its 4-byte vftable), and `CEnvCell::find_transit_cells` @0x0052cae0 — the part-array overload reached from `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 through `CPartArray::calc_cross_cells_static` @0x00518160's `[vtbl+0x7c]` dispatch — loads it at `0x0052cb36 mov esi,[ecx+0x74]`, transforms its CENTRE through the part's own `Position` at `[part+0x30]` (`0x0052cb4c add eax,0x30` / `0x0052cb5a call Position::localtolocal`), and only then reads the radius at `0x0052cb65 fadd [esi+0xc]`. The same commit also retired the 10-sphere clamp on this branch: retail's clamp lives inside the CYLSPHERE overload alone (`CObjCell::find_cell_list` @0x0052b9f0, `0x0052ba21 cmp eax,0xa` / `0x0052ba28 mov ebp,0xa`) while the BSP walk has none — 7 installed Setups carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their tail parts were dropped from the flood entirely. **WHAT REMAINS OPEN.** acdream floods from the per-part spheres through its own sphere-vs-portal walk (`CellTransit.FindTransitCellsSphere`), where retail hands the part array to each cell's own `find_transit_cells` and tests every part's sphere against that cell's portal planes in cell-local space. The sphere SET is now exact; the TRAVERSAL is still acdream's. `find_bbox_cell_list`'s name notwithstanding, retail never forms a bounding box — AP-155(b)'s "acdream approximates retail's bounding BOX" was wrong as well. **SECOND RESIDUAL, added 2026-08-06 at the fix review (finding R4): acdream SCALES the flood sphere; retail does not.** `ShadowShapeBuilder` multiplies both the radius and (new in this commit) the centre by the entity/part scale. Retail's `CEnvCell::find_transit_cells` @0x0052cae0 reads only `CPhysicsPart::pos` (`[part+0x30]`) and never `CPhysicsPart::gfxobj_scale` (`[part+0x24]`), while `CPhysicsPart::find_obj_collisions` @0x0050d8d0 DOES thread `gfxobj_scale.z` into `SPHEREPATH::cache_localspace_sphere` — so retail's cross-cell walk is itself under-inclusive for scaled parts and acdream's is not. Over-inclusive for scale > 1 (safe), under-inclusive for scale < 1 (the #98/#168 direction). **ENFORCEMENT, added 2026-08-06 at the fix review (finding A1).** The invariant now lives at the TYPE, not only at the producer seam: `ShadowShape`'s constructor is private and BSP shapes are built only through `ShadowShape.Bsp(..., FlatCollisionSphere localBounds)`, which takes radius and centre as ONE value and scales them together. The former public 7-argument constructor with `BoundsCenter = default` let a future BSP producer reintroduce this exact bug silently and green. **CONNECTED-GATE NOTE (finding A2). A null result on tall props is EXPECTED until AP-158 / #333 lands, and is not evidence against this fix.** The geometry now lands in the right cell and is then discarded one layer down by acdream's own `maxReach` broadphase filter, which measures from the same part origin: 118 of the 477 unique installed physics-BSP GfxObjs have a root-sphere offset above that filter's roughly 2.5 m walking budget, and 46 above 5 m. | `src/AcDream.Core/Physics/ShadowShape.cs` (`BoundsCenter`); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 3, `FromLandblockBspParts`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`); `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (single bounds resolver); tests `ShadowObjectRegistryMultiPartTests.BuildFloodSpheres_BspShape_CentresOnTheBoundsCentreNotThePartOrigin` / `_RotatesTheBoundsCentreByThePartRotation` / `_CapsCylSpheresAtTenButNeverTheBspParts`, `ShadowRegistrationOverflowTests.FromLandblockBspParts_CarriesTheScaledRootSphereCentre`, `InstalledSetupBspPrimitiveDispatchTests.InstalledSetups_BspFloodSpheres_ContainTheirOwnPhysicsPolygons` (oracle swapped to physics-polygon vertices at the fix review, finding R1: the shipped assertion compared two hand-copies of the same expression and was algebraically identically zero for any DAT input) | The traversal residual is a genuine approximation with its own gate, not a deferral of this fix. Closing it means porting the per-cell `find_transit_cells` part-array overload, which is different work from getting the sphere set right. **OUTDOOR HALF CLOSED 2026-08-06 by #334 (see AP-159 for what remains).** | **RISK COLUMN CORRECTED 2026-08-06 at the #334 fix — as written below it was FALSE, and its falsity is what let #334 sit unnoticed inside this row.** It generalised the INDOOR direction (sphere-vs-portal-plane, over-inclusive) to the whole residual. The OUTDOOR direction was the opposite and strictly worse: acdream routed BSP-bearing objects through `CObjCell::find_cell_list`, whose outdoor expansion is a hard-capped ±1-cell 3×3 for ANY radius, so every formation wider than one 24 m land cell was MISSED in its outer cells — a user-observed loss of collision, not extra candidates. Original text, retained for the record: *"A cell whose portal geometry a part's sphere overlaps in the sphere-vs-plane sense, but which the part's actual polygons do not reach, joins the object's shadow set: extra broadphase candidates, never a missed one. The under-inclusive direction is what the fix above removed."* That statement now holds only for the indoor half, which is AP-159. | `BSPTREE::GetSphere` 0x005397e0; `CGfxObj::physics_sphere` `[gfxobj+0x74]`; `CEnvCell::find_transit_cells` 0x0052cae0 (0x0052cb36 / 0x0052cb4c / 0x0052cb65); `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPartArray::calc_cross_cells_static` 0x00518160; `CObjCell::find_cell_list` 0x0052b9f0 (0x0052ba21) | -| AP-157 | **Filed 2026-08-06 at the AP-152 retail-conformance review (finding F4) — an unregistered substitution that predates AP-152 and was stepped over when its neighbours were filed.** `CPhysicsObj::calc_cross_cells`' THIRD branch (`0x005152dc` -> `CPartArray::GetSortingSphere` @0x00518b00 -> `CObjCell::find_cell_list` @0x0052b990) floods from ONE authored whole-object sphere: `GetSortingSphere` returns `[partArray+0x54] + 0x70`, i.e. `CSetup::sorting_sphere` (acclient.h: `CSetup` carries `CSphere sorting_sphere` immediately after `step_up_height`), and that overload takes a single sphere with no cap. acdream's `only == null` branch floods from EVERY non-BSP, non-Cylinder shape instead — the Setup's per-part `Spheres` array. Different DAT field, different cardinality, different extent. 4,154 of 5,935 installed Setups carry a non-zero `SortingSphere` and `DatReaderWriter.Setup` already exposes it, so this is available rather than blocked. Same site, second item: `BuildFloodSpheres` collapses a Cylinder to one sphere at its BASE point with the cylinder radius and IGNORES `CylHeight` entirely, where retail's `CObjCell::find_cell_list` @0x0052b9f0 is handed the `CCylSphere` array as `(low_pt, radius, height)`. | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`, the `anyCyl` and `only == null` branches) | Deliberately NOT folded into the AP-156 fix. It is a different branch of `calc_cross_cells`, reached only by objects with neither a physics BSP nor a CylSphere, so its population is disjoint from the 172 AP-152 Setups and its live gate is a different set of objects. Bundling it would make the AP-156 connected gate un-attributable — which is exactly how AP-155 came to carry two lifecycles under one id. | Sorting-sphere half: an object with several authored Spheres floods from all of them rather than from the one authored whole-object sphere — usually wider (max 5 Spheres on any installed Setup, so retail's 10-cap is never the difference), but a `sorting_sphere` LARGER than every per-part Sphere would make acdream under-inclusive, the #98 / #168 direction. CylHeight half: a tall thin cylinder floods a sphere of its radius at its base and can miss the cells its upper half occupies. | `CPhysicsObj::calc_cross_cells` 0x00515230 (0x005152dc / 0x005152e3 / 0x005152fb); `CPartArray::GetSortingSphere` 0x00518b00 (`[+0x54]+0x70`); `CObjCell::find_cell_list` 0x0052b990 (sorting sphere) / 0x0052b9f0 (cylsphere, `(low_pt, radius, height)`) | +| AP-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 | | AP-159 | **Filed 2026-08-06 at the #334 fix - the INDOOR half of AP-156's traversal residual, now the whole of it.** #334 ported retail's `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 path so a physics-BSP object's OUTDOOR membership is the filled land-cell rectangle its authored `CGfxObj::gfx_bound_box` spans (`CLandCell::add_all_outside_cells` @0x00533360 -> `add_cell_block` @0x005331d0). The INDOOR arm of that same walk is NOT ported: retail's part-array `CEnvCell::find_transit_cells` @0x0052cae0 admits a neighbour cell on a BOX test - `CPhysicsPart::GetBoundingBox` @0x0050d600 -> `BBox::LocalToLocal` @0x005b1e60 (`0x0052cbf9`) -> `Plane::intersect_box` @0x005aa170 (`0x0052cc05`), then `BBox::LocalToLocal` into the destination and `CCellStruct::box_intersects_cell` @0x00533910 -> `BSPTREE` @0x0053c880 - where acdream keeps `CellTransit.FindTransitCellsSphere`'s sphere-vs-portal-plane test, fed from the SAME per-part `CGfxObj::physics_sphere` values retail uses for its cheap `eps = F_EPSILON + radius` pre-reject at `0x0052cb65`. The outdoor building bridge (`CEnvCell::check_building_transit` @0x0052c5d0) is on the same sphere input for the same reason. Deferred deliberately: closing it needs a new BOX traversal of the containment BSP in BOTH the graph (`BSPQuery`) and the production flat (`FlatBspQuery`) representations plus their exact referee, which is a separately gateable change with no bearing on #334's outdoor defect. Filed as issue #335. | `src/AcDream.Core/Physics/CellTransit.cs` (`BuildShadowCellSetFromParts`, indoor arm); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildBspPartSpheres`) | The sphere set is exact (AP-156) and the sphere is a strictly LOOSER admitter than the box for a convex part, so the indoor set is a superset of retail's. Retail is itself conservative here in four compounding ways (render-mesh AABB over physics hull, axis-aligned re-fit after rotation, filled rectangle over per-cell test, one rectangle unioned across parts), so an over-inclusive indoor set is the same direction retail errs in. | A cell whose portal plane a part's sphere straddles but whose box does not joins the object's shadow set: extra broadphase candidates, never a missed one. This is AP-156's original risk statement, which is true of the indoor half and was false of the outdoor half. | `CEnvCell::find_transit_cells` 0x0052cae0 (0x0052cbdd / 0x0052cbf9 / 0x0052cc05 / 0x0052cc5a); `Plane::intersect_box` 0x005aa170; `CCellStruct::box_intersects_cell` 0x00533910; `CEnvCell::check_building_transit` 0x0052c5d0 | | ~~AP-152~~ | **RETIRED 2026-08-06 (the commit that filed it is one day old; this retirement corrects four statements in it).** `ShadowShapeBuilder.FromSetup` now DISPATCHES instead of unioning: a step-0 gate derived from the parts suppresses steps 1 and 2 whenever any part's EFFECTIVE GfxObj carries a physics BSP. Retail's priority, re-disassembled from the PDB-paired binary for this commit rather than inherited: `CPhysicsObj::FindObjCollisions` @0x0050f050 tests `HAS_PHYSICS_BSP_PS` FIRST (`0x0050f165 test dword [esi+0xa8],0x10000` / `0x0050f16f je 0x50f1a2`) and leaves the BSP branch through the UNCONDITIONAL `0x0050f19d jmp 0x50f2b0`, which is past the CylSphere loop at 0x50f1a2 AND the Sphere loop at 0x50f21d; a CylSphere-bearing object that survives its loop RETURNS (`0x0050f1d6 jae 0x50f317`); a Setup with zero spheres returns the seeded OK_TS (`0x0050f22f je 0x50f31b`). **BSP wins.** **CORRECTION 1 — the row's risk statement was FALSE as written.** It predicted "catching or stopping on a doorway sill". acdream did not test the extra primitive either: `Transition.BspOnlyDispatch` (`TransitionTypes.cs:1348`, landed 2026-05-25 as A6.P7) already skipped BOTH primitive branches (`:3911`, `:3954`) whenever the target's wire `PhysicsState` carries 0x10000, and ACE sets that bit from `CSetup.HasPhysicsBSP` (`WorldObject_Networking.cs:665-668`). The row's own anchor column cites the flag it failed to notice acdream was already keying on. So this retirement is NOT a collision-response change; the live half was CELL MEMBERSHIP, which had no such guard (see AP-155). **CORRECTION 2 — "the affected primitives are small and centred at the part origin" was FALSE in both halves.** The largest is `0x02001741`'s CylSphere at **r = 6.714 m**; `0x0200086E`'s Sphere is r = 5.842 m with origin (0.759, 0.165, 5.842), nowhere near the part origin. **CORRECTION 3 — the cottage door's "~14 cm base Sphere" was the wrong field.** `0x020019FF`'s Sphere radius is **0.100 m** at origin (0, 0, 0.018); `0.141` is `Setup.Radius`, which AP-22 had just finished proving is never collision geometry. **CORRECTION 4 — the row named ONE pinning test where TWO existed.** `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` also failed under the exclusive rule; both are corrected, neither deleted. Population re-measured independently at 172 of 5,935 (73 CylSphere+BSP, 99 Sphere+BSP; 530 carry a physics-BSP part), agreeing exactly with the filing commit's separate sweep, and now pinned by an installed-DAT test with external bucket controls. | RETIRED — `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 0 gate + `EffectivePartGfxObjId`, shared with step 3 so the two can never read different identities); `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` (`FromSetup_DoorSetup_EmitsBspPartsOnly`, `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` re-hosted on `_ => false`, `FromSetup_DispatchGateReadsTheEffectivePartIdentities`); `tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs` (`CylSphereAndPhysicsBspPart_EmitsOnlyTheScaledBspShape` — no App fixture combined a primitive with a BSP part before); `tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs` (population). `Transition.BspOnlyDispatch` is deliberately KEPT: retail genuinely dispatches at the query site too, and it guards against a future additive producer. | — | — | `CPhysicsObj::FindObjCollisions` 0x0050f050 (0x0050f165 / 0x0050f16f / 0x0050f19d / 0x0050f1d6 / 0x0050f22f); `CPhysicsObj::calc_cross_cells` 0x00515230 (0x00515285 / 0x0051528f) -> `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPhysicsPart::find_obj_collisions` 0x0050d8d0; `CPartArray::CacheHasPhysicsBSP` 0x00518110; evidence `docs/research/2026-08-06-ap152-contract.md` | diff --git a/docs/research/2026-08-07-ad55-sledding-constant-byte-decode.md b/docs/research/2026-08-07-ad55-sledding-constant-byte-decode.md new file mode 100644 index 00000000..982c303a --- /dev/null +++ b/docs/research/2026-08-07-ad55-sledding-constant-byte-decode.md @@ -0,0 +1,66 @@ +# AD-55 resolved by byte-decode — the Sledding flatness threshold is cos(10°), and our constant is a unit slip + +**Date:** 2026-08-07 (overnight). **Method:** `reference_pe_byte_decode` — raw +bytes from the PDB-paired v11.4186 binary (`check_exe_pdb.py` → MATCH, +CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`), not the decomp text. + +## The question AD-55 filed + +`PhysicsBody.calc_friction`'s Sledding near-flat branch compares +`GroundNormal.Z > 0.99999536f` (≈0.175° from flat). The raw decomp of +`CPhysicsObj::calc_friction` @0x0050ee70 instead shows +`__fcos(0.17453292519943295)` — cos(10°) ≈ 0.984808 — compared against +`contact_plane.N.z`. One of the two had to be a decode artifact. + +## The bytes @0x0050ef53 (verbatim from the binary) + +``` +d9 86 38 01 00 00 fld dword [esi+0x138] ; contact_plane.N.z +dd 05 28 6b 7c 00 fld qword [0x007c6b28] ; the constant +d9 ff fcos +de d9 fcompp ; cos(const) vs N.z +df e0 fnstsw ax +f6 c4 41 test ah, 0x41 +7a 0a jp +0x0a ; skip the friction load +8b 86 bc 00 00 00 mov eax, [esi+0xbc] ; this->friction +89 44 24 04 mov [esp+4], eax +d9 44 24 04 fld dword [esp+4] +``` + +`qword [0x007c6b28]` = **0.17453292519943295 = π/18 exactly** (verified by +direct read at the mapped file offset). The binary genuinely executes `FCOS` +at runtime — the compiler did not fold it — so the threshold retail compares +against `N.z` is **cos(π/18 rad) = cos(10°) = 0.984807753...**. + +## The verdict + +- **The decomp was RIGHT. Our port is wrong.** `0.99999536f` is + `cos(0.17453292519943295°)` — the *radian* literal read as *degrees* and + run through a degree→radian cosine. A one-character-class unit slip that + survived because nothing gates slope feel numerically. +- **Felt consequence:** the branch means "on ground flatter than the + threshold, use the object's own friction; on steeper ground (while slow — + the `arg3 < 6.25` speed² gate at 0x0050ef46 guards this), keep the 0.2 + sliding friction." With our constant, "flat" requires < 0.175° — real + terrain triangles essentially never qualify, so the object-friction arm of + Sledding is unreachable in practice and slow movers keep sliding friction + on gentle slopes retail treats as flat. Ice-feel in exactly the S4/S5 + slope-feel family. +- **Fix shape (S5):** replace the constant with retail's semantics. Either + the folded `0.98480775f` with a comment carrying this evidence, or the + exact `MathF.Cos(MathF.PI / 18f)` computed once — prefer the folded + constant + comment, matching how AP-7's 0.25f landed. Polarity must be + ported from the `test ah,0x41; jp` idiom above, not assumed: the friction + load is SKIPPED when the jump is taken (cos(10°) > N.z, i.e. steeper than + 10°, or unordered), and taken when N.z ≥ cos(10°). Verify our branch's + existing polarity against this before changing only the constant. +- The speed² gates in the same function — 1.5625 (= 1.25²) at 0x0050ef24 and + 6.25 (= 2.5²) at 0x0050ef46 — matched our port already at AP-7 and are + untouched. + +## Bookkeeping owed at the fix + +Retire AD-55 (the row's open question is now answered against our constant); +conformance test pinning `0.98480775f` + the ported polarity; S4/S5's +slope-feel session covers the felt change. Until the fix lands, the register +row stands corrected by this note. diff --git a/tests/AcDream.Core.Tests/Physics/Ap157SortingSphereFloodMeasurementTests.cs b/tests/AcDream.Core.Tests/Physics/Ap157SortingSphereFloodMeasurementTests.cs new file mode 100644 index 00000000..347b9e0f --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Ap157SortingSphereFloodMeasurementTests.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Tests.Conformance; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; + +namespace AcDream.Core.Tests.Physics; + +/// +/// AP-157 (sorting-sphere half) MEASUREMENT ONLY. Retail's third +/// CPhysicsObj::calc_cross_cells @0x00515230 branch — an object with +/// NO physics-BSP part and NO CylSpheres — floods cell membership from ONE +/// authored whole-object sphere: CPartArray::GetSortingSphere +/// @0x00518b00 returns CSetup::sorting_sphere, and +/// CObjCell::find_cell_list @0x0052b990 (the sorting-sphere overload, +/// which pushes a literal 1 at 0x0052b9d6) takes that single sphere. +/// 's BuildFloodSpheres instead +/// floods from EVERY per-part Setup.Spheres shape it emits for that +/// same object (a different DAT field, a different cardinality — see the +/// AP-157 note inline at ShadowObjectRegistry.cs around +/// BuildFloodSpheres). This sweep asks: over the installed +/// client_portal.dat, is the per-Sphere flood ever UNDER-inclusive +/// relative to the authored sorting sphere? +/// +/// +/// The CylHeight half of AP-157 is a SEPARATE, ALREADY-SETTLED +/// non-divergence (retail's cylsphere overload @0x0052b9f0 never reads +/// height) and is NOT covered here. +/// +/// +/// +/// No re-derivation of BuildFloodSpheres' private branch logic: the +/// classifier is 's own OUTPUT +/// (production), and the flood-sphere geometry is read back from +/// + +/// — the same +/// test-visible entry ShadowObjectRegistryMultiPartTests uses. This +/// is valid for exactly the population this test selects (zero BSP shapes, +/// zero Cylinder shapes, at least one Sphere shape): a primitive shape's +/// BoundsCenter is always Vector3.Zero +/// (), so BuildFloodSpheres' +/// composition partWorldPos + Transform(BoundsCenter, partWorldRot) +/// reduces to exactly partWorldPos — algebraically identical to the +/// ShadowEntry.Position that RegisterMultiPart writes for +/// every shape it registers. Registering at world origin with identity +/// rotation makes that partWorldPos equal to the shape's raw +/// LocalPosition, i.e. the Setup's own authored frame — the same +/// frame Setup.SortingSphere is authored in. And because this +/// population by construction carries zero Cylinder shapes, +/// BuildFloodSpheres' internal dispatch (cylsphere branch vs. +/// sphere-shape branch) always resolves to the sphere-shape branch with no +/// cap — every emitted Sphere shape becomes a flood sphere, so +/// AllEntriesForDebug()'s deduplicated per-shape rows are exactly +/// the flood-sphere list, not a subset or approximation of it. +/// +/// +public sealed class Ap157SortingSphereFloodMeasurementTests +{ + /// Arbitrary non-zero landblock id — only its prefix matters for + /// seeding the outdoor flood; the entity is placed at the landblock's own + /// origin so cell (0,0) always resolves. + private const uint LandblockId = 0xA9B40000u; + + private const int SurfaceSampleDirections = 256; // >= 200 required by the task + + [Fact] + public void InstalledSetups_ThirdBranchSortingSphereFloodCoverage_Measured() + { + string? datDir = ConformanceDats.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; + } + + IReadOnlyList sampleDirections = BuildFibonacciSphereDirections(SurfaceSampleDirections); + + int totalSetups = 0; + int thirdBranchPopulation = 0; + int zeroRadiusSortingSphereCount = 0; + int emptyFloodCount = 0; + int failuresAt1mm = 0; + int failuresAt1cm = 0; + float worstShortfallMetres = 0f; + uint worstShortfallSetupId = 0u; + float maxOvershootMetres = 0f; + uint maxOvershootSetupId = 0u; + + // Top-3 worst shortfalls for the report (task asks for the worst three). + var worstThree = new List<(uint SetupId, float Shortfall)>(); + + 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. + IReadOnlyList shapes = ShadowShapeBuilder.FromSetup(setup, 1f, HasPhysicsBsp); + + int bspCount = 0, cylCount = 0, sphCount = 0; + foreach (ShadowShape shape in shapes) + { + switch (shape.CollisionType) + { + case ShadowCollisionType.BSP: bspCount++; break; + case ShadowCollisionType.Cylinder: cylCount++; break; + case ShadowCollisionType.Sphere: sphCount++; break; + } + } + if (bspCount != 0 || cylCount != 0 || sphCount == 0) + continue; // not the third branch + + thirdBranchPopulation++; + + // Register through the PRODUCTION path, at world origin / identity + // rotation / scale 1, then read the flood spheres back through the + // same test-visible entry the MultiPart tests use. + var reg = new ShadowObjectRegistry(); + const uint ownerId = 0xCAFEu; + reg.RegisterMultiPart( + ownerId, Vector3.Zero, Quaternion.Identity, + shapes, 0u, EntityCollisionFlags.None, + worldOffsetX: 0f, worldOffsetY: 0f, landblockId: LandblockId); + + var flood = new List<(Vector3 Centre, float Radius)>(); + foreach (ShadowEntry entry in reg.AllEntriesForDebug()) + { + if (entry.EntityId != ownerId) continue; + flood.Add((entry.Position, entry.Radius)); + } + if (flood.Count == 0) + { + emptyFloodCount++; + continue; + } + + Vector3 sortingCentre = setup.SortingSphere.Origin; + float sortingRadius = setup.SortingSphere.Radius; + + // Reverse direction (overshoot) — meaningful regardless of the + // sorting sphere's radius, including zero. + foreach ((Vector3 fc, float fr) in flood) + { + float overshoot = (Vector3.Distance(fc, sortingCentre) + fr) - sortingRadius; + if (overshoot > maxOvershootMetres) + { + maxOvershootMetres = overshoot; + maxOvershootSetupId = id; + } + } + + if (sortingRadius <= 0f) + { + // Retail floods from a ZERO sphere here — acdream's per-Sphere + // flood is strictly MORE inclusive by construction. Not a + // failure; tallied separately, no containment sampling run. + zeroRadiusSortingSphereCount++; + continue; + } + + float worst = EvaluateWorstShortfall(sortingCentre, sortingRadius, flood, sampleDirections); + + bool fail1mm = worst > 0.001f; + bool fail1cm = worst > 0.01f; + if (fail1mm) failuresAt1mm++; + if (fail1cm) failuresAt1cm++; + + if (fail1mm) + { + worstThree.Add((id, worst)); + worstThree.Sort((a, b) => b.Shortfall.CompareTo(a.Shortfall)); + if (worstThree.Count > 3) worstThree.RemoveRange(3, worstThree.Count - 3); + } + + if (worst > worstShortfallMetres) + { + worstShortfallMetres = worst; + worstShortfallSetupId = id; + } + } + + Console.WriteLine("===== AP-157 sorting-sphere flood measurement ====="); + Console.WriteLine($"Total installed Setups: {totalSetups}"); + Console.WriteLine($"Third-branch population (0 BSP, 0 Cyl, >=1 Sphere shape): {thirdBranchPopulation}"); + Console.WriteLine($" of which SortingSphere.Radius == 0: {zeroRadiusSortingSphereCount} (NOT failures — retail floods from a zero sphere there too)"); + Console.WriteLine($" of which flood sphere list was empty: {emptyFloodCount} (structural anomaly, excluded from containment tallies)"); + int evaluated = thirdBranchPopulation - zeroRadiusSortingSphereCount - emptyFloodCount; + Console.WriteLine($"Evaluated for containment (radius > 0, non-empty flood): {evaluated}"); + Console.WriteLine($"Containment failures at 1 mm tolerance: {failuresAt1mm}"); + Console.WriteLine($"Containment failures at 1 cm tolerance: {failuresAt1cm}"); + Console.WriteLine( + $"Worst shortfall: {worstShortfallMetres.ToString("F4", CultureInfo.InvariantCulture)} m " + + $"on Setup 0x{worstShortfallSetupId:X8}"); + Console.WriteLine("Worst three (setup id, shortfall metres):"); + foreach ((uint setupId, float shortfall) in worstThree) + { + Console.WriteLine( + $" 0x{setupId:X8}: {shortfall.ToString("F4", CultureInfo.InvariantCulture)} m"); + } + Console.WriteLine( + $"Max overshoot (flood extends beyond sorting sphere): " + + $"{maxOvershootMetres.ToString("F4", CultureInfo.InvariantCulture)} m " + + $"on Setup 0x{maxOvershootSetupId: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(thirdBranchPopulation > 0, "Expected at least one third-branch Setup in the installed DAT."); + } + + /// + /// Max over every sampled point on (and at the centre of) the sorting + /// sphere of "distance to the nearest flood sphere's surface" — negative + /// or zero means the point sits inside some flood sphere; positive is an + /// uncovered shortfall in metres. + /// + private static float EvaluateWorstShortfall( + Vector3 sortingCentre, + float sortingRadius, + IReadOnlyList<(Vector3 Centre, float Radius)> flood, + IReadOnlyList sampleDirections) + { + float worst = float.NegativeInfinity; + + void Probe(Vector3 point) + { + float best = float.MaxValue; + foreach ((Vector3 fc, float fr) in flood) + { + float need = (point - fc).Length() - fr; + if (need < best) best = need; + } + if (best > worst) worst = best; + } + + Probe(sortingCentre); + foreach (Vector3 dir in sampleDirections) + Probe(sortingCentre + dir * sortingRadius); + + return worst; + } + + /// Fibonacci-sphere unit directions — a simple, deterministic, + /// near-uniform surface sample set. + private static IReadOnlyList BuildFibonacciSphereDirections(int count) + { + var dirs = new List(count); + double goldenAngle = Math.PI * (3.0 - Math.Sqrt(5.0)); + for (int i = 0; i < count; i++) + { + double y = 1.0 - (i / (double)(count - 1)) * 2.0; // 1 .. -1 + double radiusAtY = Math.Sqrt(Math.Max(0.0, 1.0 - y * y)); + double theta = goldenAngle * i; + double x = Math.Cos(theta) * radiusAtY; + double z = Math.Sin(theta) * radiusAtY; + dirs.Add(new Vector3((float)x, (float)y, (float)z)); + } + return dirs; + } +}