# 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`](../../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** (only `PARTICLE_EMITTER_PS 0x1000` diverts). "Registerable" ⇔ "has a DAT shape". - **Layer A — inclusion predicate (narrow phase).** `CPhysicsObj::FindObjCollisions@0x0050f050` decides per-query skip/dispatch via flag logic, then dispatches **one** shape branch (BSP-only iff `HAS_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`](../../../src/AcDream.Core/Physics/PhysicsDataCache.cs)). This spec closes the remaining gaps. --- ## 2. Target architecture (the "1 fix" mapped onto retail's four pieces) 1. **One DAT-only collision-shape authority.** `ShadowShapeBuilder` becomes the **single** place an object's collision shape is derived — from `PhysicsBSP` / `CylSpheres` / `Spheres` only. Every object-registration site (statics, scenery, weenies, building shells) funnels through it. The inline mesh-AABB synthesis is **deleted** from `GameWindow.cs`. Because there is exactly one shape site, the DAT-only rule cannot be violated per-site, and the W3 radius inconsistency (`2f` vs `1f` fallback) dies. *(Satisfies CLAUDE.md Structure Rule #1 — removes a ~150-line feature body from the 10k-line `GameWindow.cs` god-object.)* 2. **One query-time object predicate.** `CollisionExemption` (the skip gate) + the cyl/sphere/BSP dispatch form the object channel's Layer A, completed with the `ObstructionEthereal` state (D2/D5) and the PvP/missile dispatch terms wired (W1). 3. **Env / building / terrain stay distinct channels** (retail-faithful), each with its specific deviation fixed in place. 4. **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 symmetric `RemoveCellsForLandblock` (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 `ShadowShapeBuilder` the single object-shape entry point. **Delete** the mesh-AABB synthesis block at [`GameWindow.cs:7408-7565`](../../../src/AcDream.App/Rendering/GameWindow.cs) and the `IsPhantomGfxObjSource`/`isPhantomSetup` gates 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. Remove `ComputeVisualBounds` use *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 (`2f` vs `1f`) 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 test` green. 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 `Sphere` variant to `ShadowCollisionType` (`ShadowObjectRegistry.cs:537`) and a real sphere-vs-sweep primitive in `BSPQuery`; `ShadowShapeBuilder` emits `Sphere` (not coerced cylinder) for `Setup.Spheres`. Dispatch order matches retail: `HAS_PHYSICS_BSP` clear ⇒ CylSphere then Sphere. - **Acceptance:** new conformance fixture vs `CSphere::intersects_sphere` golden 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@0x0050f050` Gate-1 (pc:276782, requires `0x4 AND 0x10`), ETHEREAL-alone branch (pc:276795-276806, sets `obstruction_ethereal=1` and continues), the per-call clear in `CEnvCell::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*):** 1. add `ObstructionEthereal` field to `SpherePath`; 2. `CollisionExemption`: instant-skip only when `0x4 AND 0x10`; on `0x4`-alone set `ObstructionEthereal=true` and **continue** to shape test (remove the AD-7 `0x4`-alone return); 3. clear `ObstructionEthereal=0` at 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); 4. consume it in `BSPQuery`'s placement/solid-containment gate alongside the existing `InsertType.Placement` check. - **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 analog `CEnvCell::release`). - **Change:** add `PhysicsDataCache.RemoveCellsForLandblock(landblockId)` (drop `_cellStruct` entries whose prefix matches), call it from `PhysicsEngine.RemoveLandblock` (`:121-141`) alongside the existing `_landblocks`/`ShadowObjects`/`CellGraph` clears, so the next `CacheCellStruct` re-bases the cell BSP `WorldTransform` against 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` (calls `check_entry_restrictions` first, pc:309576), `CObjCell::check_entry_restrictions@0x0052b6d0`; dispatch terms at pc:276861. - **Change:** add the `check_entry_restrictions` gate at the front of the EnvCell/LandCell env path (returns OK when no `restriction_obj` — a no-op in current dev content but structurally present); wire the real `pvpExempt`/`missile_ignore` terms into the cyl-vs-BSP dispatch (`TransitionTypes.cs:2629`) instead of the hardcoded `false`. - **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_sphere` golden-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*`, `Issue147ArwicBuildingsDumpTests` must 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_ethereal` touches 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.ExactMatch` decoded-but-unconsumed: a *transit-feature*, not collision-inclusion. - **W4** — `CLandCell` ENTIRELY_WATER skip clause: revisit before deep-water content. - **W5** — static-prune `VisibleCellIds` vs retail `do_not_load` stab_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 `RemoveCellsForLandblock` is 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.