fix(physics): delete the invented Setup-radius collision cylinder (AP-22)

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>
This commit is contained in:
Erik 2026-08-06 08:27:26 +02:00
parent bcb66ccdf3
commit bc4679cda5
9 changed files with 947 additions and 62 deletions

View file

@ -24,6 +24,42 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #330 — The headless host registers no live-entity collision at all: a bot walks through every NPC and every server-spawned object
**Status:** OPEN
**Severity:** HIGH for headless gameplay fidelity; zero impact on the graphical client.
**Filed:** 2026-08-06, from the AP-22 deletion's blast-radius survey (§5 of
[`docs/research/2026-08-06-ap22-contract.md`](research/2026-08-06-ap22-contract.md)).
`ShadowShapeBuilder.FromSetup` — the only producer of live-entity collision
shapes — has exactly **one** production caller,
`src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`, which lives in
`AcDream.App`. `AcDream.Headless` references `AcDream.Runtime` and
`AcDream.Content`, never `AcDream.App`, so no headless code path ever builds or
registers a collision shape for a server weenie. `HeadlessSessionHost.cs:640`
additionally pins the local player to
`RuntimeLocalPlayerShadowDisposition.ProvenShapeless`.
Consequence: a headless bot has landblock **static** collision (published
through `LandblockPhysicsContentBuilder.PublishStaticCollision`, which the
headless world projection does call) but no collision against creatures, NPCs,
players, or any other server-spawned object. It walks straight through all of
them. Retail collides against every cell-resident object with a shape.
This is a **pre-existing gap**, not introduced or widened by the AP-22
deletion — AP-22 removed a branch that was unreachable for all 5,935 installed
Setups, so it changed no registered shape on either host. It is filed
separately because the AP-22 survey is what established it and because nothing
currently tracks it. Checked at filing time: **#291** is a different thing (the
headless 3x3 collision *window* wanting a divergence-register row), and no
register row covers this.
Not a one-to-two-commit change: closing it means giving Runtime or Content
ownership of live-entity shape construction (today an App concern), which
overlaps the Slice-J ownership work. If it is ever *accepted* rather than
fixed, it needs a divergence-register row; it is filed here as a defect
because the intent is to fix it.
## #329 — The portal wait cue arms five seconds late; retail emits it per tunnel rotation segment, unconditionally
**Status:** OPEN

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,736 @@
# 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.

View file

@ -29,10 +29,20 @@ internal sealed record LiveEntityCollisionRegistration(
/// <summary>
/// Ports the live-object collision-shape policy used by
/// <c>CPartArray::FindObjCollisions</c>: CylSpheres before Spheres, every
/// physics-BSP part, and the established Setup-radius fallback for the
/// remaining ACE prop data.
/// <c>CPartArray::FindObjCollisions</c>: CylSpheres before Spheres, and every
/// physics-BSP part.
/// </summary>
/// <remarks>
/// A Setup that yields no shape produces no registration. Retail synthesizes
/// nothing for a shapeless object: <c>CPhysicsObj::FindObjCollisions</c>
/// (0x0050f050) walks CylSpheres or Spheres or the physics BSP, and when
/// <c>CPartArray::GetNumSphere</c> returns zero it branches straight to the
/// epilogue (<c>0x0050f22f je 0x50f31b</c>) returning the seeded
/// <c>OK_TS</c>. <c>CPartArray::GetRadius</c> (0x005180a0) and
/// <c>GetHeight</c> (0x005180b0) are absent from that function's entire call
/// set — <c>Setup.Radius</c>/<c>Height</c> serve attack cones,
/// <c>cylinder_distance</c>, and MoveTo, never collision geometry.
/// </remarks>
internal sealed class LiveEntityCollisionBuilder
{
private readonly Func<uint, bool> _hasPhysicsBsp;
@ -133,18 +143,6 @@ internal sealed class LiveEntityCollisionBuilder
}
}
if (shapes.Count == 0 && setup.Radius > 0.0001f)
{
shapes.Add(new ShadowShape(
GfxObjId: 0u,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: setup.Radius * scale,
CylHeight: (setup.Height > 0f ? setup.Height : setup.Radius * 2f) * scale));
}
if (shapes.Count == 0 && !retainEmptyPayload)
return null;

View file

