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

189 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `Sphere`s 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) |
| ~~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 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`/`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.*