Retail synthesizes NO shape for a shapeless object, so the fix is deletion,
not a corrected height formula.
CPhysicsObj::FindObjCollisions @0x0050f050 dispatches exclusively -- BSP xor
CylSphere xor Sphere xor nothing. The BSP branch leaves via an unconditional
`jmp 0x50f2b0` at 0x0050f19d and cannot reach the primitive branches; a
CylSphere-bearing object that survives its loop returns rather than falling
through to the Sphere loop; and with zero cylspheres, zero spheres and no
physics BSP, `0x0050f22f je 0x50f31b` branches straight to the epilogue,
returning the OK_TS seeded at `0x0050f13b mov edi,1`. CPartArray::GetRadius
(0x005180a0) and GetHeight (0x005180b0) are absent from the function's entire
call set -- Setup.Radius/Height serve attack cones, cylinder_distance and
MoveTo, never collision geometry. Disassembled directly from the PDB-paired
binary (GUID 9e847e2f-777c-4bd9-886c-22256bb87f32) rather than read from the
Binary Ninja text, whose ebp_1 aliasing in this function is visibly corrupt.
THREE copies were deleted, not one. The AP-22 register row cited
LiveEntityCollisionBuilder.cs and ShadowShapeBuilder.cs; the latter never
reads Setup.Radius at all, and the row omitted both
LandblockPhysicsPublisher.PublishStaticEntity and
LandblockPhysicsContentBuilder.PublishStaticCollision -- the second being the
only copy the headless host executes. Fixing just the cited site would have
left headless statics on the invented footprint.
The branch was unreachable dead code, not a live approximation. A sweep of all
5,935 Setups in the installed client_portal.dat -- validated by byte
accounting (5,935/5,935 records consumed with an exact 20 + 48*numLights
residual tail, zero unexplained bytes) and independently reproduced by the
production FlatCollisionAssetBuilder.FlattenSetup path -- finds 0 Setups
satisfying the guard: every Setup with Radius > 0.0001 carries at least one
CylSphere or Sphere, and all 1,294 genuinely shapeless Setups have Radius
exactly 0. Buckets: 678 cylsphere, 3,605 sphere-only, 358 BSP-only, 1,294
shapeless, 4,282 with Radius > 0.0001. Nothing loses collision because nothing
gained it, so no visual gate is required.
Tests, all sabotage-verified in both directions:
- InstalledSetupCollisionReachabilityTests (new, Content) -- the negative
claim plus five EXTERNAL positive controls, so a broken enumeration cannot
satisfy it vacuously. Inverting the claim reddens it; emptying the
enumeration fails on the controls at 0 != 5935 rather than passing.
- ShapelessSetupWithRadius_ProducesNoRegistration (new, App) -- restoring the
deleted block reddens exactly this fact and nothing else.
- Build_PropagatesExactStateFlagsScaleAndFullSeedCell -- re-hosts the state /
PWD-flag / seed-cell coverage that rode on the deleted fallback test, whose
fixture (a Setup with a radius and no primitives) cannot exist in the DAT.
Flipping a FromPwdBitfield bit reddens it; so does swapping SeedCellId for
the landblock id.
Also corrects ShadowShapeBuilder's retail-anchor comment, which claimed each
part's find_obj_collisions tests "CylSpheres + GfxObj BSP".
CPhysicsPart::find_obj_collisions @0x0050d8d0 tests ONLY the GfxObj physics
BSP; CylSpheres are a Setup-level array reached via CPartArray::GetCylsphere.
That comment was the written justification for the additive emission now filed
as AP-152, so it is corrected here even though AP-152 is not fixed here.
AP-22 retired with evidence; AP-152 filed (live path emits primitives AND BSP
parts additively where retail is exclusive -- 172 of 5,935 Setups including
BSP doors; deliberately not folded in, it needs its own visual gate). Issue
#330 filed: the headless host registers no live-entity collision at all, a
pre-existing gap this survey established and nothing tracked.
Gates: Release build 0 errors / 0 warnings. Complete solution suite
11,195 passed / 4 skipped / 0 failed (baseline 11,193/4/0 at bcb66ccd; +1 App
for the added fact, +1 Content for the reachability test; the replaced test is
net zero). No new skips. Headless.Tests 89/89 exercises the site-3 copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
736 lines
36 KiB
Markdown
736 lines
36 KiB
Markdown
# AP-22 contract — the invented `setup.Radius` collision cylinder
|
||
|
||
**Status:** authored 2026-08-06, planning only. No production or test code
|
||
written; no commit made.
|
||
**Worktree:** `.claude/worktrees/peaceful-visvesvaraya-e0a196`,
|
||
branch `claude/acdream-physics-divergence-5aa784`, base HEAD `bcb66ccd`.
|
||
**Register row:** AP-22 (`docs/architecture/retail-divergence-register.md:199`).
|
||
|
||
---
|
||
|
||
## 1. Verdict
|
||
|
||
**Retail synthesizes no shape for a shapeless object. It simply does not
|
||
collide.** There is no `setup->radius` fallback, no default cylinder, no
|
||
default sphere anywhere in `CPhysicsObj::FindObjCollisions`. A shapeless
|
||
object is still inserted into cells (so it is in every candidate list), and
|
||
then contributes `OK_TS` — no block, no slide, no contact.
|
||
|
||
**The fix is deletion.** Not a corrected derivation, not a better height
|
||
formula.
|
||
|
||
**And the deletion is behaviorally inert.** A DAT sweep of all 5,935 Setups
|
||
in the installed `client_portal.dat` found **zero** Setups that can reach the
|
||
fallback branch — in any of its three copies. Every Setup with
|
||
`Radius > 0.0001` carries at least one CylSphere or Sphere, so the guard
|
||
`shapes.Count == 0 && setup.Radius > 0.0001f` is unsatisfiable against retail
|
||
data. The branch is provably dead code, not a live approximation.
|
||
|
||
That changes the shape of the gate. The register row's stated risk ("rare
|
||
decorative props collide with an invented footprint") is **not what is
|
||
happening** — those props do not exist. Nothing loses collision on deletion,
|
||
because nothing gained it. The gate is therefore a *reachability proof*, not
|
||
a before/after behavior comparison, and it is cheap and strong.
|
||
|
||
Three things found stale or false at HEAD are in §11; one of them
|
||
(a genuine, unregistered, 172-Setup divergence at the same file) is the real
|
||
finding of this investigation and is spun off, not folded in.
|
||
|
||
---
|
||
|
||
## 2. What retail does — byte-verified
|
||
|
||
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` reports
|
||
`=== MATCH: this exe pairs with our acclient.pdb ===`. Image base `0x00400000`.
|
||
Disassembly below is from that binary; pseudo-C line numbers are into
|
||
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
|
||
|
||
### 2.1 The dispatch is exclusive, and it is a three-way XOR plus "nothing"
|
||
|
||
`CPhysicsObj::FindObjCollisions` @ `0x0050f050` (pc:276776).
|
||
`edi` holds the result and is seeded `OK_TS` at `0x0050f13b`
|
||
(`mov edi, 1` — `OK_TS == 1`, confirmed by the early-out at `0x0050f083`
|
||
`mov eax, 1` and by `CPartArray::FindObjCollisions`'s `result = OK_TS`
|
||
initializer at pc:286241).
|
||
|
||
```
|
||
0050f165 f786a800000000000100 test dword ptr [esi + 0xa8], 0x10000 ; state & HAS_PHYSICS_BSP_PS
|
||
0050f16f 7431 je 0x50f1a2 ; not set -> CylSphere/Sphere path
|
||
0050f171 85ed test ebp, ebp
|
||
0050f173 752d jne 0x50f1a2 ; player-passthrough -> CylSphere/Sphere path
|
||
0050f175 56 push esi
|
||
0050f176 8bcb mov ecx, ebx
|
||
0050f178 e833ddffff call 0x50ceb0 ; OBJECTINFO::missile_ignore
|
||
0050f17d 85c0 test eax, eax
|
||
0050f17f 7521 jne 0x50f1a2 ; ignore -> CylSphere/Sphere path
|
||
0050f181 8b4e10 mov ecx, dword ptr [esi + 0x10] ; this->part_array
|
||
0050f184 85c9 test ecx, ecx
|
||
0050f186 0f848f010000 je 0x50f31b ; null -> return edi (OK_TS)
|
||
0050f18d e8ee8f0000 call 0x518180 ; CPartArray::FindObjCollisions (BSP walk)
|
||
0050f192 8bf8 mov edi, eax
|
||
0050f194 83ff01 cmp edi, 1
|
||
0050f197 0f847e010000 je 0x50f31b ; OK -> return
|
||
0050f19d e90e010000 jmp 0x50f2b0 ; not OK -> COLLISIONINFO bookkeeping, then return
|
||
```
|
||
|
||
`0x0050f19d` is an unconditional `jmp` past `0x0050f1a2`. **The BSP branch
|
||
never reaches the primitive branches.** Not "prefers"; cannot reach.
|
||
|
||
The CylSphere branch, `0x0050f1a2`:
|
||
|
||
```
|
||
0050f1a2 8b4e10 mov ecx,[esi+0x10] ; part_array
|
||
0050f1a5 85c9 test ecx, ecx
|
||
0050f1a7 7474 je 0x50f21d ; null -> Sphere path
|
||
0050f1a9 e8d28e00 call 0x518080 ; CPartArray::GetNumCylsphere (= setup->num_cylsphere)
|
||
0050f1ae 85c0 test eax, eax
|
||
0050f1b0 746b je 0x50f21d ; zero -> Sphere path
|
||
...
|
||
0050f1c9 0f844801 je 0x50f317 ; loop exhausted -> 0x50f31b, RETURN
|
||
0050f1d6 0f833b01 jae 0x50f317 ; loop exhausted -> 0x50f31b, RETURN
|
||
```
|
||
|
||
A CylSphere-bearing object that survives its whole CylSphere loop **returns**;
|
||
it never falls through to the Sphere loop. `ShadowShapeBuilder`'s step-2 gate
|
||
(`if (setup.CylSpheres.Count == 0)`) is correct and matches this.
|
||
|
||
The Sphere branch, `0x0050f21d`:
|
||
|
||
```
|
||
0050f21d 8b4e10 mov ecx,[esi+0x10]
|
||
0050f220 85c9 test ecx, ecx
|
||
0050f222 0f84f300 je 0x50f31b ; null part array -> RETURN edi (OK_TS)
|
||
0050f228 e8338e00 call 0x518060 ; CPartArray::GetNumSphere (= setup->num_sphere)
|
||
0050f22d 85c0 test eax, eax
|
||
0050f22f 0f84e600 je 0x50f31b ; ZERO SPHERES -> RETURN edi (OK_TS)
|
||
```
|
||
|
||
`0x0050f22f` is the decisive instruction. Zero cylspheres, zero spheres, no
|
||
physics BSP ⇒ jump straight to the epilogue at `0x0050f31b`:
|
||
|
||
```
|
||
0050f31b 8bc7 mov eax, edi ; still OK_TS
|
||
0050f31e c783cc01000000000000 mov dword ptr [ebx+0x1cc], 0 ; obstruction_ethereal = 0
|
||
0050f32e c20400 ret 4
|
||
```
|
||
|
||
**No fallback shape is constructed.** The complete call set of this function
|
||
is `GetNumCylsphere` `0x518080`, `GetCylsphere` `0x518090`, `GetNumSphere`
|
||
`0x518060`, `GetSphere` `0x518070`, `CCylSphere::intersects_sphere` `0x53b8f0`,
|
||
`CSphere::intersects_sphere` `0x537fd0`, `OBJECTINFO::missile_ignore`
|
||
`0x50ceb0`, `CPartArray::FindObjCollisions` `0x518180`,
|
||
`COLLISIONINFO::add_object` `0x6b4e20`. **`CPartArray::GetRadius` (`0x5180a0`)
|
||
and `GetHeight` (`0x5180b0`) are not among them.**
|
||
|
||
### 2.2 What `setup->radius` / `setup->height` are actually for in retail
|
||
|
||
They exist and are read — just never for collision geometry.
|
||
`CPartArray::GetRadius` @ `0x005180a0` (pc:286138) returns
|
||
`setup->radius * this->scale`; `GetHeight` @ `0x005180b0` likewise. Their
|
||
consumers:
|
||
|
||
| Retail site | Use |
|
||
|---|---|
|
||
| `CPhysicsObj::check_attack` `0x0050ec80` (pc:276549) | `CSphere::attack` cone radius/height |
|
||
| `CPhysicsObj::get_distance_to_object` `0x0050f7a0` (pc:277387) | `Position::cylinder_distance` |
|
||
| `CPhysicsObj::…` `0x005127e0` (pc:280583/280637) | parent-relative distance / MoveTo setup |
|
||
|
||
acdream mirrors this correctly at
|
||
`src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs:284`
|
||
(`GetSetupCylinder` → wire `MoveToObject` radius/height). **That site is
|
||
retail-faithful and must not be touched by this change.**
|
||
|
||
### 2.3 A shapeless object is still cell-resident
|
||
|
||
`CPhysicsObj::calc_cross_cells` @ `0x00515230` (pc:283330) and
|
||
`calc_cross_cells_static` @ `0x00515160` (pc:283280) both fall back to
|
||
`CPartArray::GetSortingSphere` → `CObjCell::find_cell_list` when there are no
|
||
cylspheres. So the object is in the cell's object list and *is* visited by
|
||
`CObjCell::find_obj_collisions` (`0x0052b750`, pc:308916) — it just returns
|
||
`OK_TS`. Retail's "no collision" is an empty shape walk, not an absence from
|
||
the world. acdream's equivalent (`Build` returns `null`, nothing registered in
|
||
`ShadowObjectRegistry`) is observationally identical: the registry is a
|
||
collision structure only.
|
||
|
||
### 2.4 The dispatch flag is client-derived, exactly like ours
|
||
|
||
`HAS_PHYSICS_BSP_PS` (`0x10000`) is not taken on faith from the wire.
|
||
`CPhysicsObj::CacheHasPhysicsBSP` @ `0x0050f570` (pc:277205) sets or clears it
|
||
from `CPartArray::CacheHasPhysicsBSP` @ `0x00518110` (pc:286198), which walks
|
||
the parts and looks for **any** part whose `gfxobj->physics_bsp != 0`. It is
|
||
called from `CPhysicsObj::InitPartArrayObject` @ `0x005126b0` (call at
|
||
`0x0051272e`). acdream's `_hasPhysicsBsp(gfxId)` predicate
|
||
(`LiveEntityCollisionBuilder.cs:46`) is the faithful equivalent of that
|
||
derivation.
|
||
|
||
`CPhysicsPart::find_obj_collisions` @ `0x0050d8d0` (pc:275045) additionally
|
||
guards `gfxobj->physics_bsp != 0` per part, so a BSP-less part inside a
|
||
BSP-bearing part array contributes nothing. `ShadowShapeBuilder` step 3
|
||
(`if (!hasPhysicsBsp(gfxId)) continue;`) matches.
|
||
|
||
---
|
||
|
||
## 3. Reachability — how rare is "rare decorative props"?
|
||
|
||
**Answer: not rare. Nonexistent. Zero of 5,935.**
|
||
|
||
### 3.1 Method
|
||
|
||
Read-only scratchpad parse of the user's installed
|
||
`%USERPROFILE%\Documents\Asheron's Call\client_portal.dat` (B-tree directory
|
||
walk + block-chain file reads + Setup record parse), no repo code written.
|
||
|
||
Parse validation, three independent checks:
|
||
|
||
1. **Byte accounting.** All 5,935 Setup records parsed with an exact
|
||
residual tail of `20 + 48 × numLights` bytes and **zero** unexplained
|
||
bytes. A wrong field offset anywhere upstream would desynchronize the tail
|
||
on essentially every record; 5,935/5,935 clean is not achievable by luck.
|
||
2. **Cross-check against the in-repo tool.**
|
||
`dotnet run --project tools/SetupInspect -- 0x02000001` reports
|
||
`Radius/Height = 0.679 / 1.835`, `StepUp/StepDown = 0.600 / 1.500`,
|
||
`Spheres = 2`, `CylSpheres = 0`, `Parts = 34`. The sweep reports the same
|
||
numbers bit for bit.
|
||
3. **Physics-BSP flag hypothesis validated on known cases.** `GfxObjFlags`
|
||
bit `0x01` (`HasPhysics`, the name our own test fixtures use at
|
||
`tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs:1126`)
|
||
was validated against three documented cases: the Facility Hub door Setup
|
||
`0x02000C9D` — parts `0x0B/0x0B/0x0A`, BSP-bearing, matching the #175
|
||
door work; the cottage door `0x020019FF` — part[0] `0x0B`, matching the
|
||
2026-05-24 door-collision handoff; and the human Setup `0x02000001` — all
|
||
34 parts `0x0A`, no physics BSP, colliding via body Spheres, matching
|
||
`TransitionTypes.cs:4264`'s documented humanoid path.
|
||
|
||
### 3.2 Results (all 5,935 Setups in `client_portal.dat`)
|
||
|
||
| Bucket | Count | Share |
|
||
|---|---|---|
|
||
| ≥1 CylSphere (retail: CylSphere branch) | 678 | 11.4% |
|
||
| 0 CylSpheres, ≥1 Sphere (retail: Sphere branch) | 3,605 | 60.7% |
|
||
| 0 primitives, ≥1 physics-BSP part (retail: BSP branch) | 358 | 6.0% |
|
||
| **Shapeless** — 0 CylSpheres, 0 Spheres, 0 physics-BSP parts | **1,294** | **21.8%** |
|
||
| — of those, `Radius > 0.0001` (**fallback fires**) | **0** | **0%** |
|
||
| Setups with `Radius > 0.0001` overall | 4,282 | 72.1% |
|
||
| Setups with `Radius > 0.0001` **and** no CylSphere and no Sphere | **0** | **0%** |
|
||
| Setups whose only primitives all have `radius <= 0` | 0 | 0% |
|
||
|
||
**Every one of the 1,294 shapeless Setups has `Radius` exactly 0.**
|
||
Authoring correlates the summary radius with the presence of collision
|
||
primitives, so the fallback's own precondition never holds.
|
||
|
||
The static-path variant of the gate
|
||
(`Cylinders.Length == 0 && Spheres.Length == 0 && Radius > 0f`) returns the
|
||
same **0**, because `FlatCollisionAssetBuilder.FlattenSetup`
|
||
(`src/AcDream.Core/Physics/FlatCollisionAssetBuilder.cs:248,287`) is a
|
||
bit-exact DAT pass-through with no filtering and no synthesis.
|
||
|
||
### 3.3 The two escape hatches, both closed
|
||
|
||
- **A GfxObj-sourced entity** (`SourceGfxObjOrSetupId` with a `0x01` prefix)
|
||
cannot synthesize a Setup with a nonzero radius. Live:
|
||
`DatLiveEntityProjectionMaterializer.cs:188` does
|
||
`_dats.Get<Setup>(spawn.SetupTableId.Value)`; a non-Setup id yields `null`
|
||
and the entity is **dropped** (`_missingSetup++`, `:193`) — we have no
|
||
`CSetup::makeSimpleSetup` (`0x00520090`, pc:295344) equivalent at all.
|
||
Static: `cache.GetFlatSetup(0x01…)` misses and the entity records
|
||
`noCollision`. Either way `Radius` is never fabricated.
|
||
- **`effectivePartGfxObjIds` part swaps** (retail `AnimPartChanged`) can only
|
||
empty step 3 of the walk. They cannot remove a Setup's CylSpheres or
|
||
Spheres, so they cannot manufacture the `shapes.Count == 0` **and**
|
||
`Radius > 0` combination.
|
||
|
||
### 3.4 What this means for the "walking through a prop" concern
|
||
|
||
It does not arise. No entity in the installed DAT set is currently colliding
|
||
by way of the invented cylinder, so no entity stops colliding when it is
|
||
removed. The correct gate is a **reachability proof**, described in §7–§8, not
|
||
a prop-by-prop behavioral comparison. Confirming that positively (a real
|
||
connected run that observes zero executions of the branch) is still required —
|
||
see §8 — because the sweep proves a property of the DAT, and the gate must
|
||
prove the property of the running system.
|
||
|
||
---
|
||
|
||
## 4. The exact change, by symbol
|
||
|
||
### 4.1 Delete — three copies, not one
|
||
|
||
| # | Symbol | Location at `bcb66ccd` | Host reach |
|
||
|---|---|---|---|
|
||
| 1 | `LiveEntityCollisionBuilder.Build` | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:136-146` | graphical only |
|
||
| 2 | `LandblockPhysicsPublisher.PublishStaticEntity` | `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:1041-1058` | graphical only |
|
||
| 3 | `LandblockPhysicsContentBuilder.PublishStaticCollision` | `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:694-707` | **headless only** |
|
||
|
||
All three are the same eleven-line block: `if (no cylinders && no spheres &&
|
||
Radius > 0) → add one Cylinder shape of radius `Radius × scale` and height
|
||
`(Height > 0 ? Height : Radius × 2) × scale``. Delete the `if` block in each.
|
||
In sites 2 and 3 the surrounding `setupShapes.Count == 0 → noCollision`
|
||
handling already exists and needs no edit. In site 1 the existing
|
||
`if (shapes.Count == 0 && !retainEmptyPayload) return null;` at `:148` already
|
||
handles the resulting case and needs no edit.
|
||
|
||
### 4.2 Do not touch
|
||
|
||
- `LiveEntityMotionRuntimeController.cs:284` (`GetSetupCylinder`) — retail-faithful,
|
||
see §2.2.
|
||
- `PhysicsDataCache.cs:392` (`Radius = setup.Radius`) and
|
||
`FlatSetupCollision.Radius` — the field is still consumed by the MoveTo path.
|
||
- `ShadowShapeBuilder.FromSetup` steps 1 and 2 — byte-verified correct in §2.1.
|
||
- `ShadowShapeBuilder.FromSetup` step 3's *additive* emission — real divergence,
|
||
but **out of scope**; see §11.3 and §12.2.
|
||
|
||
### 4.3 Comment corrections in the same commit
|
||
|
||
- `LiveEntityCollisionBuilder.cs:30-35` class doc — the phrase
|
||
"and the established Setup-radius fallback for the remaining ACE prop data"
|
||
describes the deleted branch and a data population that does not exist.
|
||
- `ShadowShapeBuilder.cs:25-30` — see §11.2; the retail claim in that
|
||
paragraph is false and must be corrected whether or not the exclusivity fix
|
||
lands, because a future reader will re-derive the additive design from it.
|
||
|
||
---
|
||
|
||
## 5. Blast radius across both hosts
|
||
|
||
This is where the C5b lesson bites and where the register row is wrong.
|
||
**A survey of the live-entity call graph alone misses two thirds of the
|
||
change.**
|
||
|
||
```
|
||
AcDream.Headless ─▶ AcDream.Runtime ─▶ AcDream.Content ─▶ AcDream.Core
|
||
AcDream.App ─▶ AcDream.Runtime, AcDream.Content, AcDream.Core
|
||
```
|
||
|
||
| Path | Producer | Graphical | Headless |
|
||
|---|---|---|---|
|
||
| Live server entities (weenies) | `LiveEntityCollisionBuilder.Build` → `ShadowShapeBuilder.FromSetup` | yes | **no — not registered at all** |
|
||
| Landblock statics, streaming | `LandblockPhysicsPublisher.PublishStaticEntity` | yes | no |
|
||
| Landblock statics, prepared content | `LandblockPhysicsContentBuilder.PublishStaticCollision` | no | **yes** (`HeadlessSessionWorldProjection.cs:461`) |
|
||
|
||
Verified call sites: `LandblockPhysicsContentBuilder.PublishStaticCollision`
|
||
has exactly one caller, `HeadlessSessionWorldProjection.cs:461`;
|
||
`src/AcDream.App/Streaming/LandblockBuildFactory.cs` calls only
|
||
`BuildPreparedCollisionClosure` / `HydrateStaticEntities` / `BuildDatBundle`
|
||
from that class, never `PublishStaticCollision`. `ShadowShapeBuilder.FromSetup`
|
||
has exactly one production caller, `LiveEntityCollisionBuilder.cs:116`.
|
||
|
||
Two consequences the implementer must carry:
|
||
|
||
1. **Site 3 is only exercised by `AcDream.Headless.Tests` and by a native/WSL
|
||
headless run.** An App-only suite will stay green through a wrong edit
|
||
there. The suite matrix in §8 names both.
|
||
2. **Headless registers no live-entity collision at all today** (no
|
||
`ShadowShapeBuilder.FromSetup` caller in `Runtime`/`Headless`;
|
||
`HeadlessSessionHost.cs:640` even pins the local player to
|
||
`RuntimeLocalPlayerShadowDisposition.ProvenShapeless`). That is a
|
||
pre-existing gap, **not** created or widened by this change, and **not** in
|
||
scope. Note it; do not fix it here. If it is not already registered, file
|
||
it as its own row — a headless bot currently walks through every NPC.
|
||
|
||
`RuntimeRemotePhysicsUpdater.cs:478` calls `RegisterMultiPart` but only to
|
||
re-publish an already-built shape list at a resolved pose; it constructs no
|
||
shapes and is unaffected.
|
||
|
||
---
|
||
|
||
## 6. Proof obligations
|
||
|
||
The commit is not complete until each of these is discharged with the named
|
||
evidence attached to the commit message or the closeout note.
|
||
|
||
| # | Obligation | Evidence |
|
||
|---|---|---|
|
||
| P1 | Retail synthesizes no shape for a shapeless object | §2.1 disassembly, reproduced in the commit message: `0x0050f22f je 0x50f31b` and the `CPartArray::GetRadius`-absent call set |
|
||
| P2 | The deleted branch is unreachable for every installed Setup | The DAT reachability test of §7.1, run and reported with its bucket counts |
|
||
| P3 | The deletion changes no registered shape for any entity | Diff of registered shape counts across a connected route, before vs after — §8.2 |
|
||
| P4 | All three copies are gone | `grep -rn "setup.Radius\|\.Radius > 0f" --include=*.cs src/` returns only `GetSetupCylinder`, `PhysicsDataCache.cs:392`, and non-collision hits |
|
||
| P5 | Headless is exercised, not merely compiled | `AcDream.Headless.Tests` green **and** a headless connected run — §8.3 |
|
||
| P6 | The behaviors the deleted test covered are still covered | The re-hosted test of §7.2, with its sabotage result |
|
||
| P7 | AP-22 is retired, and the three site citations are corrected in the retirement text so the record is not wrong twice | Register diff in the same commit |
|
||
|
||
---
|
||
|
||
## 7. Test plan
|
||
|
||
Design rule for every item: **state the sabotage that must redden it, and run
|
||
that sabotage.** Four green-but-empty tests shipped in this campaign already
|
||
(C5b's D3 conservation test, #276's three settler tests, #280's tautological
|
||
integration test, the atlas-tier seam 4,170 tests missed). Assume each test
|
||
below discriminates nothing until the sabotage run proves otherwise.
|
||
|
||
### 7.1 NEW — installed-DAT reachability (the load-bearing test)
|
||
|
||
Where: `tests/AcDream.Content.Tests` (Content is the lowest layer that owns
|
||
the flattened Setup shape and is reachable from both hosts) or, if the suite's
|
||
installed-DAT convention lives elsewhere, wherever the I2/I4 "representative
|
||
installed DATs" tests live. Skip cleanly when `ACDREAM_DAT_DIR` is absent,
|
||
matching the existing installed-DAT convention.
|
||
|
||
Enumerate every Setup in `client_portal.dat`, flatten with
|
||
`FlatCollisionAssetBuilder.FlattenSetup`, and assert:
|
||
|
||
- **(a) the negative claim:** zero Setups satisfy
|
||
`Cylinders.Length == 0 && Spheres.Length == 0 && Radius > 0.0001f`.
|
||
- **(b) positive controls, so (a) cannot pass vacuously:** the enumeration
|
||
yielded 5,935 Setups; 678 have ≥1 Cylinder; 3,605 have 0 Cylinders and ≥1
|
||
Sphere; 1,294 have neither; 4,282 have `Radius > 0.0001f`.
|
||
|
||
(b) is not decoration. It is the entire reason the test discriminates: a
|
||
broken enumerator, a wrong dat path, or a silently-empty flatten all satisfy
|
||
(a) trivially and are caught only by (b). This is exactly the failure the
|
||
atlas-tier seam commit (`bcb66ccd`) was written to close.
|
||
|
||
Do not assert the 358 physics-BSP-part bucket here — that requires GfxObj
|
||
resolution and belongs to §7.3 if wanted at all.
|
||
|
||
**Sabotage (must run both):**
|
||
- Invert (a) to `> 0` expected: must fail on assertion (a).
|
||
- Point the enumeration at an empty id set: must fail on assertion (b) with
|
||
`0 != 5935`, *not* pass (a).
|
||
|
||
**Trap:** do not re-derive the bucket numbers from the same predicate the
|
||
production code uses and then assert they agree — that is a tautology. The
|
||
numbers in (b) are external constants measured in §3.2 and must be written as
|
||
literals.
|
||
|
||
### 7.2 REPLACE — `LiveEntityCollisionBuilderTests.RadiusFallback_UsesExactStateFlagsScaleAndFullSeedCell`
|
||
|
||
`tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs:28-58`.
|
||
This test constructs `new Setup { Radius = 0.75f, Height = 2.5f }` with zero
|
||
primitives — a Setup that **cannot exist in the DAT** (§3.2). It is the only
|
||
test in the tree that pins the fallback, and it will fail on deletion.
|
||
|
||
Do **not** simply delete it. Beyond the fallback shape it asserts real,
|
||
still-live behavior: `registration.State` == `record.FinalPhysicsState`,
|
||
`HasWeenie`/`IsCreature`/`IsPlayer`/`IsPK` flag decode from the PWD bitfield
|
||
`0x28`, `LandblockId` and `SeedCellId` both the full cell, and
|
||
`WorldOffsetX/Y` pass-through. Re-host those assertions on a DAT-possible
|
||
fixture — a Setup with one CylSphere (`Radius 0.4`, `Height 1.2`, the shape
|
||
`LandblockPhysicsPublisherTests` already uses) — and rename accordingly
|
||
(`Build_PropagatesExactStateFlagsScaleAndFullSeedCell`).
|
||
|
||
**Sabotage:** flip one flag bit in `EntityCollisionFlagsExt.FromPwdBitfield`,
|
||
and separately swap `SeedCellId` for the landblock id at the construction
|
||
site. Each must redden this test. If neither does, the re-host lost the
|
||
coverage and the test is decoration.
|
||
|
||
### 7.3 NEW — the deleted-branch shape assertion (cheap, keeps the claim honest)
|
||
|
||
One fact in the same App test class: a Setup with `Radius = 0.75f`,
|
||
`Height = 2.5f` and **no** primitives and **no** BSP parts produces
|
||
`Build(...) == null` — no registration, no cylinder.
|
||
|
||
This is the direct inverse of the deleted test and states the retail rule in
|
||
acdream's own vocabulary. It sits beside the existing
|
||
`ShapelessSetup_ProducesNoRegistration` (`:275`), which today passes only
|
||
because its `new Setup()` has `Radius == 0`; the new fact is the one that
|
||
would have failed before the deletion.
|
||
|
||
**Sabotage:** restore the deleted `if` block — this fact must fail. This is
|
||
the only sabotage in the plan that directly re-proves the production change,
|
||
so it must be run.
|
||
|
||
### 7.4 Static-path coverage
|
||
|
||
`LandblockPhysicsPublisherTests` and the Content-side static tests use
|
||
CylSphere fixtures throughout and pin nothing about the fallback (checked:
|
||
`Radius = 0.4f` at `:228`/`:1098` are CylSphere radii, `Radius = 10f`/`1.25f`
|
||
are BSP bounding spheres). **No static test should need editing.** If one
|
||
turns red, that is a signal the deletion in site 2 or 3 removed more than the
|
||
`if` block — stop and re-read the diff rather than adjusting the test.
|
||
|
||
---
|
||
|
||
## 8. Gates
|
||
|
||
### 8.1 Suite matrix (both hosts — the C5b lesson)
|
||
|
||
- `dotnet build -c Release` green.
|
||
- `AcDream.Core.Tests`, `AcDream.Content.Tests`, `AcDream.App.Tests`,
|
||
`AcDream.Runtime.Tests`, **`AcDream.Headless.Tests`** — the last is the one
|
||
that covers site 3 and is the one a habitual App-only run skips.
|
||
- Complete Release solution suite at the expected count for `bcb66ccd`
|
||
(11,090 passed / 4 skipped / 0 failed, adjusted for the one replaced and two
|
||
added facts).
|
||
|
||
### 8.2 Connected graphical route — **positive evidence, not absence of signal**
|
||
|
||
Batch into the next connected session. Absence-of-crash is not a criterion.
|
||
Run the canonical nine-stop route
|
||
(`tools/connected-dense-town.route.txt` / the world-lifecycle route) twice on
|
||
the same binary, once at `bcb66ccd` and once with the change, with
|
||
`ACDREAM_PROBE_BUILDING=1` so `LiveEntityCollisionBuilder.Register`'s
|
||
`[entity-source]` line (`:202`) is emitted, and diff:
|
||
|
||
- the multiset of `(entityId, src, shapes=cylN+bspN)` tuples must be
|
||
**identical** between the two runs. Not "no errors" — byte-identical
|
||
registration inventory. That is the positive statement that no entity lost
|
||
or changed a shape.
|
||
- registered-owner counts per landblock (`setupOwners`/`bspOwners`/
|
||
`noCollision` from `PublishStaticCollision`, and the publisher's equivalent)
|
||
must be identical.
|
||
|
||
**Known probe gap, must be handled:** the `[entity-source]` line reports
|
||
`shapes=cylN+bspN` and classifies the fallback cylinder as an ordinary
|
||
`Cylinder`, indistinguishable from a CylSphere-derived one. As-is it cannot
|
||
tell you the fallback fired. Since §3 proves the count is zero either way the
|
||
identical-multiset diff is still decisive, but if the implementer wants direct
|
||
evidence, add a temporary `cause=` tag at the branch before deleting it, run
|
||
one route, observe zero, then delete tag and branch together. Do not ship the
|
||
tag.
|
||
|
||
### 8.3 Connected headless route
|
||
|
||
Native Linux or WSL headless single-session run through the four-stop portal
|
||
route (the K1/K3 gate). Positive criterion: static-collision publication
|
||
counts per landblock identical to a pre-change run, graceful ACE-confirmed
|
||
logout, terminal ownership ledger at zero.
|
||
|
||
### 8.4 User visual gate
|
||
|
||
**Not required.** No registered shape changes, so there is nothing for the
|
||
user to look at. Do not spend a connected session's visual budget on this;
|
||
spend it on the AP-152 slice (§12.2), which does change 172 Setups' shapes and
|
||
does need eyes.
|
||
|
||
---
|
||
|
||
## 9. Traps
|
||
|
||
1. **Deleting only the site the register row cites.** The row names
|
||
`LiveEntityCollisionBuilder.cs` and `ShadowShapeBuilder.cs`. The real
|
||
population is three copies, and the one the row does not mention at all
|
||
(`LandblockPhysicsContentBuilder`) is the **only** one headless executes.
|
||
2. **Assuming `ShadowShapeBuilder.cs` contains the fallback.** It does not —
|
||
the row's site list is wrong about which file (§11.1). Grep by symbol
|
||
before editing.
|
||
3. **Treating this as a behavior change and over-gating it.** It is dead-code
|
||
removal. The temptation to run a full two-client matrix wastes a connected
|
||
session; the reachability test is the stronger evidence and costs minutes.
|
||
4. **The inverse trap: treating "it's dead code" as licence to skip the
|
||
headless suite.** Site 3 compiles in App builds and runs only in headless.
|
||
5. **Deleting `setup.Radius` plumbing.** `FlatSetupCollision.Radius`,
|
||
`PhysicsDataCache.cs:392` and `GetSetupCylinder` are live and
|
||
retail-faithful. Only the *collision-shape* consumer dies.
|
||
6. **Deleting the fallback test outright** and silently dropping the state /
|
||
flag / seed-cell coverage riding on it (§7.2).
|
||
7. **Folding in the exclusivity fix** because it is "right there in the same
|
||
function." It changes 172 Setups including doors, needs its own visual
|
||
gate, and would destroy this commit's zero-risk profile (§12.2).
|
||
8. **Trusting the Binary Ninja text for the dispatch polarity.** BN renders
|
||
the branch as `if ((state & 0x10000) == 0 || ebp_1 != 0 || eax_12 != 0)`
|
||
with the *primitive* path in the `then` and the *BSP* path in the `else` —
|
||
readable, but easy to invert when skimming, and BN's `ebp_1` aliasing in
|
||
this function is visibly corrupt (`ebp_1 = &ebp_1->object_info.ethereal`
|
||
at pc:276891 is nonsense). Cite the disassembly, not the pseudo-C, for any
|
||
claim about which branch runs.
|
||
9. **`OK_TS` is 1, not 0.** Two independent confirmations in §2.1. A reader who
|
||
assumes 0 will misread `cmp edi, 1 / je` as an error path.
|
||
|
||
---
|
||
|
||
## 10. Size and split
|
||
|
||
**Size:** small. ~33 production lines deleted across 3 files, 1 test replaced,
|
||
2 test facts added, 1 new installed-DAT test (~60 lines), 2 doc comments
|
||
corrected, 1 register row retired + 1 filed. **One commit.**
|
||
|
||
**Split call: land AP-22 alone.** Do not bundle:
|
||
|
||
- **AP-152 (§12.2, the exclusivity divergence)** — separate slice. It is a
|
||
live behavior change on 172 Setups including every BSP door, it needs a
|
||
connected visual gate, and bundling it would mean the AP-22 retirement's
|
||
evidence no longer says "nothing changed."
|
||
- **The headless live-entity collision gap (§5)** — separate, larger, and
|
||
likely already tracked.
|
||
- **`makeSimpleSetup` parity** (retail builds a one-part Setup for a GfxObj
|
||
id; we drop the entity — §3.3) — separate, needs a look at whether ACE ever
|
||
sends one.
|
||
|
||
---
|
||
|
||
## 11. Claims found false or stale at HEAD
|
||
|
||
Numbered, as required. Every one was verified against source or the binary,
|
||
not inherited.
|
||
|
||
### 11.1 The register row's site list is incomplete and partly wrong — **FALSE**
|
||
|
||
Row AP-22 cites
|
||
`src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`;
|
||
`src/AcDream.Core/Physics/ShadowShapeBuilder.cs`.
|
||
|
||
- `ShadowShapeBuilder.cs` **does not contain the invented cylinder.** Grep for
|
||
`setup.Radius` in that file returns nothing; the class never reads
|
||
`Setup.Radius` or `Setup.Height` at all. It is cited as the *mitigation*,
|
||
and the site column absorbed it.
|
||
- Two real sites are missing:
|
||
`src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:1041` and
|
||
`src/AcDream.Content/LandblockPhysicsContentBuilder.cs:694`. The second is
|
||
the headless-only copy. A reader following the row would have shipped a fix
|
||
that leaves headless statics on the invented footprint.
|
||
|
||
The retirement text must state the corrected three-site list, or the record is
|
||
wrong in both directions.
|
||
|
||
### 11.2 `ShadowShapeBuilder`'s retail anchor comment is factually wrong — **FALSE**
|
||
|
||
`src/AcDream.Core/Physics/ShadowShapeBuilder.cs:25-30`:
|
||
|
||
> `CPhysicsObj::FindObjCollisions` calls `CPartArray::FindObjCollisions` which
|
||
> iterates parts; each part's `find_obj_collisions` tests CylSpheres + GfxObj BSP.
|
||
|
||
Two errors.
|
||
|
||
- **`CPhysicsPart::find_obj_collisions` (`0x0050d8d0`, pc:275045) tests only
|
||
the GfxObj BSP.** Its entire body is
|
||
`if (gfxobj != 0 && gfxobj->physics_bsp != 0) { cache_localspace_sphere;
|
||
CGfxObj::find_obj_collisions }`. There is no CylSphere test inside a part,
|
||
and there cannot be: CylSpheres are a **Setup**-level array, reached via
|
||
`CPartArray::GetCylsphere` → `this->setup->cylsphere` (`0x00518090`,
|
||
pc:286130). Parts have no cylsphere member.
|
||
- **`CPhysicsObj::FindObjCollisions` does not "call `CPartArray::FindObjCollisions`
|
||
which iterates parts" as one leg of a combined walk.** It calls it in the
|
||
branch it takes *instead of* the primitive walk (§2.1).
|
||
|
||
This comment is the written justification for the additive emission in 11.3,
|
||
so it must be corrected in this commit regardless of whether 11.3 is fixed
|
||
here.
|
||
|
||
### 11.3 The row's mitigation claim — "ShadowShapeBuilder (the faithful walk)" — is **STALE**, and the real divergence is bigger than AP-22
|
||
|
||
`ShadowShapeBuilder.FromSetup` emits CylSpheres (step 1) **and** Spheres when
|
||
there are no CylSpheres (step 2) **and** every physics-BSP part (step 3),
|
||
**additively**. Retail's walk is exclusive: BSP **xor** CylSphere **xor**
|
||
Sphere **xor** nothing (§2.1, byte-verified).
|
||
|
||
Measured impact: **172 of 5,935 Setups (2.9%)** carry a primitive *and* at
|
||
least one physics-BSP part — 73 CylSphere+BSP, 99 Sphere+BSP. Every one of
|
||
them registers an extra collision primitive retail never tests. This is not a
|
||
tail case: the cottage door `0x020019FF` is in the set, and
|
||
`ShadowShapeBuilderTests.FromSetup_DoorSetup_ProducesFourShapes`
|
||
(`tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs:51`) **pins the
|
||
additive behavior as intended** — 1 Sphere + 3 BSP shapes where retail would
|
||
register the BSP walk alone.
|
||
|
||
Note also the internal inconsistency: acdream's two **static** paths already
|
||
implement the exclusive rule (`LandblockPhysicsPublisher` gates the Setup walk
|
||
on `entityBspCount == 0`; `LandblockPhysicsContentBuilder` `continue`s after
|
||
BSP shapes). Only the **live** path is additive. The same object registers a
|
||
different shape set depending on whether it arrived as a landblock static or a
|
||
server weenie.
|
||
|
||
**Not fixed here.** Filed as AP-152, §12.2.
|
||
|
||
### 11.4 The register row's risk statement is **STALE**
|
||
|
||
> Those props collide with an invented footprint (especially the Radius×2
|
||
> height guess) — slides/blocks at non-retail distances.
|
||
|
||
No prop does. Zero of 5,935 Setups can reach the branch (§3.2). The risk as
|
||
written describes a live approximation; the reality is unreachable code. Both
|
||
the mitigation ("preserves prior behavior so rare decorative props don't lose
|
||
collision") and the risk are describing a population that does not exist.
|
||
|
||
### 11.5 `LiveEntityCollisionBuilder`'s own class doc is **STALE**
|
||
|
||
`:30-35` describes "the established Setup-radius fallback for the remaining
|
||
ACE prop data." There is no remaining ACE prop data with that shape.
|
||
|
||
### 11.6 The only test pinning the fallback uses DAT-impossible data — **noted**
|
||
|
||
`RadiusFallback_UsesExactStateFlagsScaleAndFullSeedCell` builds
|
||
`new Setup { Radius = 0.75f, Height = 2.5f }` with zero primitives. No such
|
||
Setup exists in `client_portal.dat`. The test is green and covers a code path
|
||
no real input reaches — the fifth instance of this campaign's recurring
|
||
pattern. §7.2 re-hosts its live assertions rather than deleting them.
|
||
|
||
---
|
||
|
||
## 12. Register edits
|
||
|
||
### 12.1 Retire AP-22 in the implementing commit
|
||
|
||
Retirement text must carry: the three-site corrected list (§11.1); the
|
||
byte-level anchor (`CPhysicsObj::FindObjCollisions` `0x0050f050`, the
|
||
zero-spheres exit `0x0050f22f je 0x50f31b`, and the absence of
|
||
`CPartArray::GetRadius`/`GetHeight` from the call set); and the reachability
|
||
measurement (0 of 5,935 Setups, with the §3.2 bucket table as the positive
|
||
control). Cite this document.
|
||
|
||
### 12.2 File AP-152 — live-path collision shape emission is additive, retail's is exclusive
|
||
|
||
Draft row:
|
||
|
||
- **Divergence:** `ShadowShapeBuilder.FromSetup` emits Setup primitives
|
||
**and** per-part physics-BSP shapes additively. Retail
|
||
`CPhysicsObj::FindObjCollisions` dispatches exclusively on
|
||
`HAS_PHYSICS_BSP_PS`: BSP walk **or** CylSpheres **or** Spheres **or**
|
||
nothing, never a union. Also internally inconsistent — acdream's two static
|
||
publication paths already implement the exclusive rule; only the live path
|
||
does not.
|
||
- **Sites:** `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup`
|
||
step 3, unconditional); consumer
|
||
`src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:116`. Exclusive
|
||
counterparts for comparison:
|
||
`src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs` (`entityBspCount == 0`
|
||
gate), `src/AcDream.Content/LandblockPhysicsContentBuilder.cs` (BSP-then-
|
||
`continue`).
|
||
- **Mitigation:** over-inclusion is the conservative direction (an extra
|
||
primitive can only add blocking, never remove it); the affected primitives
|
||
are small and centred at the part origin; behaviour is pinned by tests and
|
||
has survived the #150/#175/#182 door work.
|
||
- **Risk:** 172 of 5,935 Setups (2.9%; 73 CylSphere+BSP, 99 Sphere+BSP)
|
||
register a collision primitive retail never tests — including BSP doors,
|
||
where the door's ~10 cm base Sphere sits at the threshold. Symptom class:
|
||
catching or stopping on a doorway sill, or a small non-retail obstacle at a
|
||
BSP prop's base.
|
||
- **Retail anchor:** `CPhysicsObj::FindObjCollisions` 0x0050f050
|
||
(dispatch `0x0050f165 test …,0x10000` / `0x0050f16f je 0x50f1a2`;
|
||
BSP branch exit `0x0050f19d jmp 0x50f2b0`);
|
||
`CPhysicsPart::find_obj_collisions` 0x0050d8d0 (BSP only);
|
||
`CPartArray::CacheHasPhysicsBSP` 0x00518110;
|
||
`CPhysicsObj::CacheHasPhysicsBSP` 0x0050f570.
|
||
|
||
### 12.3 Check before filing
|
||
|
||
Confirm the headless live-entity collision gap (§5) is not already an open row
|
||
or issue. If it is not, file it — it is larger than either row above.
|
||
|
||
---
|
||
|
||
## Appendix — reproducing the measurement
|
||
|
||
The sweep was a throwaway scratchpad script, deliberately not committed. To
|
||
reproduce or to build §7.1's test, the Setup record layout confirmed against
|
||
5,935/5,935 records with zero residual bytes is:
|
||
|
||
```
|
||
u32 id
|
||
u32 flags
|
||
u32 numParts
|
||
u32 parts[numParts]
|
||
if (flags & 1) u32 parentIndex[numParts]
|
||
if (flags & 2) float3 defaultScale[numParts]
|
||
u32 numHoldingLocations ; each 36 bytes (u32 key, u32 partId, Frame{float3, float4})
|
||
u32 numConnectionPoints ; each 36 bytes (same)
|
||
u32 numPlacementFrames ; each: u32 key, Frame[numParts] (28 bytes each), u32 numHooks
|
||
u32 numCylSpheres ; each 20 bytes (float3 origin, float radius, float height)
|
||
u32 numSpheres ; each 16 bytes (float3 origin, float radius)
|
||
float height, radius, stepUpHeight, stepDownHeight
|
||
Sphere sortingSphere (16), Sphere selectionSphere (16)
|
||
u32 numLights ; each 48 bytes
|
||
u32 defaultAnimation, defaultScript, defaultMotionTable, defaultSoundTable, defaultPhysicsScriptTable
|
||
```
|
||
|
||
Physics-BSP presence per part is `GfxObj.Flags & 0x01`
|
||
(`GfxObjFlags.HasPhysics`), read from bytes 4..7 of the GfxObj record.
|
||
Distribution across the 15,318 GfxObjs in `client_portal.dat`:
|
||
flags `0x02` ×10,546, `0x0A` ×3,514, `0x03` ×641, `0x0B` ×617 — so 1,258
|
||
GfxObjs (8.2%) carry a physics BSP.
|
||
|
||
Production code should of course use `FlatCollisionAssetBuilder.FlattenSetup`
|
||
and `PhysicsDataCache`, not this layout; it is recorded only so the numbers in
|
||
§3.2 are independently checkable.
|