acdream/docs/superpowers/specs/2026-06-24-unified-collision-inclusion-design.md
Erik 2d9e0cf424 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>
2026-06-24 18:43:13 +02:00

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 (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). 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 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 riskiestobstruction_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)

  • W2BldPortalInfo.ExactMatch decoded-but-unconsumed: a transit-feature, not collision-inclusion.
  • W4CLandCell 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.