# A6.P7 — Retail dispatch investigation for door cyl + slab interaction **Date:** 2026-05-25 PM **Mode:** Report-only (per `/investigate` skill). No code edits. **Predecessor:** [`2026-05-25-a6-door-cyl-investigation-handoff.md`](2026-05-25-a6-door-cyl-investigation-handoff.md) --- ## TL;DR — the smoking gun **Retail's `CPhysicsObj::FindObjCollisions` dispatches BINARILY between "BSP-only" and "cyl + sphere" — never both.** The selector is the state bit `HAS_PHYSICS_BSP_PS = 0x10000` (named verbatim in the retail header). For a closed cottage door + walking player: - Door state `0x10008` has `HAS_PHYSICS_BSP_PS` set. - Player isn't a missile. - Player isn't a PvP-eligible target of the door. - → Retail goes to the **BSP-only branch**. **The cyl is never tested.** Acdream tests both because our dispatch iterates per `ShadowEntry` (cyl and BSP are separate entries). The residual phantom slide at NE/SE headings is the predictable consequence: the cyl's radial normal fires first, drives the slide tangent into the slab face, slab blocks in a downstream sub-tick, net out=in. The fix is **~15 LOC at the per-entry test site**, reading `obj.State & 0x10000u` (which is already populated on every `ShadowEntry` from ACE's `spawn.PhysicsState`). It is **NOT** an architectural restructuring of `ShadowObjectRegistry`. The handoff's "Option 2 = large change" assessment was wrong — Option 2 is the right answer, but its scope is dramatically smaller than the handoff feared. --- ## Question 1 — What is `state & 0x10000`? Which branch fires? **Named flag:** `HAS_PHYSICS_BSP_PS = 0x10000` — [`docs/research/named-retail/acclient.h:2833`](research/named-retail/acclient.h:2833). The full retail `PhysicsState` enum lives at lines 2815-2843. The flags implicated by the dispatch: | Bit | Name | Meaning | |---|---|---| | 0x4 | `ETHEREAL_PS` | Non-solid; passes through other objects | | 0x10 | `IGNORE_COLLISIONS_PS` | Skips collision processing entirely | | 0x40 | `MISSILE_PS` | Object is a projectile / arrow / spell in flight | | 0x10000 | `HAS_PHYSICS_BSP_PS` | Object exposes a per-Setup BSP collision mesh | **Branch logic from [`acclient_2013_pseudo_c.txt:276861`](research/named-retail/acclient_2013_pseudo_c.txt):** ```c if (((this->state & 0x10000) == 0 || ebp_1 != 0) || eax_12 != 0) { // CYL + SPHERE path (lines 276863-276953): // iterate part_array's CylSpheres → CCylSphere::intersects_sphere // fall through label_50f21d: // iterate part_array's Spheres → CSphere::intersects_sphere // (BSP is NEVER tested in this branch) } else { // BSP path (lines 276956-276985): state_3 = CPartArray::FindObjCollisions(part_array, transition); // (cyl + sphere are NEVER iterated in this branch) } ``` **What `ebp_1` and `eax_12` are:** - `ebp_1` is set at lines 276808-276841. It's non-null **only when** THIS object's weenie is a player AND the moving transition's ObjectInfo has the IsPlayer flag AND no PvP exclusion (IsPK match, IsPKLite match, IsImpenetrable). Effectively: "I am a player and the incoming mover is also a player I can collide with." - `eax_12` is `OBJECTINFO::missile_ignore(transition, this)` — [`acclient_2013_pseudo_c.txt:274385`](research/named-retail/acclient_2013_pseudo_c.txt:274385). Returns non-zero when the moving object is a missile that should ignore this target. For a walking player vs door: returns 0. **For our scenario (player walking vs closed door):** - `state & 0x10000 == 0`: FALSE (door has the bit set). - `ebp_1 != 0`: FALSE (door is not a player target). - `eax_12 != 0`: FALSE (walking isn't a missile). - Condition is FALSE → **ELSE branch fires → BSP-only path.** The retail client **never calls `CCylSphere::intersects_sphere` on the door's foot cylinder** when a non-missile, non-PvP mover walks into it. **ACE cross-reference confirms the truth table exactly.** [`references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:412-450`](../../references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs): ```csharp if (!State.HasFlag(PhysicsState.HasPhysicsBSP) || missileIgnore || exemption) { // cyl-then-sphere iteration } else if (PartArray != null) { var collided = PartArray.FindObjCollisions(transition); // BSP path // ... } ``` ACE names the flag `HasPhysicsBSP`; the local variables are `missileIgnore` (retail's `eax_12`) and `exemption` (retail's `ebp_1`). The structure is identical bar a `// TODO: reverse this check to make it more readable` comment at [`PhysicsObj.cs:401`](../../references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:401) confirming ACE faithfully transcribed the negated predicate without adding interpretation. **Verdict on Q1: the cyl is not tested in retail for our case. Bit 0x10000 means "this object has a BSP — use it exclusively, do not test the cyl/sphere proxies".** --- ## Question 2 — Does retail's cyl actually fire `collides_with_sphere`? **Answer derivable from Q1 without running cdb: NO.** The retail dispatch unambiguously routes a closed-door + walking-player call to `CPartArray::FindObjCollisions` (the BSP path). The function `CCylSphere::collides_with_sphere` is reached only via the cyl-and-sphere path; that path is dead code for our scenario. A cdb trace would confirm zero hits on `CCylSphere::collides_with_sphere` for our scenario — but the decomp + ACE agreement is sufficient evidence to skip the trace. The branch condition is fully resolved by inspection. If we wanted defensive verification (recommended only if a fix attempt fails on first land), the live-trace recipe is: ``` bp acclient!CCylSphere::collides_with_sphere "r $t0=@$t0+1; gc" bp acclient!CPartArray::FindObjCollisions "r $t1=@$t1+1; gc" ``` Walk into the cottage door from outside for ~10 seconds. Expected: `@$t0 == 0` (cyl never tested), `@$t1` non-zero. This would settle the question definitively, but is not blocking the fix. --- ## Question 3 — Compare our ShadowShapeBuilder vs retail's Setup parsing **Retail STORES both cyl and BSP** for a door whose Setup has both. The cyl + sphere primitives live in `CPartArray::cylspheres` / `CPartArray::spheres`, the BSP is per-Part. Retail does not filter at the storage layer; it filters at the **dispatch** layer via the `HAS_PHYSICS_BSP_PS` flag. **Our `ShadowShapeBuilder.FromSetup`** at [`src/AcDream.Core/Physics/ShadowShapeBuilder.cs:41-110`](../../src/AcDream.Core/Physics/ShadowShapeBuilder.cs) does the same — emits both a Cylinder shape and per-Part BSP shapes for Setup `0x020019FF`. **This is correct.** The bug isn't in registration; it's in dispatch. **Where we diverge:** | Step | Retail | Acdream | |---|---|---| | Storage | One `CPartArray` per `CPhysicsObj`; cyls + spheres + BSP parts all stored | Flat `ShadowEntry` rows in `_cells[cellId]`; one row per shape, no per-entity grouping at the cell layer | | Dispatch trigger | `CPhysicsObj::FindObjCollisions` called once per shadow object (per-cell iteration) | `Transition.FindObjCollisions` iterates every `ShadowEntry` in `nearbyObjs` | | Cyl-vs-BSP branch | Binary on `state & 0x10000` | None — every shape is tested | | Effect on door | Only BSP tested → clean slab-normal slide | Cyl tested first → radial-normal drives slide into slab | **Critical observation:** The retail state bit is already on every acdream `ShadowEntry.State` (uint field), populated at [`GameWindow.cs:3156`](../../src/AcDream.App/Rendering/GameWindow.cs:3156) from `spawn.PhysicsState ?? 0u` — ACE delivers it on the wire. Confirmed via direct check: the door test fixtures ([`DoorBugTrajectoryReplayTests.cs:61`](../../tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs:61), [`DoorCollisionApparatusTests.cs:371`](../../tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs:371)) all seed the door with `0x10008u` (= `STATIC_PS | REPORT_COLLISIONS_PS | HAS_PHYSICS_BSP_PS`). The bit is available — we just don't read it. --- ## Mapping to the three fix options | Option | Retail-faithful? | Verdict | |---|---|---| | **#1 — BSP-first per-entity test order** | NO. Retail isn't "BSP-first"; it's "BSP-only when 0x10000 set." Per-entity ordering would also test the cyl for tree trunks (no BSP) which is correct — but would still test the cyl for doors, which retail doesn't. | Reject. | | **#2 — Port retail's per-physobj dispatch** | **YES.** This is exactly what retail does. The handoff's scoping ("touches many files; large change") was based on a misread of what Option 2 requires — it does NOT require restructuring `ShadowObjectRegistry` to group shapes by entity. The retail check is per-shape on a state flag already present. | **RECOMMENDED.** ~15 LOC at the per-entry dispatch site. | | **#3 — Door-cyl-as-informational (skip cyl registration when entity has BSP)** | NO. Retail STORES both shapes in `CPartArray` — registration includes both. Filtering at registration would diverge from retail's data model and risk breaking missile / PvP paths that need the cyl. | Reject. | The handoff's option-2 worry about "restructure `ShadowObjectRegistry` to group shapes by entity" is overengineered. The retail check is local to each shape's `ShadowEntry.State`: ```text For each ShadowEntry obj in nearbyObjs: if obj is BSP and (obj.State & HAS_PHYSICS_BSP_PS) is unset, skip (impossible — BSP entries on entities WITH 0x10000 don't need a check; we just need to ensure they DO fire) if obj is Cylinder/Sphere and (obj.State & HAS_PHYSICS_BSP_PS) is SET and not pvp-target and not missile-ignored, skip ``` Effectively: **suppress cyl/sphere tests when the entity has BSP.** Implemented as a single `continue` guard inside the existing loop at [`TransitionTypes.cs:2313`](../../src/AcDream.Core/Physics/TransitionTypes.cs:2313). No data-structure change. No grouping pass. No new fields. --- ## Recommended next step **Approve the implementation of a retail-binary dispatch** at the per-entry site in `Transition.FindObjCollisions`. The fix has these properties: 1. **Site:** [`src/AcDream.Core/Physics/TransitionTypes.cs:2313`](../../src/AcDream.Core/Physics/TransitionTypes.cs:2313) (the `if (obj.CollisionType == ShadowCollisionType.BSP) ... else ...` dispatch). 2. **Guard:** add a continue at the cyl/sphere branch when `(obj.State & HasPhysicsBspPs) != 0 && !isPvpTarget && !missileIgnore`. For M1.5 polish we can treat both `isPvpTarget` and `missileIgnore` as `false` (no PK, no missiles in scope) and add `// TODO: wire when PK / missiles ship` comments. The simplified guard is `(obj.State & 0x10000u) != 0`. 3. **Companion constant:** add `HasPhysicsBsp = 0x10000u` to `PhysicsStateFlags` ([`PhysicsBody.cs:25-43`](../../src/AcDream.Core/Physics/PhysicsBody.cs:25)) — it's currently absent. Naming matches both retail (`HAS_PHYSICS_BSP_PS`) and ACE (`HasPhysicsBSP`). 4. **Existing tests that would change outcome under the fix:** - [`DoorCollisionApparatusTests.Apparatus_Grounded_50cmOffCenter_FrontApproach_DocumentsBug`](../../tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs:213) is in "documents the bug" form — its header comment at lines 285-298 explicitly says "When the fix lands, flip this to `Assert.True(blocked)`." Fix lands → invert assertion in same commit. - Apparatus dead-center + back-approach tests — should remain PASS (BSP still fires). - `DoorBugTrajectoryReplayTests` LiveCompare tests — should remain PASS (BSP-only behavior is closer to live capture). - `CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap` — unrelated path (#98 cottage-floor cap). Unaffected. - `BSPQueryTests.FindCollisions_Path5_*` — unrelated; tests `BSPQuery` internals not dispatch. PASS. - `CellTransitTests.A6P5_*` — unrelated. PASS. 5. **Risks:** - **Foot-slipping for small entities on the door cyl.** Retail doesn't have this concern because retail's cyl isn't tested on the door for the standard mover either — so we won't regress anything that retail does. If a future fix needs cyl-vs-cyl for a small dynamic entity (e.g. a chicken bumping the door), that's a separate problem solved per `MISSILE_PS` / `ebp_1` rules, which ours already approximate via `CollisionExemption`. - **Other entities with `0x10000`.** Cottage walls (the static landblock GfxObj `0xA9B47900`) likely have `HAS_PHYSICS_BSP_PS` and only register BSP shapes (no cyl) — fix is a no-op there. NPCs and players have no BSP, no `0x10000`, so the cyl path continues firing for them — desired. - **Verification:** run the existing test list from the handoff (14 tests) post-fix; rerun live launch with all three probes; expect zero `[cyl-test] obj=0x000F4245` lines in the log. --- ## Verification plan (post-fix) When the fix lands, a single live launch + 14-test green list is sufficient verification. The `door-a6p6-v2.utf8.log` showed: - 117 `hit=yes obj=0x000F4245` resolves - 350 `[cyl-test] result=Slid` (across all entities) - 12 phantom `cn=(0.86, 0.51, 0)` resolves attributed to the door Post-fix expectation in an equivalent capture: - Door cyl-test count attributed to `obj=0x000F4245`: **0** - Door BSP `[bsp-test]` calls: unchanged or slightly higher (no cyl short-circuit) - `cn=(0.86, 0.51, 0)` phantom on the door: **0** - Visual confirmation: smooth slide along door slab face from NE/SE approach. --- ## What this is NOT - This is **NOT** a recommendation to restructure `ShadowObjectRegistry`. The flat per-cell list is fine. The retail check is per-shape, not per-entity. - This is **NOT** an Option 1 ("BSP-first ordering") fix. Retail does binary selection, not reordering. - This is **NOT** an Option 3 ("don't register cyl") fix. Retail registers both shapes. - This is **NOT** related to A6.P6's `CCylSphere::step_sphere_up` port (commit `3d4e63f`). That port is correct — it just doesn't fire for the door because the cyl is never reached. A6.P6 remains useful for non-door cylinders (tree trunks, rock pillars). - This is **NOT** related to the cdb workflow being insufficient — we could trace it for confirmation but the decomp + ACE agreement makes inspection sufficient. - **The cottage-floor cap (#98) is unrelated.** This bug is in entity collision dispatch; #98 is in cell BSP / GfxObj polygon evaluation. --- ## Citations | Source | Line(s) | What | |---|---|---| | `docs/research/named-retail/acclient.h` | 2815-2843 | `enum PhysicsState` — `HAS_PHYSICS_BSP_PS = 0x10000` at 2833 | | `docs/research/named-retail/acclient_2013_pseudo_c.txt` | 276776-276996 | `CPhysicsObj::FindObjCollisions` | | `docs/research/named-retail/acclient_2013_pseudo_c.txt` | 276861 | Binary dispatch branch | | `docs/research/named-retail/acclient_2013_pseudo_c.txt` | 276808-276841 | `ebp_1` (PvP-target-player flag) setup | | `docs/research/named-retail/acclient_2013_pseudo_c.txt` | 274385-274410 | `OBJECTINFO::missile_ignore` | | `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` | 381-454 | ACE's `FindObjCollisions` | | `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` | 412-450 | ACE's binary dispatch (cleaner names) | | `references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs` | 14, 24 | ACE's `Missile = 0x40` + `HasPhysicsBSP = 0x10000` | | `src/AcDream.Core/Physics/TransitionTypes.cs` | 2189-2521 | Our `FindObjCollisions` | | `src/AcDream.Core/Physics/TransitionTypes.cs` | 2313 | Our per-shape dispatch site | | `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | 41-110 | Our `FromSetup` (emits both shapes — correct) | | `src/AcDream.App/Rendering/GameWindow.cs` | 3156 | Where `spawn.PhysicsState` lands on `ShadowEntry.State` | | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` | 587 | `ShadowEntry.State : uint` field | | `src/AcDream.Core/Physics/PhysicsBody.cs` | 25-43 | `PhysicsStateFlags` (currently missing `HasPhysicsBsp`) | | `tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs` | 213, 285-298 | The "documents the bug" fixture; flip-assertion guidance |