acdream/docs/research/2026-08-06-ap22-contract.md
Erik bc4679cda5 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>
2026-08-06 08:27:26 +02:00

36 KiB
Raw Blame History

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, 1OK_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::GetSortingSphereCObjCell::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 × scaleand 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.BuildShadowShapeBuilder.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::GetCylspherethis->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 continues 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.