acdream/docs/research/2026-08-06-ap152-contract.md
Erik 4abd1b5eb7 fix(physics): AP-152 — dispatch collision shapes BSP-first, at emission and at the cell flood
The register row predicted "catching or stopping on a doorway sill". That
symptom could not have been occurring. `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 HAS_PHYSICS_BSP_PS, and ACE sets that bit from CSetup.HasPhysicsBSP
for every affected Setup. The extra primitive was never tested for collision.

The live defect was CELL MEMBERSHIP. The same shape list feeds
`ShadowObjectRegistry.BuildFloodSpheres`, which had no such guard and
preferred Cylinders over everything whenever any Cylinder existed — retail's
SECOND priority applied ahead of its first. For the 73 CylSphere+BSP Setups
acdream therefore flooded shadow cells from the cylinder and never from the
slab: an object absent from cells it physically occupies, which is the
#98 / #168 symptom class, not the door-collision class the row named.

Retail, re-disassembled from the PDB-paired binary (v11.4186, CodeView GUID
9e847e2f-777c-4bd9-886c-22256bb87f32, check_exe_pdb.py MATCH) rather than
taken from Binary Ninja, which drops flag tests:

  CPhysicsObj::FindObjCollisions @0x0050f050
    0x0050f165  test dword [esi+0xa8], 0x10000
    0x0050f16f  je   0x50f1a2        ; clear -> primitive dispatch
    0x0050f18d  call 0x518180        ; CPartArray::FindObjCollisions
    0x0050f19d  jmp  0x50f2b0        ; UNCONDITIONAL, past BOTH primitive loops
                                     ; (CylSphere 0x50f1a2, Sphere 0x50f21d)
    0x0050f1d6  jae  0x50f317        ; CylSphere loop exhausted -> RETURN
    0x0050f22f  je   0x50f31b        ; zero Spheres -> RETURN seeded OK_TS

  CPhysicsObj::calc_cross_cells @0x00515230
    0x00515285  test dword [esi+0xa8], 0x10000
    0x0051528f  jne  0x515305 -> CPhysicsObj::find_bbox_cell_list @0x00510fc0
    0x005152d1  call 0x52b9f0        ; cylsphere branch, below the jump
    0x005152fb  call 0x52b990        ; sorting-sphere branch, below the jump

Priority at both consumers: BSP -> CylSphere -> Sphere -> nothing. BSP wins.
Every address above was resolved back to its symbol by exact lookup in
named-retail/symbols.json.

Changes:

* `ShadowShapeBuilder.FromSetup` gains a step-0 dispatch gate. Steps 1 and 2
  are skipped entirely when any part's EFFECTIVE GfxObj carries a physics
  BSP. The gate and step 3 now share one `EffectivePartGfxObjId` helper, so
  they cannot read different identities — a gate on `setup.Parts` would,
  after an ObjDesc swap, suppress the primitives while step 3 emitted
  nothing and `Build` returned null, deleting the entity's collision.
  Emission order is unchanged. This also removes acdream's undeclared
  reliance on the server sending the flag: the gate is derived from the
  parts, exactly as CPartArray::CacheHasPhysicsBSP @0x00518110 derives it.

* `ShadowObjectRegistry.BuildFloodSpheres` now applies calc_cross_cells'
  own order: BSP, else Cylinder, else everything. Given the gate above this
  is a no-op for every shape list acdream produces (FromSetup is now
  exclusive; both landblock-static publishers already emit homogeneous
  lists), so the measured membership delta remains attributable to the
  gate alone. It is kept for the same reason BspOnlyDispatch is kept: retail
  genuinely dispatches here, and it guards a future additive producer.

`Transition.BspOnlyDispatch` is deliberately untouched.

Register: AP-152 RETIRED with its four false statements corrected — the risk
statement (the symptom was already inert); "small and centred at the part
origin" (max primitive is 6.714 m, and 0x0200086E's sphere origin is
(0.759, 0.165, 5.842)); the cottage door's "~14 cm base Sphere" (it is
0.100 m; 0.141 is Setup.Radius, which AP-22 proved is never collision
geometry); and naming one pinning test where two existed. AP-153/154/155
filed: retail's dispatch flag is cached once at InitPartArrayObject+0x7e
where acdream's gate is live; the query-time guard takes a client-derived
flag off the wire; and the static publishers emit Setup Spheres as
height-capped Cylinders while BuildFloodSpheres approximates retail's
bounding box with bounding spheres.

Tests. Both pinning tests corrected, neither deleted:
`FromSetup_DoorSetup_ProducesFourShapes` -> `..._EmitsBspPartsOnly`;
`FromSetup_DoorSetup_SphereAtExpectedLocalOffset` re-hosted on
`_ => false`, the DAT-real configuration for the 3,605 Sphere-only Setups.
`FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets` was the campaign's
eighth green test covering nothing — its assertions sat inside
`if (CollisionType == Cylinder)` on a fixture with zero CylSpheres, so only
`Scale == 2.0f` ever ran. Proved empirically: with the sphere radius scale
deleted, the old body passes and the corrected body fails. Three new facts:
the effective-identity gate, the App-layer CylSphere+BSP registration (no
App fixture combined the two before), and the flood-set dispatch. One new
installed-DAT sweep pins 172 affected Setups (73 CylSphere+BSP, 99
Sphere+BSP) behind external bucket controls, re-measured independently and
agreeing exactly with the filing commit's separate sweep.

All eight sabotages run and reported; every discriminating fact reddens in
the intended direction and only there. Clean Release build after deleting
every bin/obj: 0 errors. Complete suite 11,203 passed / 4 skipped / 0
failed, +5 on the 11,198 baseline at ec29a732 — exactly the five added
facts, no new skips.

Blast radius, corrected: the FromSetup half is graphical-only (its sole
production caller is LiveEntityCollisionBuilder in AcDream.App, which
AcDream.Headless cannot reference — Headless -> Runtime -> Core/Content).
The BuildFloodSpheres half lives in AcDream.Core and DOES execute in
Headless via LandblockPhysicsContentBuilder, but is behaviour-neutral there
because both of that builder's registrations pass homogeneous lists.
Headless suite green at 89/89.

NOT yet gated live: this changes shadow-cell membership for 22 Setups used
by 151 Door weenies and 38 stationary props. Needs a connected session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 14:51:36 +02:00

1057 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**
(`<scratchpad>/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<Setup>()` / `<GfxObj>()`. 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 = <the SAME effective-identity expression step 3 uses at :129-132>;
if (hasPhysicsBsp(gfxId)) { anyPhysicsBspPart = true; break; }
}
if (!anyPhysicsBspPart) { <step 1>; <step 2>; }
<step 3 unchanged>
```
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.111.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.212.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<Setup>()` / `<GfxObj>()` 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).