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

54 KiB
Raw Permalink Blame History

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:118ReconcileAppearance (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

////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 0x020003100x02000313, 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.BuildShadowShapeBuilder.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 twoINCOMPLETE

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:161ShadowEntry.StateBspOnlyDispatch) 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_cellsunregistered

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.
  • BuildFloodSpheresfind_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).