acdream/docs/research/2026-06-24-collision-inclusion-audit.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

22 KiB
Raw Blame History

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_list of every cell its sphere overlaps. No attribute is tested at registration (not STATIC, not ETHEREAL, not HIDDEN — only PARTICLE_EMITTER_PS 0x1000 diverts to a particle list). The only thing that decides "is this registerable" is does it have a shape (physics_bsp from DAT bit-0, or a CSetup cylsphere/sphere). Source: CObjCell::find_cell_list@0x0052b4e0, CPhysicsObj::add_shadows_to_cells@0x00514ae0.
  • Layer A (inclusion predicate / narrow phase)CPhysicsObj::FindObjCollisions@0x0050f050 decides 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 iff HAS_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:

  1. 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.
  2. The query predicate is incomplete — ETHEREAL-alone short-circuit (D2), no true sphere primitive (D3), PvP/missile terms hardcoded false (watch-item W1).
  3. 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_etherealfield 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)
D6 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.
D7 terrain "Terrain silently passes through when no landblock registered" REFUTED Retail does the sameLScape::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_restrictionsfind_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_restrictionszero 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/CSetup only; no shape ⇒ passable (CPhysicsPart::find_obj_collisions@0x0050D8D0 returns OK when physics_bsp==null; CGfxObj::Serialize@0x00534970 gates physics_bsp on flags bit-0; CPartArray::InitParts@0x00517F40).
  • acdream: for scenery (0x80000000 IDs) with no CylSphere/BSP, synthesizes a clamped [0.30,1.50]m cylinder from the world-space render-mesh AABB and registers it. GameWindow.cs:7421-7425 gate: !isPhantomSetup && !isPhantomGfxObj && !_isLandblockStab && (_isOutdoorMesh || (entityBsp==0 && entityCyl==0)). For scenery _isOutdoorMesh is always true (:7151); the #101 phantom gates only cover 0x01… GfxObjs (IsPhantomGfxObjSource, PhysicsDataCache.cs:399-403) and zero-radius Setups — a Setup-sourced scenery with Radius>0.0001 and no physics_bsp still 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 only PhysicsDataCache.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:78 skips on 0x4 alone. Documented shim (AD-7) because ACE Door.Open() broadcasts 0x0001000C (ETHEREAL only). The obstruction_ethereal set-and-continue path is unported.

D3 — No true sphere primitive (LOW)

  • Retail: HAS_PHYSICS_BSP_PS clear → CylSphere then Sphere via CSphere::intersects_sphere (276917) — a real sphere test.
  • acdream: ShadowShapeBuilder.cs:68-81 coerces Setup.Spheres → short cylinders (height = radius*2); ShadowCollisionType enum (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@0x0052c130 calls check_entry_restrictions@0x0052b6d0 first; returns COLLIDED_TS when an access-locked cell's restriction_obj rejects the mover.
  • acdream: TransitionTypes.cs:2062-2207 has 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_ethereal on every find_env_collisions call (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-wins ContainsKey with no eviction — contrast RemoveBuildingsForLandblock (:472, the #146 fix). PhysicsEngine.RemoveLandblock (:121-141) clears _landblocks/ShadowObjects/CellGraph but never cells. A dungeon streamed out→in after a _liveCenter recenter would hold a stale WorldTransform (off by (oldCenternewCenter)·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 inside BSPQuery.FindCollisions (BSPQuery.cs:1717, if (path.InsertType == Placement || obj.Ethereal)), not at the FindEnvCollisions call site. Identical functional result.
  • D7 (terrain pass-through): retail LScape::get_landcell@0x00505f10 returns null for an unloaded block and transitional_insert@0x0050b6f0 returns 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==0 filter (TransitionTypes.cs:2813): checked, faithful — equals retail's "no parts[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 assumes pvpExempt=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.ExactMatch decoded but unconsumed (BuildingPhysics.cs:74, CellTransit.CheckBuildingTransit): a transit-feature, not collision-inclusion — explicitly out of scope for this phase.
  • W3 — ShadowShapeBuilder default radius 2f (:97) vs GameWindow stab fallback 1f (:7215) when BoundingSphere is null — minor inconsistency.
  • W4 — CLandCell ENTIRELY_WATER skip clause (state 0x04 / 0x40) may be unimplemented in the terrain path — verify before deep-water content.
  • W5 — static-prune VisibleCellIds vs retail do_not_load stab_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-7565 mesh-AABB synthesized-cylinder fallback (D1).
  • CollisionExemption.cs:78 ETHEREAL-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-81 sphere→cylinder coercion → true Sphere shape + primitive (D3).
  • TransitionTypes.cs:2629 hardcoded pvpExempt/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_COLLISIONS in 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_sphere golden 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)

  1. Shape-source purity (D1) — delete mesh-AABB synthesis; the highest-value, most-isolated, retail-clarifying change. Visual gate: scenery + Holtburg.
  2. Sphere primitive (D3) — unblocks faithful shape dispatch; conformance-tested.
  3. ETHEREAL/obstruction_ethereal (D2+D5) — predicate + state field + door wire compat, landed together.
  4. Cell-transform rebase (D8) — or fold into the #145 cell-relative-frame phase.
  5. 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.