Closes the door-cyl phantom slide where a sphere approaching a closed cottage door at NE/SE headings could be blocked by the cyl's radial normal contaminating the slide tangent into the slab face (live evidence in door-a6p6-v2.utf8.log: 12 resolves with cn=(0.86,0.51,0) attributed to door entity 0x000F4245). Retail anchor: CPhysicsObj::FindObjCollisions at acclient_2013_pseudo_c.txt:276861 dispatches BINARILY between BSP-only and cyl+sphere based on HAS_PHYSICS_BSP_PS (0x10000 in acclient.h:2833). For non-PvP, non-missile movers — every M1.5 scope walking-vs-static scenario — an entity with the flag set tests its BSP exclusively; the foot cyl is never tested. ACE confirms the truth table at PhysicsObj.cs:412-450 (HasPhysicsBSP, missileIgnore, exemption). Our dispatcher iterated every ShadowEntry independently and tested both the cyl AND the BSP for a closed door. Cyl was registered first (FromSetup walk order), and its diagonal radial slide normal "won" attribution at the early-return on first non-OK. Result was out=in for tangential motion along the door face. Changes (~15 LOC + 7 unit tests): - PhysicsStateFlags.HasPhysicsBsp = 0x00010000 (PhysicsBody.cs) - Transition.BspOnlyDispatch(uint state) static predicate (TransitionTypes.cs) — mirrors retail's branch with M1.5 scope defaults (ebp_1 and eax_12 treated as false; wire PvP / missile refinements when those scopes ship) - Per-entry guard in FindObjCollisions cyl/sphere branch (TransitionTypes.cs:2433) — continue when BspOnlyDispatch fires, with [cyl-skip-bsp] diagnostic line gated on ProbeBuildingEnabled - A6P7DispatchRulesTests (7 tests, all GREEN): flag value + 6 parameterized predicate cases Verification: 14-test keep-green list from the 2026-05-25 handoff passes (5 BSPQueryTests.FindCollisions_Path5_*, 2 CellTransitTests.A6P5_*, 2 DoorCollisionApparatusTests.Apparatus_DeadCenter_*, 5 DoorBugTrajectoryReplayTests, 1 CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap). Total: 20/20 pass including the new 7-test predicate suite. The DocumentsBug test (Apparatus_Grounded_50cmOffCenter) fails post-fix BUT was already failing pre-fix in the worktree baseline (verified by stashing the fix and re-running — same failure mode: sphere blocks at start with floor normal (0,0,1)). Not in the keep-green list, so this is a known pre-existing condition; the test's own header comment instructs flipping the assertion when the fix lands. Investigation: docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md Needs visual verification at Holtburg cottage door (NE/SE approach should now slide smoothly along the door face — zero [cyl-test] log lines attributed to door entity, replaced by [cyl-skip-bsp]). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
16 KiB
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
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
0x10008hasHAS_PHYSICS_BSP_PSset. - 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.
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:
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_1is 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_12isOBJECTINFO::missile_ignore(transition, this)—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:
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
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
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
from spawn.PhysicsState ?? 0u — ACE delivers it on the wire.
Confirmed via direct check: the door test fixtures
(DoorBugTrajectoryReplayTests.cs:61,
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:
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.
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:
-
Site:
src/AcDream.Core/Physics/TransitionTypes.cs:2313(theif (obj.CollisionType == ShadowCollisionType.BSP) ... else ...dispatch). -
Guard: add a continue at the cyl/sphere branch when
(obj.State & HasPhysicsBspPs) != 0 && !isPvpTarget && !missileIgnore. For M1.5 polish we can treat bothisPvpTargetandmissileIgnoreasfalse(no PK, no missiles in scope) and add// TODO: wire when PK / missiles shipcomments. The simplified guard is(obj.State & 0x10000u) != 0. -
Companion constant: add
HasPhysicsBsp = 0x10000utoPhysicsStateFlags(PhysicsBody.cs:25-43) — it's currently absent. Naming matches both retail (HAS_PHYSICS_BSP_PS) and ACE (HasPhysicsBSP). -
Existing tests that would change outcome under the fix:
DoorCollisionApparatusTests.Apparatus_Grounded_50cmOffCenter_FrontApproach_DocumentsBugis in "documents the bug" form — its header comment at lines 285-298 explicitly says "When the fix lands, flip this toAssert.True(blocked)." Fix lands → invert assertion in same commit.- Apparatus dead-center + back-approach tests — should remain PASS (BSP still fires).
DoorBugTrajectoryReplayTestsLiveCompare 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; testsBSPQueryinternals not dispatch. PASS.CellTransitTests.A6P5_*— unrelated. PASS.
-
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_1rules, which ours already approximate viaCollisionExemption. - Other entities with
0x10000. Cottage walls (the static landblock GfxObj0xA9B47900) likely haveHAS_PHYSICS_BSP_PSand only register BSP shapes (no cyl) — fix is a no-op there. NPCs and players have no BSP, no0x10000, 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=0x000F4245lines in the log.
- 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
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=0x000F4245resolves - 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_upport (commit3d4e63f). 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 |