# AP-152 contract — additive vs exclusive collision-shape emission **Status:** authored 2026-08-06, planning only. No production or test code written; no commit made. The only file this session wrote inside the repo is this one. **Worktree:** `.claude/worktrees/resume-session-e0bd03e1-d5bf45`, branch `claude/resume-session-e0bd03e1-d5bf45`, base HEAD `0d62a5ff` (identical to `main`). **Register row:** AP-152, `docs/architecture/retail-divergence-register.md:183`. **Predecessor:** `docs/research/2026-08-06-ap22-contract.md` (§11.3 is where this row was spun off). --- ## 1. Verdict Retail's exclusivity is real and I re-verified every instruction of it independently (§2). acdream's live shape list really is additive (§3). **The 172-Setup figure is exactly right** — re-derived here from three independent decoders (§4). **But the register row's stated risk does not currently occur, and the reason is a mechanism the row does not mention at all.** acdream already implements retail's exclusive dispatch — at **collision-query time**, not at build time. `Transition.BspOnlyDispatch(obj.State)` (`TransitionTypes.cs:1348`, landed 2026-05-25 as "A6.P7") skips **both** the Cylinder branch (`:3954`) and the Sphere branch (`:3911`) whenever the target entity's `PhysicsState` carries `HAS_PHYSICS_BSP_PS` (0x10000). And ACE sets that bit on every affected object: `WorldObject_Networking.cs:665-668` derives it from `CSetup.HasPhysicsBSP`, the DAT-authored `SetupFlags.HasPhysicsBSP` bit — which my sweep proves agrees with the per-part derived predicate on **all 5,935 installed Setups, zero disagreements** (§4.2). So on the live path, against ACE, **the extra primitive is never tested**. "Catching or stopping on a doorway sill" is not a symptom that can be occurring. The row's risk statement describes a defect that the A6.P7 guard already closed fourteen months of commits ago. What the additive list **does** still change is **cell membership**. The shape list is the input to `ShadowObjectRegistry.BuildFloodSpheres` (`ShadowObjectRegistry.cs:606`), which has *no* `BspOnlyDispatch` guard and which **prefers Cylinders over everything else whenever any Cylinder is present**. Retail's `CPhysicsObj::calc_cross_cells` @`0x00515230` dispatches on the *same* `HAS_PHYSICS_BSP_PS` flag *first* and routes a BSP-bearing object to `CPhysicsObj::find_bbox_cell_list` @`0x00510fc0` — never to the cylspheres (§2.3, byte-verified). So for the **73 CylSphere+BSP** Setups acdream floods shadow cells from the wrong geometry today, and the fix corrects that as a side effect. This — not door blocking — is the real behavioural surface, and it is the `#98`/`#168` symptom class (an object present in the wrong set of shadow cells). **The fix is still worth doing and is still small.** It removes a false shape list, it makes the live path agree with the two static paths, it corrects the flood source for 73 Setups, and it makes the behaviour independent of what ACE chooses to put in `PhysicsState` — which is the deeper divergence (§11.6) and which acdream is currently relying on without saying so anywhere. Eleven claims found false or stale at HEAD are in §11. Four of them are in the AP-152 row itself. --- ## 2. What retail does — re-verified from the binary Binary: `C:\Users\erikn\Downloads\acclient.exe`, v11.4186, linker UTC 2013-09-06T00:17:56, CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`. `py tools/pdb-extract/check_exe_pdb.py` → `=== MATCH: this exe pairs with our acclient.pdb ===`. Image base `0x00400000`. Every address below was disassembled from that binary this session with a from-scratch capstone script, **and** resolved back to a name through `docs/research/named-retail/symbols.json` by exact address. The name/address pairs are not inherited from AP-22: | Address | PDB name (exact hit) | |---|---| | `0x0050f050` | `CPhysicsObj::FindObjCollisions` | | `0x00518180` | `CPartArray::FindObjCollisions` | | `0x0050d8d0` | `CPhysicsPart::find_obj_collisions` | | `0x00518060` / `0x00518070` | `CPartArray::GetNumSphere` / `GetSphere` | | `0x00518080` / `0x00518090` | `CPartArray::GetNumCylsphere` / `GetCylsphere` | | `0x005180a0` / `0x005180b0` | `CPartArray::GetRadius` / `GetHeight` | | `0x0050f570` | `CPhysicsObj::CacheHasPhysicsBSP` | | `0x00518110` | `CPartArray::CacheHasPhysicsBSP` | | `0x00515230` | `CPhysicsObj::calc_cross_cells` | | `0x00510fc0` | `CPhysicsObj::find_bbox_cell_list` | | `0x0052b9f0` / `0x0052b990` | `CObjCell::find_cell_list` | | `0x00518b00` | `CPartArray::GetSortingSphere` | | `0x0050ceb0` | `OBJECTINFO::missile_ignore` | `HAS_PHYSICS_BSP_PS = 0x10000` is `acclient.h:2833`, in `enum PhysicsState`. ### 2.1 The collision dispatch is a four-way exclusive choice `CPhysicsObj::FindObjCollisions` @ `0x0050f050`. `edi` is the result, seeded `OK_TS` at `0x0050f13b mov edi, 1`. ``` 0050f165 f786a800000000000100 test dword ptr [esi + 0xa8], 0x10000 ; this->state & HAS_PHYSICS_BSP_PS 0050f16f 7431 je 0x50f1a2 ; clear -> primitive dispatch 0050f171 85ed test ebp, ebp 0050f173 752d jne 0x50f1a2 ; pass-through predicate -> primitive dispatch 0050f178 e833ddffff call 0x50ceb0 ; OBJECTINFO::missile_ignore 0050f17f 7521 jne 0x50f1a2 0050f181 8b4e10 mov ecx, dword ptr [esi + 0x10] ; this->part_array 0050f186 0f848f010000 je 0x50f31b ; null -> epilogue, return OK_TS 0050f18d e8ee8f0000 call 0x518180 ; CPartArray::FindObjCollisions (per-part BSP walk) 0050f194 83ff01 cmp edi, 1 0050f197 0f847e010000 je 0x50f31b ; OK -> return 0050f19d e90e010000 jmp 0x50f2b0 ; UNCONDITIONAL — past BOTH primitive loops ``` `0x0050f19d` is an unconditional `jmp` to `0x50f2b0`. The CylSphere loop begins at `0x50f1a2` and the Sphere loop at `0x50f21d`; both are below the target. **The BSP branch cannot reach either primitive branch — not "prefers", cannot.** CylSphere branch, `0x50f1a2`: ``` 0050f1a7 je 0x50f21d ; null part array -> Sphere path 0050f1a9 call 0x518080 ; GetNumCylsphere 0050f1b0 je 0x50f21d ; zero cylspheres -> Sphere path 0050f1c9 je 0x50f317 ; loop guard -> 0x50f31b RETURN 0050f1d6 jae 0x50f317 ; loop exhausted -> 0x50f31b RETURN ``` A CylSphere-bearing object that survives its loop **returns**; it never falls into the Sphere loop. Sphere branch, `0x50f21d`: ``` 0050f222 je 0x50f31b ; null part array -> RETURN OK_TS 0050f228 call 0x518060 ; GetNumSphere 0050f22f je 0x50f31b ; zero spheres -> RETURN OK_TS ``` **Priority order, decided top-down: BSP → CylSphere → Sphere → nothing.** When a Setup carries both a primitive and a physics-BSP part, **the BSP wins**. That is the answer to "which shape wins", and it is decided by a single `test`/`je` pair at the top of the function, before any primitive is read. `ebp` is a whole-object pass-through predicate computed at `0x0050f0cf-0x0050f134` (weenie present, two virtual calls, `[transition->object_info.state]` bits `0x100`, `0x80`, `0x800`, `0x10`). It is tested identically in all three branches — set, the object collides with nothing at all. It does not change which shape wins. ### 2.2 A part has no primitive of its own — re-confirmed `CPhysicsPart::find_obj_collisions` @ `0x0050d8d0`, whole body: ``` 0050d8d3 mov ecx, [esi+0x20] ; this->gfxobj 0050d8d6 mov ecx, [ecx] 0050d8da mov eax, 1 ; OK_TS 0050d8df je 0x50d90d ; null gfxobj -> return OK 0050d8e1 mov edx, [ecx+0x78] ; gfxobj->physics_bsp 0050d8e6 je 0x50d90d ; null bsp -> return OK 0050d8f8 call 0x50c9d0 ; cache localspace sphere 0050d907 call 0x534700 ; CGfxObj::find_obj_collisions ``` No CylSphere test, no Sphere test. And there cannot be one: the four accessors are one-liners that dereference `CPartArray::setup` at `+0x54`: ``` 00518060 mov eax,[ecx+0x54] ; mov eax,[eax+0x50] ; setup->num_sphere 00518070 mov eax,[ecx+0x54] ; mov eax,[eax+0x54] ; setup->sphere 00518080 mov eax,[ecx+0x54] ; mov eax,[eax+0x48] ; setup->num_cylsphere 00518090 mov eax,[ecx+0x54] ; mov eax,[eax+0x4c] ; setup->cylsphere ``` Those offsets reconcile exactly with `acclient.h`'s `CSetup` (`num_cylsphere` 0x48, `cylsphere` 0x4c, `num_sphere` 0x50, `sphere` 0x54, `height` 0x60, `radius` 0x64 — and `GetHeight`/`GetRadius` read `[eax+0x60]` / `[eax+0x64]`). **CylSpheres and Spheres are Setup-level arrays. Parts have none.** `CPartArray::FindObjCollisions` @`0x518180` is a bare `for i in 0..num_parts: if (parts[i]) CPhysicsPart::find_obj_collisions(...)` loop with an early exit on `!= 1`. ### 2.3 Cell membership dispatches on the SAME flag, and also exclusively This is the part the register row does not have, and it is where the fix actually bites. `CPhysicsObj::calc_cross_cells` @ `0x00515230`: ``` 00515285 f786a800000000000100 test dword ptr [esi+0xa8], 0x10000 0051528f 7574 jne 0x515305 ; BSP-bearing 00515291 8b4e10 mov ecx,[esi+0x10] 00515296 7444 je 0x5152dc ; null part array -> sorting sphere 00515298 e8e32d0000 call 0x518080 ; GetNumCylsphere 0051529f 743b je 0x5152dc ; zero -> sorting sphere 005152d1 e81a670100 call 0x52b9f0 ; CObjCell::find_cell_list (cylsphere array) 005152da eb35 jmp 0x515311 005152dc ... call 0x518b00 ; CPartArray::GetSortingSphere 005152fb e890660100 call 0x52b990 ; CObjCell::find_cell_list (sorting sphere) 00515305 8bce / e8afbcffff call 0x510fc0 ; CPhysicsObj::find_bbox_cell_list ``` **Retail's flood priority is BSP-bbox → cylspheres → sorting sphere.** A door with both a CylSphere and a physics BSP floods from the BSP bounding box; its CylSphere is never consulted for membership either. acdream's `BuildFloodSpheres` (`ShadowObjectRegistry.cs:606-635`) does the opposite: `if (anyCyl) use only the Cylinders`, else use every shape's centre and radius, capped at 10. So today, for the 73 CylSphere+BSP Setups, acdream floods from the cylinders where retail floods from the BSP bbox. Removing the cylinders from the shape list flips that to "all BSP shapes' bounding spheres" — still an approximation of a bbox, but the right geometry. ### 2.4 The dispatch flag is CLIENT-derived, and cached exactly once `CPartArray::CacheHasPhysicsBSP` @ `0x00518110` walks `num_parts` /`parts`, reads `part->gfxobj[0]->physics_bsp` (`[esi+0x78]`), and on the first non-null does `or [ecx], 0x10000` on `CPartArray::pa_state` (offset 0, per `acclient.h`), returning 1; otherwise `and [ecx], 0xfffeffff`, returning 0. `CPhysicsObj::CacheHasPhysicsBSP` @ `0x0050f570` mirrors the result onto `CPhysicsObj::state` at `+0xa8`. **A full `.text` scan for direct `call`/`jmp` to `0x0050f570` finds exactly one caller: `CPhysicsObj::InitPartArrayObject+0x7e` (`0x0051272e`).** The flag is computed once at part-array construction and never recomputed — notably not after a part swap. The per-part guard in §2.2 stays live, so retail's *per-part* test tracks a swapped GfxObj while its *dispatch flag* does not. See trap 7. --- ## 3. The divergence at HEAD, by symbol All line numbers read at `0d62a5ff` this session, not inherited. ### 3.1 The live path — additive `src/AcDream.Core/Physics/ShadowShapeBuilder.cs`, `FromSetup`: | Step | Lines | Gate | |---|---|---| | 1 — CylSpheres → `Cylinder` shapes | `:85-97` | `cyl.Radius > 0f` only | | 2 — Spheres → `Sphere` shapes | `:103-117` | `setup.CylSpheres.Count == 0` | | 3 — per-part physics BSP → `BSP` shapes | `:123-155` | `hasPhysicsBsp(effectiveId)` per part, **unconditional w.r.t. steps 1/2** | Step 2's gate is correct (it mirrors `0x50f1b0`). Step 3 has no gate against steps 1/2, and steps 1/2 have no gate against step 3. That is the whole divergence. `FromSetup` has exactly **one** production caller: `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:126`. Its `Build` substitutes the real BSP bounding radius (`:135-139`) and returns null on an empty list (`:146`). Two entry points reach it: `DatLiveEntityProjectionMaterializer.cs:832` (spawn) and `LiveEntityAppearanceBinding.cs:118` → `ReconcileAppearance` (ObjDesc swap). The class doc at `ShadowShapeBuilder.cs:38-45` **already carries a `KNOWN DIVERGENCE (AP-152)` paragraph** — the AP-22 commit added it while correcting the false retail anchor. Nothing in the class doc needs re-deriving; it needs deleting when the divergence goes. ### 3.2 The static paths — exclusive, but not by the same mechanism | Path | File | Exclusivity gate | BSP source | |---|---|---|---| | streaming statics (graphical) | `LandblockPhysicsPublisher.cs` | `if (setup is not null && entityBspCount == 0)` at `:984` | `FromLandblockBspParts(entity.MeshRefs, …)` at `:951` | | prepared-content statics (headless) | `LandblockPhysicsContentBuilder.cs` | `bspOwners++; continue;` at `:631-632` | `FromLandblockBspParts(entity.MeshRefs, …)` at `:612` | Both are genuinely exclusive, so the row's claim holds. But the row's framing ("the same object registers a different shape set depending on how it arrived") stays true **even after this fix**, for two reasons it does not mention: 1. The static paths derive "has BSP" from **`entity.MeshRefs`**, the render mesh pipeline's per-part list; the live path derives it from **`setup.Parts` + the effective post-`AnimPartChanged` identities**. Those are different sources and can disagree. 2. The static paths emit a Setup **Sphere as a `Cylinder`** (`LandblockPhysicsPublisher.cs:1030-1037`, `LandblockPhysicsContentBuilder.cs:683-690`: radius = sphere radius, `CylHeight = radius * 2f`, origin shifted down by one radius). The live path emits a true `ShadowCollisionType.Sphere`. `ShadowCollisionType.Sphere` is produced at exactly one site in `src/` — `ShadowShapeBuilder.cs:113`. Retail tests a Setup Sphere with `CSphere::intersects_sphere` @`0x537fd0` in both cases. This is an unregistered divergence; see §12.3. ### 3.3 The query-time gate that makes the divergence inert today `src/AcDream.Core/Physics/TransitionTypes.cs`: ``` :1348 public static bool BspOnlyDispatch(uint entityState) :1349 => (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0; :3911 Sphere branch: if (BspOnlyDispatch(obj.State)) { …[sph-skip-bsp]…; continue; } :3954 Cylinder branch: if (BspOnlyDispatch(obj.State)) { …[cyl-skip-bsp]…; continue; } ``` `obj.State` is `ShadowEntry.State`, written by `ShadowObjectRegistry.RegisterMultiPart(…, state, …)` (`:486`), which for the live path is `(uint)exactRecord.FinalPhysicsState` (`LiveEntityCollisionBuilder.cs:161`) — the canonical **wire** PhysicsState from `CreateObject`/`SetState`. **acdream never ORs `HasPhysicsBsp` in client-side**: a repo-wide grep for `PhysicsStateFlags.HasPhysicsBsp` in `src/` returns exactly two hits, `TransitionTypes.cs:1349` (this predicate) and `PhysicsEngine.cs:1613` (the **mover's** state selecting a shadow-commit action — unrelated). ACE supplies the bit: `references/ACE/Source/ACE.Server/WorldObjects/ WorldObject_Networking.cs:665-668` — ```csharp ////HasPhysicsBSP = 0x00010000, if (CSetup.HasPhysicsBSP) physicsState |= PhysicsState.HasPhysicsBSP; else physicsState &= ~PhysicsState.HasPhysicsBSP; ``` `CSetup.HasPhysicsBSP` is `SetupFlags.HasPhysicsBSP` (0x8) straight from the DAT (`ACE.DatLoader/FileTypes/SetupModel.cs:53`). It **overrides** the weenie's authored `PhysicsState`, which is why the 2018 weenie dump showing `PhysicsState = 0x8` for the cottage door (wcid 412) does not contradict our own live capture of `0x10008` (`DoorBugTrajectoryReplayTests.cs:997`, `DoorClosedState`). And §4.2 shows `SetupFlags.HasPhysicsBSP` agrees with the derived per-part predicate on 5,935 / 5,935 Setups. **Therefore every one of the 172 affected Setups arrives from ACE with 0x10000 set, and its extra primitive is skipped at query time.** Landblock statics register `state: 0u`, so the guard never fires for them — but their build paths are already exclusive, so there is nothing to skip. `BuildFloodSpheres` has no such guard. That is the gap. --- ## 4. The 172 figure — independently re-derived ### 4.1 Method A throwaway .NET 10 console project written this session **outside the repo** (`/Ap152Sweep/`), referencing `Chorizite.DatReaderWriter 2.1.7` — the same package `AcDream.Core` consumes — and enumerating the user's installed `%USERPROFILE%\Documents\Asheron's Call\client_portal.dat` via `DatCollection.GetAllIdsOfType()` / `()`. It reproduces `ShadowShapeBuilder.FromSetup`'s three steps literally (including the `Radius > 0f` filters and step 2's `CylSpheres.Count == 0` gate). Built clean from an empty `obj/`; no repo assembly is involved, so no stale-DLL risk. Not a raw byte parser: I deliberately used the production decoder rather than re-deriving AP-22's hand-rolled layout, so this is a genuinely different instrument from the four that produced AP-22's number. ### 4.2 Three decoders, one answer | Predicate for "this part has a physics BSP" | Affected Setups | |---|---| | acdream production (`Flags.HasPhysics && PhysicsBSP.Root != null && VertexArray != null`, per `FlatCollisionAssetBuilder.cs:377-380`) | **172** | | retail (`gfxobj->physics_bsp != 0`, i.e. `PhysicsBSP.Root != null`) | **172** | | DAT-authored `SetupFlags.HasPhysicsBSP` (0x8) at Setup level | **530 Setups carry it; 0 disagreements with the derived predicate across all 5,935** | GfxObj side: 15,318 GfxObjs; 1,258 carry `Flags.HasPhysics`; of those, **0** have a null `PhysicsBSP.Root` and **0** have a null `VertexArray` — which is why acdream's stricter predicate and retail's coincide exactly. `SetupFlags.HasPhysicsBSP = 0x8` was confirmed against the binary, not taken from the enum name: `CSetup::UnPack` @`0x00520c50` reads the flags dword and does `movzx edx, bl; shr edx, 3; and edx, 1; mov [edi+0x28], edx`, where `edi` is the `PackObj` sub-object at `CSetup+0x30`, so `[edi+0x28]` is `CSetup+0x58` = `has_physics_bsp` in `acclient.h`. (`[edi+0x2c]` = `+0x5c` = `allow_free_heading` = bit 2.) ### 4.3 Full distribution (5,935 Setups) | Bucket | Count | |---|---| | step 1/2 emits ≥1 primitive | 4,283 | | step 3 emits ≥1 BSP part shape | 530 | | **both — AFFECTED** | **172** (73 CylSphere+BSP, 99 Sphere+BSP) | | BSP only | 358 | | primitive only | 4,111 | | neither (no registration at all) | 1,294 | | ≥1 CylSphere (any radius) | 678 — none with all radii ≤ 0 | | 0 CylSpheres, ≥1 Sphere | 3,605 — none with all radii ≤ 0 | These reconcile with the corrected AP-22 record: 1,294 + 358 = **1,652 no-primitive Setups**, the figure the AP-22 architecture review substituted for the row's original 1,294. Spot-check against production tooling: `dotnet run --project tools/SetupInspect -- 0x020019FF` reports 1 Sphere `radius=0.1`, origin `(0,0,0.018)`, 3 parts, part[0] `0x010044B5` `flags=0x0000000B physicsBsp=present`, parts[1..2] `0x010044B6` `physicsBsp=none`. The sweep says the same: `sph=1 bsp=1 parts=3 primRadius 0.1000`. ### 4.4 What the 172 actually are Cross-referenced against `references/weenies(single file).json` (the 2018 ACE weenie dump) by `didStats key 1 = Setup`: - **98 of 172** are referenced by ≥1 ACE weenie. This is a **lower bound**, not a partition — Setup `0x020019FF` is referenced by **zero** weenies in that dump, yet our own live capture at `DoorBugTrajectoryReplayTests.cs:997` records `live: spawn … name=Door setup=0x020019FF`. Treat the 2018 dump as a characteriser, never as a reachability proof. - **22 Setups are used by 151 `Door` weenies**, including `0x0200027C` ("Witshire's Cottage Door", 21 weenies), the sliding-door family `0x02000310`–`0x02000313`, `0x020005DA` / `0x020005F1` / `0x020005F2`, `0x020009A9` ("Lyceum Gates", "Vault Door"), `0x020011C5` ("Armory Door"), `0x020010A8` ("Watcher's Wall"). Their primitive radii run 0.10 m – 1.31 m, median 0.40 m. - **38 Setups are used by `Creature`-typed weenies — and every one is a stationary prop**: Garbage Barrel, Magically Sealed Dais, Menhir, Sarcophagus, Mosswart Enchantment Idol, Abyssal Totem, Colosseum Arena, Boulder, Altar of the Black Crystal, Ancient Throne, Wall of Ice, Security Station, Rynthid Assessment Crystal, and a large set of Doors/Walls/Barriers. **No mobile monster and no humanoid is in the affected set.** The human Setup `0x02000001` is not affected — all 34 parts are `flags=0x0A`, no physics BSP — so player/creature body collision is untouched. The Facility Hub door `0x02000C9D` is also not affected (BSP parts, no primitives). - Remaining types: Generic 30 Setups, Hooker 7, Container 5, Chest 4, Switch 3, Portal 3, PressurePlate 2, HotSpot 2, Book 2, and one each of Gem, CraftTool, Caster, PKModifier, LScoreKeeper, GScoreGatherer. - Largest primitives: `0x02001741` CylSphere **r = 6.714 m** (h 1.476 m, one BSP part — a 10 × 10 m flat plate); `0x0200086E` Sphere **r = 5.842 m** at origin `(0.759, 0.165, 5.842)`, 7 parts. Neither appears in the weenie dump. Among weenie-referenced Setups the max is 2.000 m (`0x02001761`, "Boulder"). - Total shapes per affected Setup never exceeds 10, so the fix cannot interact with `BuildFloodSpheres`'s `RetailSphereCap = 10`. --- ## 5. The exact change, by symbol ### 5.1 The one production edit `src/AcDream.Core/Physics/ShadowShapeBuilder.cs`, `FromSetup`. Insert a pre-pass before step 1 that answers "will step 3 emit anything?", and skip steps 1 and 2 when it will. Shape: ``` bool anyPhysicsBspPart = false; for (int i = 0; i < setup.Parts.Count; i++) { uint gfxId = ; if (hasPhysicsBsp(gfxId)) { anyPhysicsBspPart = true; break; } } if (!anyPhysicsBspPart) { ; ; } ``` Three properties this shape has that alternatives do not: - **BSP wins**, matching `0x0050f165`/`0x0050f16f` (§2.1). Getting this backwards would delete a door's slab collision and leave a 10 cm sphere. - **Emission order is preserved** (primitives, then BSP). Reordering to "return early with the BSP list" would change `shapes[0]`/`shapes[1]` indices that `Issue175HubDoorPoseInspectionTests` relies on, and would change the order `RegisterMultiPart` writes `ShadowEntry` rows into cells. - **The pre-pass uses the identical effective-id expression as step 3.** If it used `setup.Parts[i]` while step 3 uses `effectivePartGfxObjIds[i]`, an ObjDesc swap could make the gate and the emission disagree — the gate would suppress the primitives while step 3 emitted nothing, producing a shapeless registration and `Build` returning null. That is trap 1. - Zero allocation; `FromSetup` is a build-time path, not a per-resolve one, but the pre-pass adds nothing either way. No signature change. No caller change. `LiveEntityCollisionBuilder.Build`'s `shapes.Count == 0 && !retainEmptyPayload → return null` at `:146` already handles every new case. ### 5.2 Comment / doc edits in the same commit - `ShadowShapeBuilder.cs:38-45` — delete the `KNOWN DIVERGENCE (AP-152)` paragraph; replace with one sentence recording that the emission is exclusive and why (BSP first, `0x0050f165` / `0x0050f16f` / `0x0050f19d`). - `ShadowShapeBuilder.cs:17-21` — the summary still says "Walks (1) … (2) … and (3) …", which reads as a union. Rewrite as the dispatch. - `ShadowShapeBuilder.cs:99-100` and `LandblockPhysicsContentBuilder.cs:695` both point at "`GameWindow.cs:6034`" for the landblock-static convention. `GameWindow.cs` is 1,622 lines. Dead citation; drop or repoint. - `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs:51-55` carries a second false retail anchor: *"the outer loop in `CPartArray::FindObjCollisions` iterates all parts regardless of CylSpheres/Spheres. `ShadowShapeBuilder.FromSetup` mirrors this by emitting one BSP shape per part"*. The first clause is true; the second uses it to justify the union. Correct it, for the same reason AP-22 corrected the one in `ShadowShapeBuilder.cs`: a future reader re-derives the additive design from it otherwise. ### 5.3 Do NOT touch - `Transition.BspOnlyDispatch` and its two call sites. After the fix it is redundant for live entities and inert for statics (`State == 0`), but retail genuinely dispatches at query time (§2.1) and the guard is the faithful modelling of that. Deleting it would also silently re-open the divergence for any future producer that builds an additive list. Leave it; note in the commit message that it is now belt-and-braces. - `BuildFloodSpheres` (`ShadowObjectRegistry.cs:606`). Its cylinder-preference is a *separate* divergence from `calc_cross_cells` (§2.3, §12.4). This fix changes its **input**, deliberately; changing its **logic** in the same commit would make the flood delta impossible to attribute. - Both static publishers. They are already exclusive. If a static test turns red, the diff is wrong — do not adjust the test. - `LiveEntityMotionRuntimeController.GetSetupCylinder` and every other `Setup.Radius`/`Height` consumer. AP-22 settled those. --- ## 6. Blast radius across both hosts ``` AcDream.App -> AcDream.Runtime, AcDream.Content, AcDream.Core AcDream.Headless -> AcDream.Runtime, AcDream.Content (never AcDream.App) ``` | Path | Producer | Graphical | Headless | |---|---|---|---| | live server weenies | `LiveEntityCollisionBuilder.Build` → `ShadowShapeBuilder.FromSetup` | yes | **no — see below** | | landblock statics, streaming | `LandblockPhysicsPublisher.PublishStaticEntity` | yes | no | | landblock statics, prepared content | `LandblockPhysicsContentBuilder.PublishStaticCollision` | no | yes | **#330 is not merely adjacent; it bounds this fix's reach.** Issue #330 (`docs/ISSUES.md:172`) states the headless host registers no live-entity collision at all. I re-verified its two premises at `0d62a5ff` rather than inheriting them: - `ShadowShapeBuilder.FromSetup` has exactly one production caller and it is in `AcDream.App` (`LiveEntityCollisionBuilder.cs:126`); a repo-wide grep returns no other `src/` hit. - The `LiveEntityCollisionBuilder` type is referenced only from `AcDream.App` (`Composition/ContentEffectsAudioComposition.cs`, `Rendering/DatLiveEntityProjectionMaterializer.cs`, `Rendering/LiveEntityAppearanceBinding.cs`, `Rendering/GameWindow.cs`, `Physics/LiveEntityPvpBitfieldSync.cs`) plus two doc-comment mentions in `AcDream.Runtime` and `AcDream.Content`. **Consequence for this fix: it is graphical-only.** Unlike AP-22 — which had three copies, one of them headless-only — AP-152 has exactly one production site and headless cannot execute it. The C5b lesson still applies in the opposite direction: run the headless suite and a headless connected route to prove the change did **not** reach it, rather than assuming it didn't. Do not "fix" #330 here. Do not let this fix's scope creep into giving Runtime ownership of live shape construction. `RuntimeRemotePhysicsUpdater` re-publishes an already-built shape list at a resolved pose; it constructs no shapes and is unaffected. --- ## 7. Proof obligations | # | Obligation | Evidence | |---|---|---| | P1 | Retail's collision dispatch is exclusive and BSP-first | §2.1 disassembly reproduced in the commit message: `0x0050f165` / `0x0050f16f` / `0x0050f19d` / `0x0050f1d6` / `0x0050f22f` | | P2 | The affected population is 172 Setups | §7.1's installed-DAT test, run, with its bucket controls | | P3 | The extra primitive was already inert for collision, via `BspOnlyDispatch` + ACE's `CSetup.HasPhysicsBSP` | §3.3, plus the §8.2 connected diff showing zero collision-response change | | P4 | Cell membership changed only for affected owners, and only in the retail-correct direction | §8.2's flood-set diff, keyed by owner | | P5 | The gate is computed from the same effective identities step 3 uses | §7.3's fact, sabotage-verified | | P6 | The change did not reach headless | `AcDream.Headless.Tests` green **and** a headless connected route with identical static publication counts (§8.3) | | P7 | `FromSetup_DoorSetup_ProducesFourShapes` is corrected, not deleted, and the second test that pins the union is found too | §7.2 | | P8 | AP-152 is retired with the row's four false/stale claims explicitly corrected | register diff in the same commit; §11.1–11.4 | --- ## 8. Gates ### 8.1 Suites Clean build first — three stale-DLL incidents this session, one under `-t:Rebuild`. Delete `bin/` and `obj/` for the touched projects, then `dotnet build -c Release`. `AcDream.Core.Tests` (owns `ShadowShapeBuilder`), `AcDream.App.Tests`, `AcDream.Content.Tests`, `AcDream.Runtime.Tests`, **`AcDream.Headless.Tests`**, then the complete Release solution suite. Baseline recorded at the C5c closeout (`1304dafa`) is **11,196 passed / 4 skipped / 0 failed**; re-measure at `0d62a5ff` before the change so the delta is attributable, and expect it to move only by the facts §7 adds. ### 8.2 Connected graphical route — positive evidence Absence of a crash proves nothing here; the whole point of §3.3 is that the obvious symptom was already suppressed. The criterion is a **keyed diff**, run twice on the same binary, once at `0d62a5ff` and once with the change, over the canonical nine-stop route (`tools/connected-world-lifecycle.route.txt` or `tools/connected-dense-town.route.txt`) with `ACDREAM_PROBE_BUILDING=1`: 1. **Shape inventory must change only for affected owners.** The `[entity-source]` line (`LiveEntityCollisionBuilder.cs:200`) prints `shapes=cyl{n}+bsp{m}` per registration. Every `(entityId, src, cyl, bsp)` tuple whose `src` is **not** in the 172 must be byte-identical between runs; every tuple whose `src` **is** in the 172 must go from `cyl>0 && bsp>0` to `cyl0+bsp{m}` with the same `m`. Note the probe labels a `Sphere` shape as `bsp` (its `else` branch at `:197-198` counts everything non-Cylinder as BSP) — so for the 99 Sphere+BSP Setups the counter will show `cyl0+bspN` before **and** `cyl0+bsp(N-1)` after. Verify the count drop, not the label. 2. **`[cyl-skip-bsp]` / `[sph-skip-bsp]` must go to zero.** Before the change these fire for exactly the affected live entities and are the direct observation that the query-time gate was carrying the divergence. After, no such shape exists to skip. A non-zero count after the fix means an affected Setup slipped the gate — investigate, do not adjust. 3. **Flood-set diff, the load-bearing one.** For each affected owner, the set of shadow cells `RegisterMultiPart` produces (`_entityToCells`) will change for the 73 CylSphere+BSP Setups (flood source flips from cylinders to BSP bounding spheres) and may shrink by one contributing sphere for the 99 Sphere+BSP ones. This is the only real behavioural delta and it must be *measured*, not assumed. The registry has no per-owner cell probe today; the cheapest honest instrument is a temporary probe line at `ShadowObjectRegistry.cs:498` printing `owner, cellSet.Count, sorted cellIds` under `ProbeBuildingEnabled`, run both routes, diff, then **delete the probe before committing**. Acceptance: no owner outside the 172 changes its cell set; every affected owner's new set is a superset-or-subset explainable by the geometry swap; no affected owner ends with an **empty** set (that would silently drop its collision — `RegisterMultiPart` returns early at `:465`). 4. **Collision response unchanged.** Walk each of the affected door families reachable on the route, from at least two approach headings and two source cells. Blocking distance and slide direction must be indistinguishable — which §3.3 predicts, because the primitive was already skipped. ### 8.3 Connected headless route Native Linux or WSL single-session run, four-stop portal route (the K1/K3 gate). Positive criterion: per-landblock `bspOwners`/`setupOwners`/ `noCollision` counts from `PublishStaticCollision` **identical** to a pre-change run, graceful ACE-confirmed logout, terminal ownership ledger at zero. This is a *negative-reach* proof (§6) and it needs the counts, not just a clean exit. ### 8.4 User visual gate — narrow, and not about doorway feel Worth eyes, but a full matrix is not. Batch into a connected session and ask the user to look at exactly one thing: **stand next to, walk around, and walk through the doorway of two or three of the affected props from different landcells** — a cottage door (`0x0200027C`), a large one (`0x020010A8` "Watcher's Wall" — 3 CylSpheres up to 0.77 m plus 4 BSP shapes, the biggest live shape-list change on the list), and one big free-standing prop (`0x02001761` "Boulder", 2.00 m sphere + 1 BSP). The symptom to watch for is a **cell-membership** failure, not a feel change: the prop stops blocking when you approach it from one particular direction or from the neighbouring cell, while still blocking from another. That is the `#98`/`#168` signature and it is the only way this commit can break something. Do **not** ask for a general "does the door feel right" pass. §3.3 predicts no feel change, and asking for one spends the session's visual budget on a question the disassembly already answered. --- ## 9. Test plan Design rule, unchanged from AP-22 and for the same reason: **state the sabotage that must redden each fact, and run it.** This campaign has shipped or caught **seven** green tests covering nothing; §11.5 makes it eight. Assume each fact below discriminates nothing until its sabotage proves otherwise. ### 9.1 CORRECT — `ShadowShapeBuilderTests.FromSetup_DoorSetup_ProducesFourShapes` `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs:50-72`. Its fixture `CreateDoorSetup()` is the real `0x020019FF` — 1 Sphere r=0.100 at `(0,0,0.018)`, 3 parts — and `hasBsp` returns true for both part ids, so it asserts `1 Sphere + 3 BSP = 4`. It must become `FromSetup_DoorSetup_EmitsBspPartsOnly`: **3 shapes, all `ShadowCollisionType.BSP`, zero Sphere shapes**, with a comment naming the retail anchor (`0x0050f165` test / `0x0050f16f je` / `0x0050f19d jmp`). Keep the fixture — it is real DAT data and it is the exact case the register row names. **Sabotage:** restore the additive emission (delete the pre-pass gate). This fact must redden. This is the one sabotage that directly re-proves the production change, so it must be run. ### 9.2 CORRECT — the SECOND test the register row does not mention `ShadowShapeBuilderTests.FromSetup_DoorSetup_SphereAtExpectedLocalOffset` (`:74-90`) calls `FromSetup(setup, 1.0f, _ => true)` — every part BSP-bearing — and then asserts a `Sphere`-typed shape exists at `(0,0,0.018)` with r=0.100 and `CylHeight == 0`. **Under the exclusive rule it returns no Sphere at all and this test fails.** The AP-152 row names only `FromSetup_DoorSetup_ProducesFourShapes`; this is the same "named one site where two existed" failure AP-22's row had. Do not delete it — its content (Setup Spheres emit true `Sphere`, not a height-capped Cylinder; local offset and radius pass through) is live and is the premise of the whole `CSphere` family port (`TransitionTypes.cs:4264`, `SphereCollisionFamilyTests`). Re-host it on `hasPhysicsBsp: _ => false`, which is the DAT-real configuration for the 3,605 Sphere-only Setups. **Sabotage:** change `ShadowShapeBuilder.cs:113` from `ShadowCollisionType.Sphere` to `Cylinder`. Must redden. If it does not, the re-host lost the coverage. ### 9.3 NEW — the effective-identity gate fact Same class. A Setup with one CylSphere **and** one part, where `hasPhysicsBsp` is true only for a *replacement* id supplied through `effectivePartGfxObjIds`: - with the replacement supplied → **BSP only**, no Cylinder; - with `effectivePartGfxObjIds` null (so the gate sees the base id, for which `hasPhysicsBsp` is false) → **Cylinder only**, no BSP. This is the only fact that discriminates "the gate reads the same identities step 3 does" from "the gate reads `setup.Parts`". **Sabotage:** change the pre-pass to read `(uint)setup.Parts[i]` instead of the effective id. The first case must redden (it would emit Cylinder + BSP), and watch that the second stays green. ### 9.4 NEW — App-layer registration fact `tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs`. **No existing App test combines a primitive and a BSP part** — I checked all fourteen facts in that file; every fixture is primitive-only or BSP-only. So today nothing at the App layer would catch this change either way. Add: a Setup with one CylSphere (r 0.4, h 1.2) *and* one part whose `hasPhysicsBsp` is true, built through `LiveEntityCollisionBuilder.Build` with `scale: 1.5f`, registers **exactly one shape**, `CollisionType == BSP`, `Radius == physicsBspRadius * scale` — i.e. the BSP radius substitution at `:135-139` still applies and no Cylinder survives. **Sabotage:** restore the additive emission. Must redden on `Assert.Single`. ### 9.5 NEW — installed-DAT population fact Where the AP-22 commit put `InstalledSetupCollisionReachabilityTests` (`tests/AcDream.Content.Tests/`), beside it, same `ACDREAM_DAT_DIR` skip convention. Enumerate every Setup, apply `FromSetup`'s own three steps through the production decoder, and assert: - **(a)** exactly **172** Setups emit ≥1 primitive *and* ≥1 BSP part shape; 73 of them CylSphere-bearing, 99 Sphere-bearing. - **(b) positive controls, so (a) cannot pass vacuously:** 5,935 Setups enumerated; 4,283 emit ≥1 primitive; 530 emit ≥1 BSP part shape; 358 BSP-only; 1,294 emit nothing. - **(c)** the DAT-authored `SetupFlags.HasPhysicsBSP` agrees with the derived per-part predicate on all 5,935 — **0** disagreements. This is the fact that underwrites §3.3's "ACE always sends the bit", and it is the one that would silently rot if a future DAT patch changed the relationship. (b) is not decoration; it is why (a) discriminates. A broken enumerator, a wrong dat path, or a silently-empty flatten all satisfy (a) trivially. This is exactly the failure the atlas-tier seam commit was written to close, and exactly the failure the AP-22 architecture review found in the *other* direction (a coverage claim that sabotage disproved). **Sabotage (run both):** invert (a) to expect 0 — must fail on (a). Point the enumeration at an empty id set — must fail on **(b)** at `0 != 5935`, *not* pass (a). **Trap:** the numbers in (b) and (c) are external constants measured in §4.3. Write them as literals. Deriving them from the same predicate the assertion uses is a tautology. ### 9.6 VERIFY, do not edit Predicted green; a red one is *information about the divergence*, not a test to adjust. If any of these reddens, stop and read the diff — it means the shape this contract says was inert was in fact carrying behaviour. | Test | Why it survives | |---|---| | `ShadowShapeBuilderTests.FromSetup_PartWithoutBsp_SkipsBspShape` (`:92`) | counts BSP shapes only; still 1 | | `…FromSetup_EffectivePartIdentitiesControlPhysicsBspSelection` (`:106`) | no primitives in fixture | | `…FromSetup_CreatureWithCylSpheres_OnlyEmitsCylinders` (`:126`) | `_ => false`, no BSP | | `…FromSetup_EmptySetup_ReturnsEmptyList`, `…NullSetup_Throws` | unchanged | | all four `ShadowShapeBuilderShapeSourceTests` | every fixture is primitive-only or BSP-only | | `Issue175HubDoorPoseInspectionTests` ×3 `FromSetup` facts | `MakeTwoPartSetup()` has no primitives; and the DAT Setup they load, `0x02000C9D`, is **not** in the affected 172 | | `DoorCollisionApparatusTests` (`:404`, real Setup `0x020019FF`) | its blocking assertions test the door **slab BSP**; the 10 cm sphere was already skipped at query time by `BspOnlyDispatch` — but this suite registers with `state = 0x10008`, so verify the state literal is still what makes that true | | `DoorBugTrajectoryReplayTests` (`:783`, `:895`, real `0x020019FF`) | same; `Assert.Contains(shapes, BSP)` still holds | | `LandblockPhysicsPublisherTests`, Content static tests | static paths untouched | | all 14 `LiveEntityCollisionBuilderTests` facts | every fixture is primitive-only or BSP-only | `DoorCollisionApparatusTests` and `DoorBugTrajectoryReplayTests` are the **discriminating behavioural evidence** for §3.3: they are the only tests in the tree that run a real affected Setup through a real resolve. Their staying green is the positive statement that the primitive was not doing the blocking. --- ## 10. Traps 1. **Deriving the gate from `setup.Parts` while step 3 derives from `effectivePartGfxObjIds`.** They can disagree after an ObjDesc swap, and the disagreement is silent: the gate suppresses the primitives, step 3 emits nothing, `Build` returns null, and the entity's collision disappears entirely. §9.3 is the only fact that catches it. 2. **Getting the priority backwards.** BSP wins. A door that kept its 10 cm sphere and lost its slab would be walk-through — and would still be green on any test that only counts shapes. 3. **Reordering the emission** by returning the BSP list early. Changes `shapes[0]`/`shapes[1]` indices in `Issue175HubDoorPoseInspectionTests` and the order `ShadowEntry` rows enter cells. 4. **Believing this changes door blocking.** It does not (§3.3). Gating on "walk into a door and see if it feels different" produces a green gate that proves nothing, and would have proved nothing before the change either. 5. **Missing the flood-set change.** It is the *only* behavioural delta, it is in a function with no `BspOnlyDispatch` guard, and nothing currently instruments it. §8.2 item 3 is the load-bearing gate. 6. **Deleting `BspOnlyDispatch` as "now redundant".** It is retail's actual dispatch site and the guard against a future additive producer. §5.3. 7. **Modelling retail's flag as live when it is cached once.** `CPhysicsObj::CacheHasPhysicsBSP` has exactly one caller, `InitPartArrayObject+0x7e`, so after an `AnimPartChanged` part swap retail's *dispatch flag* is stale while its *per-part* test is live (§2.4). acdream's gate will be live in both. The two disagree only when a swap adds or removes the last physics-BSP part; humanoid part swaps (clothing/armour) involve no physics-BSP GfxObjs on either side, so this is not reachable against ACE today. **Note it in the register; do not build state to model it.** 8. **Trusting the Binary Ninja text for branch polarity.** BN renders the dispatch as `if ((state & 0x10000) == 0 || ebp_1 != 0 || eax_12 != 0)` with the *primitive* path in the `then`, and its `ebp_1` aliasing in this function is visibly corrupt. Cite the disassembly. 9. **Treating the 2018 weenie dump as authoritative for reachability.** `0x020019FF` is in zero of its Setup didStats and is nevertheless a live ACE spawn in our own capture (§4.4). 10. **Assuming "not in `AcDream.App`" means "not affected".** It is true here (§6) — which is exactly why it must be *proved* with the headless suite and a headless route rather than asserted, per C5b. --- ## 11. Claims found false or stale at HEAD Numbered, as required. Each was checked against the binary, the DAT, or the source at `0d62a5ff` — none inherited. ### 11.1 The AP-152 row's risk statement is **FALSE as written** > "172 … register a collision primitive retail never tests … Symptom class: > catching or stopping on a doorway sill, or a small non-retail obstacle at a > BSP prop's base." acdream **does not test it either**. `Transition.BspOnlyDispatch(obj.State)` skips both primitive branches (`TransitionTypes.cs:3911`, `:3954`) whenever the target's `PhysicsState` carries 0x10000, and ACE sets that bit from `CSetup.HasPhysicsBSP` for every affected Setup (`WorldObject_Networking.cs:665-668`; authored-vs-derived agreement 5,935/5,935, §4.2). The stated symptom cannot be occurring on the live path against ACE. The row's own retail-anchor column even cites the flag it fails to notice acdream is already keying on — and the guard's source comment (`TransitionTypes.cs:3936-3949`) names the exact door and the exact bug ("stuck on door" phantom, `door-a6p6-v2.utf8.log`) that this row re-predicts as open. The true risk is **cell membership** (§2.3, §8.2 item 3), which is a different symptom class with a different gate. ### 11.2 The row's mitigation "the affected primitives are small and centred at the part origin" is **FALSE** Neither half. Sizes: `0x02001741` CylSphere r = **6.714 m**, `0x0200086E` Sphere r = **5.842 m**, `0x02000D85` r = 5.576 m; median across the 172 is 0.500 m and the weenie-reachable max is 2.000 m. Centring: `0x0200086E`'s sphere origin is `(0.759, 0.165, 5.842)`, nowhere near the part origin. ### 11.3 The row's "cottage door 0x020019FF, whose ~14 cm base Sphere" is **WRONG about the number** The Sphere radius is **0.100 m**, origin `(0, 0, 0.018)`. `0.141` is `Setup.Radius` — a *different field*, and the one AP-22 had just finished proving is never collision geometry. Confirmed twice: `tools/SetupInspect -- 0x020019FF` prints `Radius/Height = 0.141 / 0.200` and separately `sphere[0] … radius=0.1 radiusBits=0x3DCCCCCD`; the sweep agrees. The row conflated the two on the same line as its `AP-22` neighbour. "Sits at the threshold" is right — 1.8 cm above the part origin. Also worth pinning for the register: **`0x020019FF` has `DefaultMotionTable = 0x00000000`** and appears in zero weenies of the 2018 ACE dump, yet is a live ACE spawn in our own capture. Either statement alone would mislead. ### 11.4 The row names one pinning test where there are **two** — **INCOMPLETE** It names `FromSetup_DoorSetup_ProducesFourShapes`. It omits `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` (`ShadowShapeBuilderTests.cs:74`), which also fails under the exclusive rule (§9.2). This is structurally the same error AP-22's row made with its site list, one row earlier in the same file. ### 11.5 `FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets` is a **green test covering nothing** — new instance `ShadowShapeBuilderTests.cs:150-166`. It runs `CreateDoorSetup()` (0 CylSpheres) through `FromSetup` and then asserts radii/offsets **inside** `if (s.CollisionType == ShadowCollisionType.Cylinder)`. That branch has been unreachable since Setup Spheres started emitting `ShadowCollisionType.Sphere` (the 2026-06-24 change noted at `:60`). The only assertion that executes is `Scale == 2.0f`. The test's name promises radius and offset scaling and pins neither. Eighth instance of the pattern this campaign. Out of scope to fix here — note it, or fold it into §9.2's re-host if it is free. ### 11.6 The deeper divergence the row does not name: acdream takes a CLIENT-DERIVED flag from the WIRE Retail computes `HAS_PHYSICS_BSP_PS` itself, from its own part array, at `InitPartArrayObject` (§2.4). acdream reads it out of the server's `PhysicsState` (`LiveEntityCollisionBuilder.cs:161` → `ShadowEntry.State` → `BspOnlyDispatch`) and never derives it. It happens to be correct because ACE reads the same DAT bit — that is an undocumented dependency on a specific server implementation, in a subsystem whose whole premise is that the client decides. It is unregistered. §12.3 files it. This fix makes the *outcome* independent of the wire without touching the flag, which is why it is worth doing even though §11.1 shows the current symptom is inert. ### 11.7 The static paths are exclusive but **not equivalent** — the row's "internally inconsistent" is understated Even after AP-152 is fixed the live and static paths still disagree, in two ways the row does not mention (§3.2): the static paths emit a Setup Sphere as a **Cylinder** with `CylHeight = radius * 2` and a base-shifted origin, and they derive "has BSP" from `entity.MeshRefs` rather than from `setup.Parts`. `ShadowCollisionType.Sphere` is produced at exactly one site in the whole of `src/`. Unregistered; §12.4 files it. ### 11.8 `BuildFloodSpheres`'s cylinder preference contradicts `calc_cross_cells` — **unregistered** `ShadowObjectRegistry.cs:614-622` uses cylinders exclusively whenever any exist. Retail routes a BSP-bearing object to `find_bbox_cell_list` *before* looking at cylspheres (§2.3). For the 73 CylSphere+BSP Setups acdream floods from the wrong geometry today. This fix corrects it by accident (the cylinders stop existing); the general rule — BSP-bbox first — is still unimplemented, and acdream approximates a bbox with per-shape bounding spheres in any case. §12.4 files it. ### 11.9 AP-22's contract §5 said headless "registers no live-entity collision at all" — **still true, and now load-bearing here** Re-verified independently (§6). Recorded because this fix's blast radius *depends* on it: AP-152 is graphical-only precisely because #330 is open. Closing #330 later will retroactively widen this fix's reach to headless bots, which is a reason to land the exclusivity rule now rather than after. ### 11.10 The stale `GameWindow.cs:6034` citations `ShadowShapeBuilder.cs:19` and `:100`, and `LandblockPhysicsContentBuilder.cs:695`, cite a `GameWindow.cs` line number from before the eight-slice decomposition; the file is now 1,622 lines. Free to fix in this commit (§5.2). ### 11.11 The register row's "must be rewritten when this is fixed" is right, and the row itself must go AP-152's row states the test must be rewritten. Confirmed (§9.1). Adding it here so the retirement text carries the corrected two-test list rather than repeating the row's single-test claim. --- ## 12. Register edits ### 12.1 Retire AP-152 in the implementing commit The retirement text must carry, or the record is wrong twice: - the byte-level anchor with the priority order stated (`0x0050f165` test / `0x0050f16f je` → primitives; `0x0050f19d jmp` past both; `0x0050f1d6 jae` → return; `0x0050f22f je` → return; **BSP wins**); - the corrected cottage-door number (**0.100 m** Sphere, not ~14 cm — the `0.141` is `Setup.Radius`); - the corrected mitigation (primitives run to **6.714 m**, not "small", and are not all origin-centred); - **the correction that the collision symptom was already suppressed** by `BspOnlyDispatch` + ACE's `CSetup.HasPhysicsBSP`, so the retirement is not a collision-behaviour change but a shape-list and **cell-membership** change; - the two corrected tests, not one; - the 172 figure with its three-decoder derivation and the 5,935/5,935 authored-vs-derived agreement; - a pointer to this document. ### 12.2 File — retail's dispatch flag is cached once; acdream's gate is live Narrow, unreachable against ACE today (trap 7), but it is a real modelling difference introduced by putting the gate in `FromSetup`, and it must be recorded in the same commit that introduces it. One row, with `CPhysicsObj::CacheHasPhysicsBSP` 0x0050f570 / single caller `InitPartArrayObject+0x7e` 0x0051272e as the anchor. ### 12.3 File — `HAS_PHYSICS_BSP_PS` is taken from the wire, not derived §11.6. Anchor: `CPartArray::CacheHasPhysicsBSP` 0x00518110 (derives from the parts) vs `LiveEntityCollisionBuilder.cs:161` (copies `FinalPhysicsState`). Note ACE's `WorldObject_Networking.cs:665-668` as the reason it currently agrees, and that the agreement is not guaranteed by anything on our side. ### 12.4 File — static-vs-live shape-source divergences §11.7 and §11.8, as one row or two: (a) static paths emit Setup Spheres as height-capped Cylinders with a shifted origin where retail and the live path use `CSphere::intersects_sphere`; (b) `BuildFloodSpheres` prefers cylinders where `calc_cross_cells` @0x00515230 routes a BSP-bearing object to `find_bbox_cell_list` @0x00510fc0 first. (b) is partly retired by this fix for the 73 CylSphere+BSP Setups; the general rule is not. ### 12.5 Check before filing Confirm none of 12.2–12.4 is already covered by an existing row or by #291. I grepped the register for `LandblockPhysicsPublisher` / `LandblockPhysicsContentBuilder` and found only the retired AD-6 and the AP-152 and AP-22 rows themselves; no row covers the sphere-as-cylinder conversion or the flood-source rule. --- ## 13. Size and split **Size: small.** One production method gains a ~6-line pre-pass. Two tests corrected, three added (one of them an installed-DAT sweep, ~70 lines), four comments fixed, one register row retired, up to three filed. **One commit.** **Split call: land AP-152 alone.** Do not bundle: - **#330** (headless live-entity collision). Larger, overlaps Slice-J ownership, and bundling would destroy this commit's "graphical-only, proven by the headless route" evidence. - **The wire-vs-derived flag (§12.3).** Changing `registration.State` touches every consumer of `FinalPhysicsState` — Hidden, Missile, ethereal layer-2, the `[setstate]` log. Separate slice, separate gate. - **The static-path sphere-as-cylinder conversion (§12.4a).** It changes static collision geometry on a much larger population and needs its own count. - **`BuildFloodSpheres` → `find_bbox_cell_list` parity (§12.4b).** Changing the flood *logic* in the same commit that changes its *input* makes the §8.2 item-3 diff unattributable. That is the single strongest reason to keep this commit narrow. - **`FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets` (§11.5).** Free to fix if it falls out of §9.2; otherwise file it. --- ## Appendix — reproducing the measurements **Disassembly.** A from-scratch capstone script over `C:\Users\erikn\Downloads\acclient.exe` (PE32, image base `0x00400000`, RVA→file offset via the section table), plus a `.text` scan for `E8`/`E9` relative targets to build the xref lists in §2.4. Symbol resolution by exact address against `docs/research/named-retail/symbols.json` (18,366 entries). **DAT sweep.** A .NET 10 console project outside the repo referencing `Chorizite.DatReaderWriter 2.1.7`, enumerating via `DatCollection.GetAllIdsOfType()` / `()` and reproducing `FromSetup`'s three steps. Emits `ap152-affected.txt` (`setupId, cylShapes, sphShapes, bspShapes, parts, minPrimRadius, maxPrimRadius`) for the 172. Deliberately not committed; §9.5 is the committed form of the load-bearing assertion. **Characterisation.** A streaming regex pass over `references/weenies(single file).json` (168 MB, 2018 ACE dump) keying `didStats key 1 = Setup` to `wcid` / `Name` / `weenieType`, and `intStats key 93 = PhysicsState`. Of 459 weenies whose Setup is in the affected 172, 163 carry 0x10000 in the *authored* PhysicsState and 296 do not — which is why §3.3 rests on ACE's `WorldObject_Networking` override rather than on the authored value. Treat this dump as a characteriser only; see trap 9. **Setup DAT layout** is `Setup.generated.cs` in `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/`; the `SetupFlags` bit assignment was independently confirmed against `CSetup::UnPack` @`0x00520c50` (§4.2).