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>
22 KiB
Collision-inclusion audit — retail vs acdream ("1 fix for all collision") — 2026-06-24
Mode: report-first / brainstorm-gated (DO-NOT-RETRY collision area). No code written.
Method: 10 parallel named-retail readers (5 retail-model + 5 acdream-channel) → synthesis → adversarial verification of every claimed deviation against the actual cited code (the feedback_verify_subagent_claims_against_source discipline). Plus orchestrator first-hand reads of CollisionExemption.cs, EntityCollisionFlags.cs, PhysicsDataCache.cs, TransitionTypes.cs, GameWindow.cs:7388-7565, and the divergence register.
Branch: claude/thirsty-goldberg-51bb9b. Named-retail + references/{ACE,ACViewer,holtburger} + symbols.json live in the MAIN repo.
TL;DR — the unifying insight
Retail's collision model is two clean layers:
- Layer B (registration / broad phase) — every object with a DAT physics shape is written unconditionally into the per-cell
shadow_object_listof every cell its sphere overlaps. No attribute is tested at registration (not STATIC, not ETHEREAL, not HIDDEN — onlyPARTICLE_EMITTER_PS 0x1000diverts to a particle list). The only thing that decides "is this registerable" is does it have a shape (physics_bspfrom DAT bit-0, or aCSetupcylsphere/sphere). Source:CObjCell::find_cell_list@0x0052b4e0,CPhysicsObj::add_shadows_to_cells@0x00514ae0. - Layer A (inclusion predicate / narrow phase) —
CPhysicsObj::FindObjCollisions@0x0050f050decides per-query whether the registered object actually blocks, via a flag predicate (ETHEREAL+IGNORE, viewer-vs-creature, IGNORE_CREATURES, PvP pool), then dispatches one shape branch (BSP-only iffHAS_PHYSICS_BSP_PS 0x10000, else CylSphere-then-Sphere).
acdream already has this two-layer shape (per-cell ShadowObjectRegistry + query-time CollisionExemption). The user's intuition is correct, and the divergences are now narrow and enumerable — they are NOT a scattered mess of per-channel filters anymore (the #147 portal-count skip was the last of that family and is already deleted in 9743537). What remains:
- The shape-source rule is impure — acdream fabricates collision from the render-mesh AABB for scenery + outdoor meshes (D1, the prime deviation; retail never consults render bounds). This is the one place acdream over-includes vs retail.
- The query predicate is incomplete — ETHEREAL-alone short-circuit (D2), no true sphere primitive (D3), PvP/missile terms hardcoded false (watch-item W1).
- One cached collision transform can go stale — EnvCell cell-struct cache lacks the per-apply rebase that buildings got in #146 (D8). Plus two indoor-channel omissions that are latent today (D4 entry-restrictions, D5 obstruction_ethereal).
So "1 fix for all collision" = make the shape-source rule pure (DAT-only) + complete the query predicate + give every cached transform the same per-apply rebase. It is a unification of three things, not one line — but it collapses to one ObjectCollides(...) predicate + one registration rule + one rebase rule that every channel funnels through.
Verdict table (6 confirmed deviations, 2 refuted false-positives)
| ID | Sev | Layer | Title | Verdict | Register |
|---|---|---|---|---|---|
| D1 | HIGH | shape-source | Render-mesh-AABB → synthetic collision cylinder for scenery (#101 residual) | CONFIRMED (high) | extend AP-2 (currently cites only the Setup case at PhysicsDataCache.cs:96; the scenery 0x8… site at GameWindow.cs:7408 is uncovered) |
| D2 | MED | predicate | ETHEREAL-alone instant-skip; retail needs ETHEREAL and IGNORE_COLLISIONS | CONFIRMED (high) | AD-7 exists |
| D3 | LOW | shape-source | Setup Spheres coerced to short cylinders (no true sphere primitive) |
CONFIRMED (high) | NEW ROW needed |
| D4 | LOW | predicate | EnvCell path omits check_entry_restrictions (access-locked cells) |
CONFIRMED (high) | NEW ROW (no effect in dev content) |
| D5 | MED | predicate/state | EnvCell never clears obstruction_ethereal — field doesn't even exist in acdream SpherePath |
CONFIRMED (high) | NEW ROW (couples with D2) |
| D8 | MED | stale-frame | EnvCell cell-struct WorldTransform never evicted/rebased (asymmetric with buildings #146) |
CONFIRMED (med) | NEW ROW (latent; masked by single-block dungeon collapse) |
| — | dispatch | "EnvCell never calls placement_insert" |
REFUTED | acdream does branch — inside BSPQuery.FindCollisions (BSPQuery.cs:1717), not at the call site. Functionally identical to retail. No fix. |
|
| — | terrain | "Terrain silently passes through when no landblock registered" | REFUTED | Retail does the same — LScape::get_landcell@0x00505f10 returns null for an unloaded block, transitional_insert@0x0050b6f0 returns OK_TS on null cell. This is the #135/#138 streaming-gap free-fall, a streaming-foundation issue, NOT collision-inclusion. |
The retail model (the oracle table)
Layer A — the inclusion predicate (CPhysicsObj::FindObjCollisions@0x0050f050)
PhysicsState flags and whether each gates collision (verified against acclient.h + the decomp body, lines 276776-276996, cross-checked vs ACE PhysicsObj.cs:381-454):
| Flag | Value | Gates collision? | Mechanism |
|---|---|---|---|
ETHEREAL_PS |
0x4 | Only with 0x10 | Instant-skip (return OK_TS, no shape test) requires both 0x4 AND 0x10 (276782). 0x4 alone → set sphere_path.obstruction_ethereal=1 and still run shape test (276795-276806); downstream step-down passes through but contact is recorded. |
IGNORE_COLLISIONS_PS |
0x10 | with 0x4 | (above) |
HAS_PHYSICS_BSP_PS |
0x10000 | dispatch | Set (and not missile-ignored, not PvP-exempt ebp_1==0) → BSP-only via CPartArray::FindObjCollisions (276858-276961). Clear → CylSphere loop then Sphere branch. Mutually exclusive — never both. |
STATIC_PS |
0x1 | no (response only) | Decides hit is reported as collided_with_environment vs object collision (276969-276981); does not skip. |
REPORT_COLLISIONS_PS |
0x8 | no | Gates DoCollisionEnd/Begin callback delivery only (278614), not geometry. |
NODRAW_PS |
0x20 | no | Not read anywhere in the collision path. Render-only. (Confirms handoff hypothesis.) |
MISSILE_PS |
0x40 | no | Marks target isCreature=true for sphere dispatch; missile_ignore (274390) makes a missile mover skip missile targets. |
HIDDEN_PS |
0x4000 | indirect | set_hidden also sets 0x10 + clears 0x8 as a side-effect (282992,283029); but HIDDEN alone doesn't set ETHEREAL, so Gate-1 still needs 0x4. |
SCRIPTED_COLLISION_PS |
0x8000 | no | Never read in the physics collision path. |
FROZEN_PS |
0x1000000 | no | Gates update_object (sim skip) only (283955). |
Plus the non-flag gates: mover≠self, not-creature-when-viewer/IGNORE_CREATURES mover, PvP pool (both-players → skip unless target Impenetrable OR both PK OR both PKLite).
Layer B — registration / broad phase
shadow_object_list membership predicate (CObjCell::find_cell_list@0x0052b4e0):
- (1) object is NOT a pure particle emitter (
PARTICLE_EMITTER_PS 0x1000), (2) at least one collision sphere overlaps the cell volume (indoor:sphere_intersects_cell != OUTSIDE; outdoor: sphere center maps to the cell grid square + boundary neighbours). No other attribute tested at registration. A single object lands in multiple cells' lists (the doorway-door-in-both-cells answer that the A6.P4/BR-7 flood already ports).
The four collision channels (per-cell dispatch order: env → building → objects)
| Channel | Function | Inclusion rule |
|---|---|---|
| Terrain | CLandCell::find_env_collisions@0x00532f20 |
check_entry_restrictions → find_terrain_poly (always 2 triangles) → water-skip clause → validate_walkable. Always solid where the landblock is loaded. |
| EnvCell (indoor) | CEnvCell::find_env_collisions@0x0052c130 |
check_entry_restrictions → zero obstruction_ethereal → if structure->physics_bsp != null: find_collisions (or placement_insert for INITIAL_PLACEMENT). No building leg. |
| Building shell | CBuildingObj::find_building_collisions@0x006b5300 via CSortCell::find_collisions@0x005340a0 |
In CLandBlockInfo.buildings for an outdoor block AND makeBuilding succeeded AND origin landcell non-null. Portal count / portal list / model id NOT examined. BSP on part_array->parts[0]. (This is the #147 truth.) |
| Object shadows | CObjCell::find_obj_collisions@0x0052b750 → Layer A |
Per-cell shadow list iteration. |
Shape assignment (the rule D1 violates)
A CPhysicsObj gets a collision shape from DAT only: a part's CGfxObj.physics_bsp (deserialized by CGfxObj::Serialize@0x00534970 only when serialized-flags bit-0 / num_physics_polygons>0), or CSetup cylsphere/sphere arrays. CPartArray::InitParts@0x00517F40 builds parts from the Setup list only. When none exist, CPhysicsPart::find_obj_collisions@0x0050D8D0 returns OK (passable) — no synthesis from drawing geometry. The render-mesh gfx_bound_box is used for cell-registration + frustum culling, never collision.
The acdream model (per-channel de-facto predicate)
| Channel | acdream predicate (de-facto) | Code | Verdict vs retail |
|---|---|---|---|
| Terrain | foot-XY inside a registered landblock's [0,192)² AND outdoor cell. null landblock → pass through. |
PhysicsEngine.SampleTerrainWalkable :261; TransitionTypes.cs:2229-2247 |
Faithful (D7 refuted — retail passes through unloaded blocks too). |
| Building | outdoor cell AND GetBuilding != null AND ModelId != 0 AND GetGfxObj(ModelId).BSP.Root != null |
TransitionTypes.cs:2805-2819 |
Faithful post-#146/#147. ModelId==0 → inert ≈ retail "no parts[0]". Per-apply rebase via RemoveBuildingsForLandblock (#146). |
| Shadow objects | source hi-byte 0x01/0x02 AND not building shell AND not phantom Setup/GfxObj AND ≥1 non-zero-radius shape OR scenery mesh-AABB fallback | GameWindow.cs:7185-7565; ShadowObjectRegistry.cs; ShadowShapeBuilder.cs |
D1 (mesh-AABB), D3 (sphere-as-cyl) + watch-items. |
| EnvCell | cell in BFS shadow-set AND CellPhysics.BSP.Root != null |
TransitionTypes.cs:2062-2207; CellTransit.cs |
D4, D5, D8. |
| Query exemption | CollisionExemption.ShouldSkip — ETHEREAL(0x4) alone, viewer/creature, IGNORE_CREATURES, PvP pool |
CollisionExemption.cs:59 |
Faithful except D2 (ETHEREAL-alone). PvP block is a clean port (cross-checked vs ACE). |
Confirmed deviations — detail
D1 — Render-mesh-AABB synthetic collision cylinder (HIGH, the prime deviation)
- Retail: shape from DAT
physics_bsp/CSetuponly; no shape ⇒ passable (CPhysicsPart::find_obj_collisions@0x0050D8D0returns OK whenphysics_bsp==null;CGfxObj::Serialize@0x00534970gatesphysics_bspon flags bit-0;CPartArray::InitParts@0x00517F40). - acdream: for scenery (
0x80000000IDs) with no CylSphere/BSP, synthesizes a clamped[0.30,1.50]mcylinder from the world-space render-mesh AABB and registers it.GameWindow.cs:7421-7425gate:!isPhantomSetup && !isPhantomGfxObj && !_isLandblockStab && (_isOutdoorMesh || (entityBsp==0 && entityCyl==0)). For scenery_isOutdoorMeshis always true (:7151); the #101 phantom gates only cover0x01…GfxObjs (IsPhantomGfxObjSource,PhysicsDataCache.cs:399-403) and zero-radius Setups — a Setup-sourced scenery withRadius>0.0001and nophysics_bspstill gets the synthesized cylinder. - Why it exists: the comment admits a gameplay motive — "catches trees whose BSP is only on the canopy (player walks under)". This is exactly the attribute/shape category-error the user wants gone: acdream invents collision retail doesn't have.
- Register: AP-2 must be widened to cover
GameWindow.cs:7408(it currently cites onlyPhysicsDataCache.cs:96).
D2 — ETHEREAL-alone instant-skip (MED)
- Retail: Gate-1 needs both 0x4 AND 0x10 (
276782); 0x4 alone →obstruction_ethereal=1+ still run shape test (276795-276806), consumed by BSP solid-containment gates (321692,323742,324573). - acdream:
CollisionExemption.cs:78skips on 0x4 alone. Documented shim (AD-7) because ACEDoor.Open()broadcasts0x0001000C(ETHEREAL only). Theobstruction_etherealset-and-continue path is unported.
D3 — No true sphere primitive (LOW)
- Retail:
HAS_PHYSICS_BSP_PSclear → CylSphere then Sphere viaCSphere::intersects_sphere(276917) — a real sphere test. - acdream:
ShadowShapeBuilder.cs:68-81coercesSetup.Spheres→ short cylinders (height = radius*2);ShadowCollisionTypeenum (ShadowObjectRegistry.cs:537) has only{BSP, Cylinder}— no Sphere. New register row.
D4 — EnvCell omits check_entry_restrictions (LOW, no current effect)
- Retail:
CEnvCell::find_env_collisions@0x0052c130callscheck_entry_restrictions@0x0052b6d0first; returns COLLIDED_TS when an access-locked cell'srestriction_objrejects the mover. - acdream:
TransitionTypes.cs:2062-2207has no such gate. No access-locked cells in dev content ⇒ no observable effect, but structurally absent. New register row.
D5 — obstruction_ethereal never cleared — field absent entirely (MED)
- Retail: zeros
sphere_path.obstruction_etherealon everyfind_env_collisionscall (0x0052c144) before BSP dispatch. - acdream: the field does not exist in
SpherePath(TransitionTypes.cs:315-435) — so the retail BSP solid-weakening gate keyed on it is simply not modelled. Couples with D2 (port the set/clear/consume sites together). New register row.
D8 — EnvCell cell-struct transform never evicted/rebased (MED, latent)
- Retail: no persistent client-side streaming-relative transform cache; cell geometry rebuilt from live cell pointers (eviction analog:
CEnvCell::release). - acdream:
PhysicsDataCache._cellStruct(:179) is first-winsContainsKeywith no eviction — contrastRemoveBuildingsForLandblock(:472, the #146 fix).PhysicsEngine.RemoveLandblock(:121-141) clears_landblocks/ShadowObjects/CellGraphbut never cells. A dungeon streamed out→in after a_liveCenterrecenter would hold a staleWorldTransform(off by(oldCenter−newCenter)·192m). Masked today by the single-block collapse+recenter dungeon pattern. New register row. This is the #146 stale-frame class the handoff predicted "almost certainly more of these" — and there is exactly one more.
Refuted (do NOT chase — anti-rabbit-hole)
- D6 (placement_insert): acdream does route INITIAL_PLACEMENT to
PlacementInsert— the branch is insideBSPQuery.FindCollisions(BSPQuery.cs:1717,if (path.InsertType == Placement || obj.Ethereal)), not at theFindEnvCollisionscall site. Identical functional result. - D7 (terrain pass-through): retail
LScape::get_landcell@0x00505f10returns null for an unloaded block andtransitional_insert@0x0050b6f0returns OK_TS on a null cell — retail passes through unloaded terrain too. The free-fall is the streaming-gap (#135/#138), owned by the cell-relative-frame work, not collision-inclusion. - Building
ModelId==0filter (TransitionTypes.cs:2813): checked, faithful — equals retail's "noparts[0]⇒ no shell BSP". - #147 portal-count skip: already deleted (
9743537). The last scattered attribute-as-filter is gone. - Registration-time attribute filters: acdream's registration is already overlap-based with filtering deferred to query time (
CollisionExemption) — architecturally already retail-shaped. The remaining divergences are shape-source + predicate-detail + one stale cache, not a registration-filter mess.
Watch-items (sub-deviations not promoted — note, don't necessarily fix now)
- W1 — PvP/missile dispatch terms hardcoded false (
TransitionTypes.cs:2629): the cyl-vs-BSP dispatch assumespvpExempt=false && missile_ignore=false. Harmless in M1.5 (no PK/missiles), but a missile/PK mover would wrongly take BSP-only against all BSP targets. Belongs in the unified predicate when PK/missiles ship. - W2 —
BldPortalInfo.ExactMatchdecoded but unconsumed (BuildingPhysics.cs:74,CellTransit.CheckBuildingTransit): a transit-feature, not collision-inclusion — explicitly out of scope for this phase. - W3 —
ShadowShapeBuilderdefault radius2f(:97) vsGameWindowstab fallback1f(:7215) whenBoundingSphereis null — minor inconsistency. - W4 —
CLandCellENTIRELY_WATER skip clause (state 0x04 / 0x40) may be unimplemented in the terrain path — verify before deep-water content. - W5 — static-prune
VisibleCellIdsvs retaildo_not_loadstab_list (CellTransit.cs:549-562): approximation; verify against the CEnvCell dat stab_list field.
The unified target — "1 fix for all collision" (PROPOSAL — brainstorm-gate before coding)
One ObjectCollides(targetState, moverState, targetFlags, shape) predicate + one registration rule + one rebase rule, that every channel funnels through:
(a) Pure shape resolution (one helper, DAT-only). Shape comes ONLY from physics_bsp (DAT bit-0) or CSetup cylsphere/sphere; an object with no DAT shape registers no shape and is passable. Delete the mesh-AABB synthesis (D1).
(b) The query predicate matching FindObjCollisions@0x0050f050: instant-skip iff (0x4 AND 0x10); 0x4-alone → set ObstructionEthereal + continue (D2/D5); viewer/creature; IGNORE_CREATURES/creature; PvP pool; then dispatch BSP-only iff HAS_PHYSICS_BSP_PS && !pvpExempt && !missileIgnore else CylSphere→Sphere (D3). Wire the PvP/missile terms (W1).
(c) One overlap-based per-cell registration (already mostly present via ShadowObjectRegistry + the A6.P4/BR-7 flood) — no attribute filter at registration; everything funnels through it (statics, scenery, weenies, building shell, cell BSP).
(d) One per-apply rebase — every cached collision transform (cells, buildings, shadow positions) rebased to the current streaming center on each per-landblock apply via a symmetric Remove*ForLandblock pre-clear. Add RemoveCellsForLandblock (D8). (Or fold into the #145 cell-relative-frame port so collision frames can never go stale — the handoff's preferred long-term home.)
Deletions
GameWindow.cs:7408-7565mesh-AABB synthesized-cylinder fallback (D1).CollisionExemption.cs:78ETHEREAL-alone instant-skip → replace with both-bits Gate-1 +ObstructionEthereal-set-and-continue (D2); move ACE door-open compat to the wire/adaptation layer, not the collision predicate.ShadowShapeBuilder.cs:68-81sphere→cylinder coercion → true Sphere shape + primitive (D3).TransitionTypes.cs:2629hardcodedpvpExempt/missileIgnore=false→ wire real terms (W1).
Risks (each needs a user visual gate)
- D1 removal makes shape-less scenery walk-through — retail-faithful, but a visible behavior change (trees you currently bump may become passable; trees with real canopy-only BSP regain retail behavior). Highest-risk item; gate carefully.
- D2 re-solidifies ACE-opened doors unless the door-open wire path sends
ETHEREAL|IGNORE_COLLISIONSin the same change — must land together or doors become impassable. - D2/D5 obstruction_ethereal touches BSP solid-containment gates — port set+clear+consume together; a partial port hardens/weakens walls unpredictably.
- W1 PvP/missile wiring has no live M1.5 test path — risk of regressing the A6.P7 BSP-only door dispatch; gate + conformance-test the door case.
- D3 sphere primitive is new physics code — conformance-test vs
CSphere::intersects_spheregolden values, not eyeballed. - D8 eviction interacts with #135/#138 dungeon collapse/recenter — adding eviction could surface a re-stream ordering bug currently masked by "never evict"; verify against teleport-OUT.
Suggested slicing (for the brainstorm)
- Shape-source purity (D1) — delete mesh-AABB synthesis; the highest-value, most-isolated, retail-clarifying change. Visual gate: scenery + Holtburg.
- Sphere primitive (D3) — unblocks faithful shape dispatch; conformance-tested.
- ETHEREAL/obstruction_ethereal (D2+D5) — predicate + state field + door wire compat, landed together.
- Cell-transform rebase (D8) — or fold into the #145 cell-relative-frame phase.
- Predicate wiring + entry-restrictions (W1+D4) — lowest urgency (no M1.5 effect); wire as conservative no-ops now, activate when PK/missiles/locked-cells ship.
Divergence-register bookkeeping required
- Widen AP-2 to cite
GameWindow.cs:7408(scenery mesh-AABB), not only the Setup case (D1). - AD-7 already covers D2 — keep; retire it when the obstruction_ethereal port lands.
- AP-6 already covers the analytic-cylinder-vs-retail-CylSphere dispatch — relevant to D3/W1.
- New rows: D3 (sphere-as-cylinder), D4 (entry-restrictions omitted), D5 (obstruction_ethereal absent from SpherePath), D8 (cell-struct transform never evicted). Optional: a terrain-pass-through note for the D7 streaming-gap (clarify it's #135/#138, not a collision bug) so nobody re-files it.
Apparatus for the implementation phase (reuse, don't rebuild)
ACDREAM_PROBE_BUILDING ([entity-source] BSP-vs-Cylinder classification per static — directly shows which objects got the D1 synthetic cylinder), ACDREAM_CAPTURE_RESOLVE, Issue147ArwicBuildingsDumpTests (dat-dump pattern), the CellarUp*/HouseExitWalk*/CornerFlood* replay harnesses, and a new CSphere::intersects_sphere conformance fixture for D3.
Generated by a 19-agent verified workflow (wf_b2f8da74-9fa): 10 readers → synthesis → 8 adversarial verifiers. 6/8 candidate deviations survived adversarial verification; 2 were refuted (D6, D7) with the retail evidence corrected.