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>
54 KiB
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:
- The static paths derive "has BSP" from
entity.MeshRefs, the render mesh pipeline's per-part list; the live path derives it fromsetup.Parts+ the effective post-AnimPartChangedidentities. Those are different sources and can disagree. - 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 trueShadowCollisionType.Sphere.ShadowCollisionType.Sphereis produced at exactly one site insrc/—ShadowShapeBuilder.cs:113. Retail tests a Setup Sphere withCSphere::intersects_sphere@0x537fd0in 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
0x020019FFis referenced by zero weenies in that dump, yet our own live capture atDoorBugTrajectoryReplayTests.cs:997recordslive: spawn … name=Door setup=0x020019FF. Treat the 2018 dump as a characteriser, never as a reachability proof. - 22 Setups are used by 151
Doorweenies, including0x0200027C("Witshire's Cottage Door", 21 weenies), the sliding-door family0x02000310–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 Setup0x02000001is not affected — all 34 parts areflags=0x0A, no physics BSP — so player/creature body collision is untouched. The Facility Hub door0x02000C9Dis 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:
0x02001741CylSphere r = 6.714 m (h 1.476 m, one BSP part — a 10 × 10 m flat plate);0x0200086ESphere 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'sRetailSphereCap = 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 thatIssue175HubDoorPoseInspectionTestsrelies on, and would change the orderRegisterMultiPartwritesShadowEntryrows into cells. - The pre-pass uses the identical effective-id expression as step 3. If it
used
setup.Parts[i]while step 3 useseffectivePartGfxObjIds[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 andBuildreturning null. That is trap 1. - Zero allocation;
FromSetupis 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 theKNOWN 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-100andLandblockPhysicsContentBuilder.cs:695both point at "GameWindow.cs:6034" for the landblock-static convention.GameWindow.csis 1,622 lines. Dead citation; drop or repoint.tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs:51-55carries a second false retail anchor: "the outer loop inCPartArray::FindObjCollisionsiterates all parts regardless of CylSpheres/Spheres.ShadowShapeBuilder.FromSetupmirrors 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 inShadowShapeBuilder.cs: a future reader re-derives the additive design from it otherwise.
5.3 Do NOT touch
Transition.BspOnlyDispatchand 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 fromcalc_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.GetSetupCylinderand every otherSetup.Radius/Heightconsumer. 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.FromSetuphas exactly one production caller and it is inAcDream.App(LiveEntityCollisionBuilder.cs:126); a repo-wide grep returns no othersrc/hit.- The
LiveEntityCollisionBuildertype is referenced only fromAcDream.App(Composition/ContentEffectsAudioComposition.cs,Rendering/DatLiveEntityProjectionMaterializer.cs,Rendering/LiveEntityAppearanceBinding.cs,Rendering/GameWindow.cs,Physics/LiveEntityPvpBitfieldSync.cs) plus two doc-comment mentions inAcDream.RuntimeandAcDream.Content.
Consequence for this fix: it is graphical-only. Unlike AP-22 — which had three copies, one of them headless-only — AP-152 has exactly one production site and headless cannot execute it. The C5b lesson still applies in the opposite direction: run the headless suite and a headless connected route to prove the change did not reach it, rather than assuming it didn't.
Do not "fix" #330 here. Do not let this fix's scope creep into giving Runtime ownership of live shape construction.
RuntimeRemotePhysicsUpdater re-publishes an already-built shape list at a
resolved pose; it constructs no shapes and is unaffected.
7. Proof obligations
| # | Obligation | Evidence |
|---|---|---|
| P1 | Retail's collision dispatch is exclusive and BSP-first | §2.1 disassembly reproduced in the commit message: 0x0050f165 / 0x0050f16f / 0x0050f19d / 0x0050f1d6 / 0x0050f22f |
| P2 | The affected population is 172 Setups | §7.1's installed-DAT test, run, with its bucket controls |
| P3 | The extra primitive was already inert for collision, via BspOnlyDispatch + ACE's CSetup.HasPhysicsBSP |
§3.3, plus the §8.2 connected diff showing zero collision-response change |
| P4 | Cell membership changed only for affected owners, and only in the retail-correct direction | §8.2's flood-set diff, keyed by owner |
| P5 | The gate is computed from the same effective identities step 3 uses | §7.3's fact, sabotage-verified |
| P6 | The change did not reach headless | AcDream.Headless.Tests green and a headless connected route with identical static publication counts (§8.3) |
| P7 | FromSetup_DoorSetup_ProducesFourShapes is corrected, not deleted, and the second test that pins the union is found too |
§7.2 |
| P8 | AP-152 is retired with the row's four false/stale claims explicitly corrected | register diff in the same commit; §11.1–11.4 |
8. Gates
8.1 Suites
Clean build first — three stale-DLL incidents this session, one under
-t:Rebuild. Delete bin/ and obj/ for the touched projects, then
dotnet build -c Release.
AcDream.Core.Tests (owns ShadowShapeBuilder), AcDream.App.Tests,
AcDream.Content.Tests, AcDream.Runtime.Tests, AcDream.Headless.Tests,
then the complete Release solution suite. Baseline recorded at the C5c closeout
(1304dafa) is 11,196 passed / 4 skipped / 0 failed; re-measure at
0d62a5ff before the change so the delta is attributable, and expect it to
move only by the facts §7 adds.
8.2 Connected graphical route — positive evidence
Absence of a crash proves nothing here; the whole point of §3.3 is that the
obvious symptom was already suppressed. The criterion is a keyed diff, run
twice on the same binary, once at 0d62a5ff and once with the change, over the
canonical nine-stop route (tools/connected-world-lifecycle.route.txt or
tools/connected-dense-town.route.txt) with ACDREAM_PROBE_BUILDING=1:
- Shape inventory must change only for affected owners. The
[entity-source]line (LiveEntityCollisionBuilder.cs:200) printsshapes=cyl{n}+bsp{m}per registration. Every(entityId, src, cyl, bsp)tuple whosesrcis not in the 172 must be byte-identical between runs; every tuple whosesrcis in the 172 must go fromcyl>0 && bsp>0tocyl0+bsp{m}with the samem. Note the probe labels aSphereshape asbsp(itselsebranch at:197-198counts everything non-Cylinder as BSP) — so for the 99 Sphere+BSP Setups the counter will showcyl0+bspNbefore andcyl0+bsp(N-1)after. Verify the count drop, not the label. [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.- Flood-set diff, the load-bearing one. For each affected owner, the set
of shadow cells
RegisterMultiPartproduces (_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 atShadowObjectRegistry.cs:498printingowner, cellSet.Count, sorted cellIdsunderProbeBuildingEnabled, 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 —RegisterMultiPartreturns early at:465). - 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
effectivePartGfxObjIdsnull (so the gate sees the base id, for whichhasPhysicsBspis 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.HasPhysicsBSPagrees 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
- Deriving the gate from
setup.Partswhile step 3 derives fromeffectivePartGfxObjIds. They can disagree after an ObjDesc swap, and the disagreement is silent: the gate suppresses the primitives, step 3 emits nothing,Buildreturns null, and the entity's collision disappears entirely. §9.3 is the only fact that catches it. - 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.
- Reordering the emission by returning the BSP list early. Changes
shapes[0]/shapes[1]indices inIssue175HubDoorPoseInspectionTestsand the orderShadowEntryrows enter cells. - 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.
- Missing the flood-set change. It is the only behavioural delta, it is
in a function with no
BspOnlyDispatchguard, and nothing currently instruments it. §8.2 item 3 is the load-bearing gate. - Deleting
BspOnlyDispatchas "now redundant". It is retail's actual dispatch site and the guard against a future additive producer. §5.3. - Modelling retail's flag as live when it is cached once.
CPhysicsObj::CacheHasPhysicsBSPhas exactly one caller,InitPartArrayObject+0x7e, so after anAnimPartChangedpart 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. - 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 thethen, and itsebp_1aliasing in this function is visibly corrupt. Cite the disassembly. - Treating the 2018 weenie dump as authoritative for reachability.
0x020019FFis in zero of its Setup didStats and is nevertheless a live ACE spawn in our own capture (§4.4). - 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
(
0x0050f165test /0x0050f16f je→ primitives;0x0050f19d jmppast both;0x0050f1d6 jae→ return;0x0050f22f je→ return; BSP wins); - the corrected cottage-door number (0.100 m Sphere, not ~14 cm — the
0.141isSetup.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'sCSetup.HasPhysicsBSP, so the retirement is not a collision-behaviour change but a shape-list and cell-membership change; - the two corrected tests, not one;
- the 172 figure with its three-decoder derivation and the 5,935/5,935 authored-vs-derived agreement;
- a pointer to this document.
12.2 File — retail's dispatch flag is cached once; acdream's gate is live
Narrow, unreachable against ACE today (trap 7), but it is a real modelling
difference introduced by putting the gate in FromSetup, and it must be
recorded in the same commit that introduces it. One row, with
CPhysicsObj::CacheHasPhysicsBSP 0x0050f570 / single caller
InitPartArrayObject+0x7e 0x0051272e as the anchor.
12.3 File — HAS_PHYSICS_BSP_PS is taken from the wire, not derived
§11.6. Anchor: CPartArray::CacheHasPhysicsBSP 0x00518110 (derives from the
parts) vs LiveEntityCollisionBuilder.cs:161 (copies
FinalPhysicsState). Note ACE's WorldObject_Networking.cs:665-668 as the
reason it currently agrees, and that the agreement is not guaranteed by
anything on our side.
12.4 File — static-vs-live shape-source divergences
§11.7 and §11.8, as one row or two:
(a) static paths emit Setup Spheres as height-capped Cylinders with a shifted
origin where retail and the live path use CSphere::intersects_sphere;
(b) BuildFloodSpheres prefers cylinders where calc_cross_cells @0x00515230
routes a BSP-bearing object to find_bbox_cell_list @0x00510fc0 first.
(b) is partly retired by this fix for the 73 CylSphere+BSP Setups; the general
rule is not.
12.5 Check before filing
Confirm none of 12.2–12.4 is already covered by an existing row or by #291.
I grepped the register for LandblockPhysicsPublisher /
LandblockPhysicsContentBuilder and found only the retired AD-6 and the AP-152
and AP-22 rows themselves; no row covers the sphere-as-cylinder conversion or
the flood-source rule.
13. Size and split
Size: small. One production method gains a ~6-line pre-pass. Two tests corrected, three added (one of them an installed-DAT sweep, ~70 lines), four comments fixed, one register row retired, up to three filed. One commit.
Split call: land AP-152 alone.
Do not bundle:
- #330 (headless live-entity collision). Larger, overlaps Slice-J ownership, and bundling would destroy this commit's "graphical-only, proven by the headless route" evidence.
- The wire-vs-derived flag (§12.3). Changing
registration.Statetouches every consumer ofFinalPhysicsState— 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_listparity (§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).