19-agent verified audit of how retail decides which objects collide vs acdream's per-channel filters. Confirms the user's "1 fix for all collision" intuition: acdream already has retail's two-layer shape (per-cell shadow registration + query-time exemption); the divergences are now narrow and enumerable, not a scattered filter mess. Audit (docs/research/2026-06-24-collision-inclusion-audit.md): 6 confirmed deviations (D1 mesh-AABB phantom HIGH, D2 ETHEREAL-alone, D3 no sphere primitive, D4 entry-restrictions, D5 obstruction_ethereal absent, D8 cell- transform stale cache) + 2 refuted by the adversarial pass (D6 placement- insert present in BSPQuery; D7 terrain pass-through is the #135/#138 streaming-gap, retail does it too). Loader verified faithful: CacheGfxObj reads PhysicsBSP gated on HasPhysics; the mesh-AABB is a pure additive non-faithful layer. Design (docs/superpowers/specs/2026-06-24-unified-collision-inclusion-design.md): "1 fix" = one DAT-only shape authority (delete mesh-AABB), one query predicate, four faithful channels kept distinct (retail keeps find_env/ find_building/find_obj separate), one per-apply rebase invariant. 5 independently-gated slices. Retires register rows AP-2 + AD-7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
16 KiB
Unified verbatim-retail collision-inclusion — design spec — 2026-06-24
Phase goal: make acdream's "which objects collide, and how the engine decides that" verbatim with retail, replacing the last non-faithful per-channel artifacts. World feel — what you bump into vs. walk through — should match the retail client object-for-object.
Source of truth for the divergences: docs/research/2026-06-24-collision-inclusion-audit.md
(19-agent verified audit: 6 confirmed deviations D1/D2/D3/D4/D5/D8, 2 refuted D6/D7).
Read first: claude-memory/project_physics_collision_digest.md (DO-NOT-RETRY table).
Branch: claude/thirsty-goldberg-51bb9b. Named-retail + references/ live in the MAIN repo.
1. The retail model we are matching (two layers, four channels)
Retail decides collision in two layers (verified addresses in the audit):
- Layer B — registration (broad phase). Every object that has a DAT physics shape
registers a shadow into every cell its sphere overlaps (
CObjCell::find_cell_list@0x0052b4e0,add_shadows_to_cells@0x00514ae0). No attribute is tested at registration (onlyPARTICLE_EMITTER_PS 0x1000diverts). "Registerable" ⇔ "has a DAT shape". - Layer A — inclusion predicate (narrow phase).
CPhysicsObj::FindObjCollisions@0x0050f050decides per-query skip/dispatch via flag logic, then dispatches one shape branch (BSP-only iffHAS_PHYSICS_BSP_PS 0x10000, else CylSphere-then-Sphere).
The object's shape comes from DAT only — a part's physics_bsp (CGfxObj::Serialize@0x00534970,
gated on serialized-flags bit-0) or CSetup cylsphere/sphere (CPartArray::InitParts@0x00517F40).
No shape ⇒ CPhysicsPart::find_obj_collisions@0x0050D8D0 returns OK (passable). Render-mesh bounds
never enter collision.
The four channels stay distinct (retail keeps them separate; we do too):
terrain (CLandCell::find_env_collisions), EnvCell (CEnvCell::find_env_collisions@0x0052c130,
no building leg), building shell (CBuildingObj::find_building_collisions@0x006b5300,
portal-independent), object shadows (CObjCell::find_obj_collisions@0x0052b750 → Layer A).
acdream already has this two-layer shape (ShadowObjectRegistry + CollisionExemption +
ShadowShapeBuilder + the A6.P4/BR-7 flood). Verified faithful: the DAT loader reads
gfxObj.PhysicsBSP (gated on HasPhysics) + setup.CylSpheres/setup.Spheres
(PhysicsDataCache.cs:61-68,157-158).
This spec closes the remaining gaps.
2. Target architecture (the "1 fix" mapped onto retail's four pieces)
-
One DAT-only collision-shape authority.
ShadowShapeBuilderbecomes the single place an object's collision shape is derived — fromPhysicsBSP/CylSpheres/Spheresonly. Every object-registration site (statics, scenery, weenies, building shells) funnels through it. The inline mesh-AABB synthesis is deleted fromGameWindow.cs. Because there is exactly one shape site, the DAT-only rule cannot be violated per-site, and the W3 radius inconsistency (2fvs1ffallback) dies. (Satisfies CLAUDE.md Structure Rule #1 — removes a ~150-line feature body from the 10k-lineGameWindow.csgod-object.) -
One query-time object predicate.
CollisionExemption(the skip gate) + the cyl/sphere/BSP dispatch form the object channel's Layer A, completed with theObstructionEtherealstate (D2/D5) and the PvP/missile dispatch terms wired (W1). -
Env / building / terrain stay distinct channels (retail-faithful), each with its specific deviation fixed in place.
-
One per-apply rebase invariant: every cached collision transform rebases to the current streaming center on each landblock apply. Buildings already comply (
RemoveBuildingsForLandblock, #146); cells get the symmetricRemoveCellsForLandblock(D8); shadow positions are already absolute.
What we do NOT do: no ObjectCollides facade migrating all four channels onto one predicate
(retail keeps find_env/find_building/find_obj separate — collapsing them is less faithful).
No new abstraction layer; we complete the units that already implement retail's model.
Component boundaries
| Unit | Responsibility | Depends on | Layer |
|---|---|---|---|
ShadowShapeBuilder (extended) |
Sole DAT-only collision-shape authority: object → {BSP | CylSphere[] | Sphere[] | None}. No render bounds. |
PhysicsDataCache (DAT shapes) |
Core |
ShadowObjectRegistry (unchanged) |
Per-cell shadow lists; overlap-based flood registration (BR-7) | shape rows from builder | Core |
CollisionExemption (extended) |
Query-time skip gate + ObstructionEthereal decision | PhysicsState, EntityCollisionFlags, ObjectInfoState |
Core |
SpherePath (extended) |
carries ObstructionEthereal state across the transition |
— | Core |
BSPQuery (extended) |
sphere primitive; consume ObstructionEthereal in the solid-containment gate |
shape rows | Core |
PhysicsDataCache (extended) |
RemoveCellsForLandblock symmetric with buildings |
— | Core |
TransitionTypes (edited) |
EnvCell entry-restrictions + obstruction_ethereal clear; dispatch wiring | above | Core |
GameWindow (reduced) |
registration sites call the builder; mesh-AABB block deleted | builder | App |
3. Slices (each: build+test green, conformance where applicable, its own visual gate)
Collision is the DO-NOT-RETRY area — grep named-retail → pseudocode → port → conformance-test for every slice; no guess-patches. Each slice is one-to-few commits and an independent visual gate.
Slice 1 — DAT-only shape authority + delete mesh-AABB (D1, D3-prep, W3)
- Retail oracle:
CPartArray::InitParts@0x00517F40,CGfxObj::Serialize@0x00534970(physics_bsp on flags bit-0),CPhysicsPart::find_obj_collisions@0x0050D8D0(null shape ⇒ OK/passable). - Change: Make
ShadowShapeBuilderthe single object-shape entry point. Delete the mesh-AABB synthesis block atGameWindow.cs:7408-7565and theIsPhantomGfxObjSource/isPhantomSetupgates that only existed to fence it (the phantom concept becomes the default: no DAT shape ⇒ no shape). Unify the static/live/scenery registration sites to one builder call. RemoveComputeVisualBoundsuse for collision (visual bounds stay for culling). - Shape vs broad-phase sphere (do not conflate): the collision shape is DAT-only and may be
None(object registers no shape, is passable — no synthetic radius). The broad-phase registration sphere (which cells the flood touches) derives from the shape's own bounding sphere; a shapeless object does not register at all. Kill the W3 fallback-radius (2fvs1f) by sourcing the registration radius from the shape's bounding sphere, never a synthetic constant — and never let a render-mesh bound produce a collision shape (a culling AABB is fine). - Acceptance: every object's collision shape originates from
PhysicsBSP/CylSpheres/Spheres; an object with none registers no shape.dotnet build/dotnet testgreen. Existing replay harnesses (CellarUp*,HouseExitWalk*,CornerFlood*) unchanged-green. - Register: delete AP-2 (the divergence is gone). Note AP-6 still covers analytic cylinder math.
- Visual gate: walk Holtburg + open world; objects that become passable must be exactly those
retail walks through (DAT has no physics shape). The
ACDREAM_PROBE_BUILDING[entity-source]line (BSP-vs-Cylinder per static) is the lens.
Slice 2 — true Sphere collision primitive (D3)
- Retail oracle:
CSphere::intersects_sphere(pc:276917),CPartArray::GetSphere@0x00518070. - Change: add a
Spherevariant toShadowCollisionType(ShadowObjectRegistry.cs:537) and a real sphere-vs-sweep primitive inBSPQuery;ShadowShapeBuilderemitsSphere(not coerced cylinder) forSetup.Spheres. Dispatch order matches retail:HAS_PHYSICS_BSPclear ⇒ CylSphere then Sphere. - Acceptance: new conformance fixture vs
CSphere::intersects_spheregolden values (port the decomp's intersection math; do not eyeball). Build/test green. - Register: D3 is a pre-existing unregistered gap being fixed; no row needed (fixed before it had one).
- Visual gate: an object whose Setup carries a Sphere (not CylSphere) collides correctly — pick a known sphere-shape object from the Slice-1 inventory probe.
Slice 3 — ObstructionEthereal verbatim port (D2 + D5) — highest care
- Retail oracle:
FindObjCollisions@0x0050f050Gate-1 (pc:276782, requires0x4 AND 0x10), ETHEREAL-alone branch (pc:276795-276806, setsobstruction_ethereal=1and continues), the per-call clear inCEnvCell::find_env_collisions@0x0052c144(pc:309580), and the consume sites in the BSP solid-containment gate (pc:321692 / 323742 / 324573:if (obstruction_ethereal || insert_type==PLACEMENT)). - Change (port set + clear + consume together):
- add
ObstructionEtherealfield toSpherePath; CollisionExemption: instant-skip only when0x4 AND 0x10; on0x4-alone setObstructionEthereal=trueand continue to shape test (remove the AD-70x4-alone return);- clear
ObstructionEthereal=0at the front of the EnvCell and LandCell env dispatch (this is D5 — the clear that currently doesn't exist because the field doesn't exist); - consume it in
BSPQuery's placement/solid-containment gate alongside the existingInsertType.Placementcheck.
- add
- The AD-7 shim is subsumed, not patched around. ACE broadcasts ETHEREAL-only (
0x0001000C) for opened doors; the faithful obstruction_ethereal path makes that door passable the retail way, so no wire-layer compat is needed — verify this is what happens, do not add a shim. - Acceptance: build/test green; a door-collision replay (reuse
DoorCollisionApparatusTests/ the flipped-door apparatus) shows ETHEREAL-alone → passable, ETHEREAL-absent → solid; wall collision on non-ethereal cells unchanged (the consume site must not weaken normal walls). - Register: delete AD-7 (divergence retired).
- Visual gate: the critical one — open a door in-world (ACE), walk through it (passable),
close it, walk into it (solid); confirm no house wall went soft. Live:
notan/MittSnus81!.
Slice 4 — RemoveCellsForLandblock (D8) — the anti-staleness invariant
- Retail oracle: the #146 pattern (
RemoveBuildingsForLandblock,PhysicsDataCache.cs:472); retail rebuilds cell transforms from live cell pointers (eviction analogCEnvCell::release). - Change: add
PhysicsDataCache.RemoveCellsForLandblock(landblockId)(drop_cellStructentries whose prefix matches), call it fromPhysicsEngine.RemoveLandblock(:121-141) alongside the existing_landblocks/ShadowObjects/CellGraphclears, so the nextCacheCellStructre-bases the cell BSPWorldTransformagainst the current_liveCenter. Completes invariant #4 (all cached collision transforms rebase per apply). - Acceptance: build/test green; a teleport-OUT then re-stream of the same dungeon yields a fresh cell transform (add a targeted test mirroring the building re-base test). Interacts with #135/#138 — verify no re-stream ordering regression.
- Register: new row not needed (divergence eliminated, not introduced).
- Visual gate: teleport into a dungeon → out → back into the same dungeon; indoor walls collide correctly each time (no stale-offset clip).
Slice 5 — entry-restrictions hook + predicate wiring (D4 + W1) — conservative
- Retail oracle:
CEnvCell::find_env_collisions@0x0052c130(callscheck_entry_restrictionsfirst, pc:309576),CObjCell::check_entry_restrictions@0x0052b6d0; dispatch terms at pc:276861. - Change: add the
check_entry_restrictionsgate at the front of the EnvCell/LandCell env path (returns OK when norestriction_obj— a no-op in current dev content but structurally present); wire the realpvpExempt/missile_ignoreterms into the cyl-vs-BSP dispatch (TransitionTypes.cs:2629) instead of the hardcodedfalse. - Acceptance: build/test green; no behavior change in dev content (no locked cells, no PK/missiles). Conformance-test the dispatch against the door BSP-only case (must not regress A6.P7).
- Register: D4 gets a one-line "ported, inert until locked-cell content" note or no row if fully faithful; W1 wiring removes the "hardcoded false" smell.
- Visual gate: none required (no live trigger in M1.5) — rides the suite + the Slice-3 door gate.
4. Testing strategy
- Conformance tests (Core): Slice 2's
CSphere::intersects_spheregolden-value fixture is the load-bearing one (new physics math). Slices 3/5 reuse the door apparatus. - Replay harnesses (regression guard, every slice):
CellarUpTrajectoryReplayTests,HouseExitWalk*,CornerFlood*,Issue147ArwicBuildingsDumpTestsmust stay green — they pin the shadow-list/building/cell machinery this phase touches. - Live capture when a slice needs evidence:
ACDREAM_PROBE_BUILDING([entity-source]BSP-vs-Cylinder per object — the Slice-1 lens),ACDREAM_CAPTURE_RESOLVE(per-resolve JSONL), short targeted runs only. - Visual gates are the acceptance test for feel — Slices 1, 3, 4 each require the user's eyes (the only thing that genuinely requires stopping). Slices 2, 5 ride the suite.
5. Risks (each gated)
- Slice 1 changes scenery feel — objects with no DAT shape become passable. Retail-faithful by construction (loader verified faithful, §1), but visible; the Holtburg/open-world gate confirms the passable set matches retail.
- Slice 3 is the riskiest —
obstruction_etherealtouches the BSP solid-containment gate. A partial port (set-but-not-consumed, or cleared inconsistently) hardens or softens walls unpredictably. Mitigation: port set+clear+consume in one slice; door gate + "walls unchanged" check. - Slice 4 interacts with dungeon collapse/recenter (#135/#138) — eviction could surface a re-stream ordering bug currently masked by "never evict." Mitigation: teleport-OUT gate.
- Slice 5 wiring has no live M1.5 trigger — risk is regressing the A6.P7 BSP-only door dispatch. Mitigation: conformance-test the door case before/after.
6. Divergence-register bookkeeping (per CLAUDE.md — same-commit rule)
- Delete AP-2 (Slice 1), AD-7 (Slice 3) — divergences retired, rows removed in the landing commit.
- Keep AP-6 (analytic cylinder math vs retail CylSphere — unchanged this phase).
- Add a clarifying note (optional) that terrain pass-through on an unstreamed block is the #135/#138 streaming-gap (D7 refuted), not a collision divergence — so nobody re-files it.
7. Out of scope (filed, not touched)
- W2 —
BldPortalInfo.ExactMatchdecoded-but-unconsumed: a transit-feature, not collision-inclusion. - W4 —
CLandCellENTIRELY_WATER skip clause: revisit before deep-water content. - W5 — static-prune
VisibleCellIdsvs retaildo_not_loadstab_list: verify before it bites. - The full #145 cell-relative-frame port — the eventual "collision frames can never go stale by
construction" home; Slice 4's
RemoveCellsForLandblockis the interim faithful-enough invariant and is a clean delete when #145 lands. - D6 / D7 — refuted by the audit's adversarial pass; not deviations.
8. Definition of done
All five slices landed; AP-2 + AD-7 deleted from the register; conformance + replay suites green; the three feel-gates (Slice 1 scenery, Slice 3 door, Slice 4 dungeon re-entry) passed by the user. Collision-inclusion is verbatim with retail: an object collides iff retail would collide with it, decided by one DAT-only shape authority + one query predicate + four faithful channels + one anti-staleness invariant.