docs(physics): collision-inclusion audit + unified verbatim-collision design

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>
This commit is contained in:
Erik 2026-06-24 18:43:13 +02:00
parent bc4f71b932
commit 2d9e0cf424
2 changed files with 412 additions and 0 deletions

View file

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