diff --git a/docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md b/docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md new file mode 100644 index 0000000..01cd202 --- /dev/null +++ b/docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md @@ -0,0 +1,319 @@ +# 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 | diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs index 753db36..67882f0 100644 --- a/src/AcDream.Core/Physics/PhysicsBody.cs +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -32,6 +32,19 @@ public enum PhysicsStateFlags : uint Gravity = 0x00000400, // bit 10 — apply downward gravity Hidden = 0x00001000, /// + /// A6.P7 (2026-05-25): retail HAS_PHYSICS_BSP_PS bit + /// (acclient.h:2833). When set, the entity exposes a per-Setup + /// BSP collision mesh; retail's + /// CPhysicsObj::FindObjCollisions at + /// acclient_2013_pseudo_c.txt:276861 dispatches the entity's + /// collision queries to the BSP path EXCLUSIVELY for non-PvP, + /// non-missile movers — the foot cylinder and per-Setup spheres + /// are NEVER tested in this case. Closed cottage doors have + /// state 0x10008 (STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP). + /// ACE name: PhysicsState.HasPhysicsBSP. + /// + HasPhysicsBsp = 0x00010000, // bit 16 — retail HAS_PHYSICS_BSP_PS + /// /// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834). /// When set, wall-collisions zero the velocity instead of reflecting. /// Used by spell projectiles and missiles that should embed/explode on diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index f514046..eb93665 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -586,6 +586,58 @@ public sealed class Transition private static bool DumpEdgeSlideEnabled => Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1"; + // ----------------------------------------------------------------------- + // A6.P7 (2026-05-25) — retail-binary dispatch rule + // ----------------------------------------------------------------------- + + /// + /// A6.P7 retail-binary dispatch predicate. Returns true when an + /// entity's collision queries should go to its BSP exclusively, + /// skipping the cyl/sphere shapes. + /// + /// + /// Mirrors the dispatch branch in retail's + /// CPhysicsObj::FindObjCollisions at + /// docs/research/named-retail/acclient_2013_pseudo_c.txt:276861: + /// + /// if (((state & 0x10000) == 0 || ebp_1 != 0) || eax_12 != 0) + /// // cyl + sphere iteration + /// else + /// // BSP-only via CPartArray::FindObjCollisions + /// + /// where ebp_1 is the PvP-target-player flag (lines 276808- + /// 276841) and eax_12 is the OBJECTINFO::missile_ignore + /// result (line 274385). The flag is named + /// HAS_PHYSICS_BSP_PS = 0x10000 in acclient.h:2833 and + /// PhysicsState.HasPhysicsBSP in ACE + /// (references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:24). + /// + /// + /// + /// M1.5 scope (walking-vs-static, no PK, no missiles) treats both + /// ebp_1 and eax_12 as false. The predicate + /// reduces to (state & HAS_PHYSICS_BSP_PS) != 0. When + /// PK ships (M2+ phase) and missiles ship (F.3), wire the + /// PvP-exemption and missile_ignore checks through as additional + /// parameters following retail's + /// references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:412 + /// dispatch shape. + /// + /// + /// + /// Investigation: + /// docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md. + /// + /// + /// The collision target entity's raw + /// PhysicsState value (as stored on + /// ShadowEntry.State). + /// True when retail would dispatch BSP-only — i.e. when + /// the entity has HAS_PHYSICS_BSP_PS set; cyl/sphere shapes + /// must be skipped at the per-entry dispatch site. + public static bool BspOnlyDispatch(uint entityState) + => (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0; + // ----------------------------------------------------------------------- // Public entry point // ----------------------------------------------------------------------- @@ -2383,6 +2435,36 @@ public sealed class Transition // ACE: Sphere.IntersectsSphere handles CylSphere objects via // the same 6-path dispatcher. For now we keep the swept-sphere // cylinder test which matches the retail CylSphere behavior. + + // A6.P7 (2026-05-25) — retail-binary dispatch. Retail's + // CPhysicsObj::FindObjCollisions at + // acclient_2013_pseudo_c.txt:276861 dispatches BSP-only + // when HAS_PHYSICS_BSP_PS (0x10000) is set on the entity + // (and the mover isn't a PvP-eligible player or + // missile-ignored). Cottage doors carry the flag in their + // state (0x10008), so retail tests their slab BSP + // exclusively — the foot cyl is never tested. Without + // this guard, our dispatcher iterated cyl FIRST (it's + // registered before the BSP shape) and its radial normal + // contaminated the slide direction at NE/SE approach + // headings, producing the "stuck on door" phantom in + // door-a6p6-v2.utf8.log. See investigation at + // docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md. + // + // M1.5 scope: PvP exemption (ebp_1) and missile_ignore + // (eax_12) are treated as false. Wire them through when + // PK / missiles ship — matches retail's full predicate + // at line 276861. + if (BspOnlyDispatch(obj.State)) + { + if (PhysicsDiagnostics.ProbeBuildingEnabled) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[cyl-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only")); + } + continue; + } + result = CylinderCollision(obj, sp, engine); // A6.P4 door investigation (2026-05-24): log every Cylinder diff --git a/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs b/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs new file mode 100644 index 0000000..2bcb4e8 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs @@ -0,0 +1,78 @@ +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// A6.P7 (2026-05-25) — retail-binary dispatch rule. Retail's +/// CPhysicsObj::FindObjCollisions at +/// docs/research/named-retail/acclient_2013_pseudo_c.txt:276861 +/// dispatches BINARILY between "BSP-only" and "cyl + sphere" based on +/// the HAS_PHYSICS_BSP_PS flag (bit 16, hex 0x10000) in +/// PhysicsState. The flag is defined in +/// docs/research/named-retail/acclient.h:2833 and mirrored in +/// ACE at references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:24 +/// as HasPhysicsBSP = 0x00010000. +/// +/// +/// For non-PvP, non-missile movers (M1.5 scope — walking-vs-static), an +/// entity with HAS_PHYSICS_BSP_PS in its state tests its BSP exclusively +/// — the foot cyl is NEVER tested. The closed cottage door (state +/// 0x10008 = STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP) +/// hits this branch. +/// +/// +/// +/// Pre-A6.P7: our dispatcher iterates every ShadowEntry +/// independently and tests both the cyl AND the BSP for a door. The +/// cyl's radial normal contaminates the slide direction at NE/SE +/// approach headings (see +/// docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md). +/// +/// +/// +/// Post-A6.P7: the dispatcher consults +/// on each cyl/sphere +/// entry and skips it when the entity has BSP, matching retail. +/// +/// +public class A6P7DispatchRulesTests +{ + /// + /// Retail bit constant HAS_PHYSICS_BSP_PS = 0x10000 + /// (acclient.h:2833) and ACE's HasPhysicsBSP = 0x00010000 + /// (PhysicsState.cs:24) must match the value we expose on + /// . + /// + [Fact] + public void PhysicsStateFlags_HasPhysicsBsp_Is_Bit_16() + { + Assert.Equal(0x00010000u, (uint)PhysicsStateFlags.HasPhysicsBsp); + } + + /// + /// The dispatch predicate maps directly from retail's branch at + /// acclient_2013_pseudo_c.txt:276861: + /// + /// ((state & 0x10000) == 0 || ebp_1 != 0 || eax_12 != 0) + /// → cyl + sphere path + /// else + /// → BSP-only path + /// + /// For M1.5 scope ebp_1 (PvP-target-player) and eax_12 + /// (missile_ignore) are treated as false. The predicate reduces to: + /// "BSP-only iff HAS_PHYSICS_BSP_PS is set". + /// + [Theory] + [InlineData(0x00010008u, true)] // closed cottage door — STATIC | REPORT | HAS_BSP + [InlineData(0x00010000u, true)] // bare HAS_BSP + [InlineData(0x00110000u, true)] // HAS_BSP | CLOAKED + [InlineData(0x00000008u, false)] // creature — REPORT_COLLISIONS only + [InlineData(0x00000000u, false)] // empty state + [InlineData(0x0000FFFFu, false)] // every bit BELOW 0x10000 set + public void BspOnlyDispatch_RespectsHasPhysicsBspFlag( + uint entityState, bool expected) + { + Assert.Equal(expected, Transition.BspOnlyDispatch(entityState)); + } +}