@ -1038,24 +1038,9 @@ public sealed class LandblockPhysicsPublisher
}
}
if (setup.Cylinders.Length == 0
&& setup.Spheres.Length == 0
&& setup.Radius > 0f)
{
float radius = setup.Radius * scale;
float height = (setup.Height > 0f
? setup.Height
: setup.Radius * 2f) * scale;
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: height));
}
// No Setup-radius fallback: retail synthesizes no shape for a
// Setup with neither CylSpheres nor Spheres. See
// LiveEntityCollisionBuilder's remarks for the retail anchor.
if (setupShapes.Count > 0)
{
publication.StagingEngine.ShadowObjects.RegisterMultiPart(

View file

@ -691,24 +691,9 @@ public static class LandblockPhysicsContentBuilder
}
}
if (setup.Cylinders.Length == 0
&& setup.Spheres.Length == 0
&& setup.Radius > 0f)
{
float radius = setup.Radius * scale;
float height = (setup.Height > 0f
? setup.Height
: setup.Radius * 2f) * scale;
setupShapes.Add(new ShadowShape(
entity.SourceGfxObjOrSetupId,
Vector3.Zero,
Quaternion.Identity,
scale,
ShadowCollisionType.Cylinder,
radius,
height));
}
// No Setup-radius fallback: retail synthesizes no shape for a
// Setup with neither CylSpheres nor Spheres. See
// LiveEntityCollisionBuilder's remarks for the retail anchor.
if (setupShapes.Count == 0)
{
noCollision++;

View file

@ -22,11 +22,26 @@ namespace AcDream.Core.Physics;
/// </para>
///
/// <para>
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> calls
/// <c>CPartArray::FindObjCollisions</c> which iterates parts; each part's
/// <c>find_obj_collisions</c> tests CylSpheres + GfxObj BSP. We emit one
/// ShadowShape per part contribution so the existing FindObjCollisions
/// iteration loop in <see cref="Transition"/> tests each part independently.
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> (0x0050f050)
/// dispatches EXCLUSIVELY on <c>HAS_PHYSICS_BSP_PS</c> (0x10000): it calls
/// <c>CPartArray::FindObjCollisions</c> (the per-part BSP walk) and returns
/// (<c>0x0050f19d jmp</c> past the primitive branches), OR walks the Setup's
/// CylSpheres, OR walks the Setup's Spheres, OR — with none of the three —
/// returns the seeded <c>OK_TS</c> without synthesizing any shape
/// (<c>0x0050f22f je 0x50f31b</c>). It is never a union.
/// <c>CPhysicsPart::find_obj_collisions</c> (0x0050d8d0) tests ONLY the
/// GfxObj physics BSP; CylSpheres and Spheres are <c>Setup</c>-level arrays
/// reached through <c>CPartArray::GetCylsphere</c> (0x00518090) and
/// <c>GetSphere</c> (0x00518070), so a part has no primitive of its own.
/// </para>
///
/// <para>
/// KNOWN DIVERGENCE (AP-152): steps 1/2 and step 3 below are emitted
/// ADDITIVELY here, where retail is exclusive — 172 of 5,935 installed
/// Setups carry a primitive and a physics-BSP part. Do not cite the anchor
/// above as justification for the additive design; it is the evidence
/// against it. acdream's two static publication paths already implement the
/// exclusive rule.
/// </para>
/// </summary>
public static class ShadowShapeBuilder

View file

@ -7,6 +7,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Physics;
@ -25,10 +26,23 @@ public sealed class LiveEntityCollisionBuilderTests
Assert.Equal([0x01001001u, 0x01002000u], resolved);
}
/// <summary>
/// Re-hosts the state/flag/seed-cell coverage that used to ride on the
/// deleted Setup-radius fallback (AP-22). The old fixture was a Setup with
/// a radius and zero primitives, which cannot exist in client_portal.dat —
/// all 1,294 shapeless Setups have Radius exactly 0. This uses a
/// DAT-possible single-CylSphere Setup instead.
/// </summary>
[Fact]
public void RadiusFallback_UsesExactStateFlagsScaleAndFullSeedCell()
public void Build_PropagatesExactStateFlagsScaleAndFullSeedCell()
{
var setup = new Setup { Radius = 0.75f, Height = 2.5f };
var setup = new Setup();
setup.CylSpheres.Add(new CylSphere
{
Origin = Vector3.Zero,
Radius = 0.4f,
Height = 1.2f,
});
WorldSession.EntitySpawn spawn = Spawn(
scale: 2f,
itemType: (uint)ItemType.Creature,
@ -45,8 +59,8 @@ public sealed class LiveEntityCollisionBuilderTests
ShadowShape shape = Assert.Single(registration.Shapes);
Assert.Equal(ShadowCollisionType.Cylinder, shape.CollisionType);
Assert.Equal(1.5f, shape.Radius);
Assert.Equal(5f, shape.CylHeight);
Assert.Equal(0.8f, shape.Radius);
Assert.Equal(2.4f, shape.CylHeight);
Assert.Equal((uint)record.FinalPhysicsState, registration.State);
Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.HasWeenie));
Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsCreature));
@ -57,6 +71,33 @@ public sealed class LiveEntityCollisionBuilderTests
Assert.Equal((192f, -192f), (registration.WorldOffsetX, registration.WorldOffsetY));
}
/// <summary>
/// AP-22: retail synthesizes no shape for a Setup with no primitives and
/// no physics-BSP part, whatever its summary Radius/Height say.
/// <c>CPhysicsObj::FindObjCollisions</c> (0x0050f050) branches to the
/// epilogue at <c>0x0050f22f je 0x50f31b</c> and returns the seeded
/// OK_TS; <c>CPartArray::GetRadius</c>/<c>GetHeight</c> are absent from
/// its call set. This is the direct inverse of the deleted
/// RadiusFallback fact.
/// </summary>
[Fact]
public void ShapelessSetupWithRadius_ProducesNoRegistration()
{
var setup = new Setup { Radius = 0.75f, Height = 2.5f };
WorldSession.EntitySpawn spawn = Spawn(scale: 2f);
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
WorldEntity entity = Entity();
record.WorldEntity = entity;
Assert.Null(Builder().Build(
entity,
setup,
Array.Empty<uint>(),
spawn,
record,
new Vector3(192f, -192f, 0f)));
}
[Fact]
public void BspOnlyPart_UsesRealScaledPhysicsBoundingRadius()
{

View file

@ -0,0 +1,88 @@
using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
namespace AcDream.Content.Tests;
/// <summary>
/// AP-22 reachability proof. The deleted Setup-radius collision fallback fired
/// only for a Setup with no CylSphere, no Sphere, and a nonzero summary
/// Radius. This sweeps every Setup in the installed client_portal.dat and
/// asserts no such Setup exists, so the deletion cannot remove collision from
/// any real object.
/// </summary>
public sealed class InstalledSetupCollisionReachabilityTests
{
// Positive controls. These are EXTERNAL constants, measured on 2026-08-06
// by an independent raw client_portal.dat parse (B-tree directory walk +
// Setup record decode) that validated itself by byte accounting: all
// 5,935 records consumed with an exact residual tail of
// 20 + 48 * numLights and zero unexplained bytes.
//
// They are deliberately NOT derived from FlatCollisionAssetBuilder. A
// broken enumeration, a wrong dat path, or a silently-empty flatten all
// satisfy the negative claim vacuously and are caught only here.
private const int ExpectedSetups = 5935;
private const int ExpectedWithCylinder = 678;
private const int ExpectedSphereOnlyNoCylinder = 3605;
private const int ExpectedWithoutAnyPrimitive = 1652;
private const int ExpectedWithSummaryRadius = 4282;
[Fact]
public void InstalledSetups_NeverReachTheDeletedRadiusFallback()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
int total = 0;
int withCylinder = 0;
int sphereOnly = 0;
int withoutPrimitive = 0;
int withRadius = 0;
var fallbackReachable = new List<uint>();
foreach (uint id in dats.GetAllIdsOfType<Setup>())
{
if (!dats.Portal.TryGet<Setup>(id, out Setup? setup)
|| setup is null)
{
continue;
}
FlatSetupCollision flat =
FlatCollisionAssetBuilder.FlattenSetup(setup);
total++;
bool hasCylinder = flat.Cylinders.Length > 0;
bool hasSphere = flat.Spheres.Length > 0;
if (hasCylinder)
withCylinder++;
else if (hasSphere)
sphereOnly++;
else
withoutPrimitive++;
if (flat.Radius > 0.0001f)
withRadius++;
// The exact guard the three deleted copies used.
if (!hasCylinder && !hasSphere && flat.Radius > 0.0001f)
fallbackReachable.Add(id);
}
// (b) Positive controls first: if the enumeration is broken, fail here
// rather than passing (a) for the wrong reason.
Assert.Equal(ExpectedSetups, total);
Assert.Equal(ExpectedWithCylinder, withCylinder);
Assert.Equal(ExpectedSphereOnlyNoCylinder, sphereOnly);
Assert.Equal(ExpectedWithoutAnyPrimitive, withoutPrimitive);
Assert.Equal(ExpectedWithSummaryRadius, withRadius);
// (a) The negative claim the deletion rests on.
Assert.Empty(fallbackReachable);
}
}