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>
36 KiB
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:
- Byte accounting. All 5,935 Setup records parsed with an exact
residual tail of
20 + 48 × numLightsbytes 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. - Cross-check against the in-repo tool.
dotnet run --project tools/SetupInspect -- 0x02000001reportsRadius/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. - Physics-BSP flag hypothesis validated on known cases.
GfxObjFlagsbit0x01(HasPhysics, the name our own test fixtures use attests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs:1126) was validated against three documented cases: the Facility Hub door Setup0x02000C9D— parts0x0B/0x0B/0x0A, BSP-bearing, matching the #175 door work; the cottage door0x020019FF— part[0]0x0B, matching the 2026-05-24 door-collision handoff; and the human Setup0x02000001— all 34 parts0x0A, no physics BSP, colliding via body Spheres, matchingTransitionTypes.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 (
SourceGfxObjOrSetupIdwith a0x01prefix) cannot synthesize a Setup with a nonzero radius. Live:DatLiveEntityProjectionMaterializer.cs:188does_dats.Get<Setup>(spawn.SetupTableId.Value); a non-Setup id yieldsnulland the entity is dropped (_missingSetup++,:193) — we have noCSetup::makeSimpleSetup(0x00520090, pc:295344) equivalent at all. Static:cache.GetFlatSetup(0x01…)misses and the entity recordsnoCollision. Either wayRadiusis never fabricated. effectivePartGfxObjIdspart swaps (retailAnimPartChanged) can only empty step 3 of the walk. They cannot remove a Setup's CylSpheres or Spheres, so they cannot manufacture theshapes.Count == 0andRadius > 0combination.
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) andFlatSetupCollision.Radius— the field is still consumed by the MoveTo path.ShadowShapeBuilder.FromSetupsteps 1 and 2 — byte-verified correct in §2.1.ShadowShapeBuilder.FromSetupstep 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-35class 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:
- Site 3 is only exercised by
AcDream.Headless.Testsand 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. - Headless registers no live-entity collision at all today (no
ShadowShapeBuilder.FromSetupcaller inRuntime/Headless;HeadlessSessionHost.cs:640even pins the local player toRuntimeLocalPlayerShadowDisposition.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
> 0expected: 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 Releasegreen.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/noCollisionfromPublishStaticCollision, 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
- Deleting only the site the register row cites. The row names
LiveEntityCollisionBuilder.csandShadowShapeBuilder.cs. The real population is three copies, and the one the row does not mention at all (LandblockPhysicsContentBuilder) is the only one headless executes. - Assuming
ShadowShapeBuilder.cscontains the fallback. It does not — the row's site list is wrong about which file (§11.1). Grep by symbol before editing. - 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.
- 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.
- Deleting
setup.Radiusplumbing.FlatSetupCollision.Radius,PhysicsDataCache.cs:392andGetSetupCylinderare live and retail-faithful. Only the collision-shape consumer dies. - Deleting the fallback test outright and silently dropping the state / flag / seed-cell coverage riding on it (§7.2).
- 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).
- 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 thethenand the BSP path in theelse— readable, but easy to invert when skimming, and BN'sebp_1aliasing in this function is visibly corrupt (ebp_1 = &ebp_1->object_info.etherealat pc:276891 is nonsense). Cite the disassembly, not the pseudo-C, for any claim about which branch runs. OK_TSis 1, not 0. Two independent confirmations in §2.1. A reader who assumes 0 will misreadcmp edi, 1 / jeas 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.
makeSimpleSetupparity (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.csdoes not contain the invented cylinder. Grep forsetup.Radiusin that file returns nothing; the class never readsSetup.RadiusorSetup.Heightat all. It is cited as the mitigation, and the site column absorbed it.- Two real sites are missing:
src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:1041andsrc/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::FindObjCollisionscallsCPartArray::FindObjCollisionswhich iterates parts; each part'sfind_obj_collisionstests CylSpheres + GfxObj BSP.
Two errors.
CPhysicsPart::find_obj_collisions(0x0050d8d0, pc:275045) tests only the GfxObj BSP. Its entire body isif (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 viaCPartArray::GetCylsphere→this->setup->cylsphere(0x00518090, pc:286130). Parts have no cylsphere member.CPhysicsObj::FindObjCollisionsdoes not "callCPartArray::FindObjCollisionswhich 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.FromSetupemits Setup primitives and per-part physics-BSP shapes additively. RetailCPhysicsObj::FindObjCollisionsdispatches exclusively onHAS_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(FromSetupstep 3, unconditional); consumersrc/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:116. Exclusive counterparts for comparison:src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs(entityBspCount == 0gate),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::FindObjCollisions0x0050f050 (dispatch0x0050f165 test …,0x10000/0x0050f16f je 0x50f1a2; BSP branch exit0x0050f19d jmp 0x50f2b0);CPhysicsPart::find_obj_collisions0x0050d8d0 (BSP only);CPartArray::CacheHasPhysicsBSP0x00518110;CPhysicsObj::CacheHasPhysicsBSP0x0050f570.
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.