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