# M1.5 — Broken stairs (cyl-only multi-part entity) — investigation handoff **Date:** 2026-05-25 PM **Status:** Filed as issue #101 (post-A6.P7 visual verification surfaced a NEW bug, not the closed door bug). **Research-only next session.** No implementation until we know what retail does at this exact stair location. **Predecessor handoff:** [`2026-05-25-a6-door-cyl-investigation-handoff.md`](2026-05-25-a6-door-cyl-investigation-handoff.md) (closed by A6.P7 commit `888272a`). --- ## TL;DR A6.P7 visual verification at Holtburg confirmed the cottage door is fixed. While exploring, the user found **a different staircase that doesn't work** — sphere can't climb at all. Captures show: - Stairs are in cells `0xA9B40159` + `0xA9B4015A` (NOT the cottage-cellar cells `0xA9B40143/146/147` that work post-A6.P3 cellar fix). - Geometry is a **multi-part entity** `0x0040B500` (entityId; ~150 parts in the setup; 10 of them are stair-step cylinders). - Each step is a separate cylinder (`r=0.80m, h=0.80m`) at `Y=26.60`, stepping up in X and Z (0.25 m per step, Z: 94.22 → 96.47). - `state=0x00000000` on each cyl part — **no `HAS_PHYSICS_BSP_PS` flag**, so A6.P7's dispatch gate (`Transition.BspOnlyDispatch`) does NOT skip them. - The cyls fire 284 `result=Slid` with diagonal radial normals like `(0.88, -0.47, 0)` — the same phantom shape A6.P7 closed for the cottage door, but here the cause is per-cyl-without-BSP, not per-entity-with-both. - **Player Z stayed at 94.00 for the entire 4216-record capture** — never gained altitude. This is **NOT** a regression of A6.P7. The fix did exactly what retail does for entities with `HAS_PHYSICS_BSP_PS`. The stair bug is a separate class: **cyl-only entities (no BSP) whose cyl geometry shouldn't physically block the player but does.** --- ## What today shipped (DO NOT redo) ### A6.P7 — retail-binary cyl/BSP dispatch (commit `888272a`) - File: `src/AcDream.Core/Physics/PhysicsBody.cs` (added `PhysicsStateFlags.HasPhysicsBsp = 0x00010000`) - File: `src/AcDream.Core/Physics/TransitionTypes.cs` (added `Transition.BspOnlyDispatch(uint)` predicate + per-entry guard at the cyl/sphere branch) - Test: `tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs` (7 tests) - Investigation: [`docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md`](2026-05-25-a6-door-cyl-retail-dispatch-investigation.md). - **Visual-verified at Holtburg cottage door 2026-05-25.** Captures: `launch-a6p7.log`, `launch-a6p7-v2.log` — 1187 `[cyl-skip-bsp]`, 0 `[cyl-test]` on the door, 30 axis-aligned hits, no phantom diagonals. --- ## The new bug — captures + evidence ### Captures (on disk, gitignored — DO NOT commit them; treat as live data) - **Working baseline** (cellar stairs that work): `stairs-working.jsonl` (16.9 MB, ~22K records). Z range 90.95 ↔ 94.00 (full cellar climb). 12 cell transitions; only 23 `hit=yes` events; no diagonal normals; user ran up + down twice. Cells `0xA9B40143/146/147`. - **Broken stairs**: `stairs-broken.jsonl` (8.1 MB, 4216 records). Z stayed at 94.00 for the entire capture. Cells `0xA9B40159` + `0xA9B4015A`. The player tried multiple approach angles; never climbed any step. - **Launch logs with probes**: `stairs-working.launch.log`, `stairs-broken.launch.log`. Contain `[cyl-test]`, `[cyl-skip-bsp]`, `[bsp-test]`, `[resolve]`, `[resolve-bldg]` probe lines. ### Reproduction Login as `+Acdream` at Holtburg. The cellar stairs work (verified). The broken stairs the user found are at world XY around (110, 26), Z range 94 → 96. Walk west into them — sphere hits something diagonal and gets stuck oscillating between `n=(0, 1, 0)` and `n=(0.87, -0.49, 0)` slides. ### Geometry summary (from `stairs-broken.launch.log`) The blocker is multi-part entity `entityId=0x0040B500`. Ten of its parts are cylinders forming a staircase at `Y=26.60`: | Part | World XY | Z (cyl bottom) | |---|---|---| | `0x40B5008C` (part 140) | (108.72, 26.60) | 96.47 | | `0x40B5008D` (part 141) | (108.97, 26.60) | 96.22 | | `0x40B5008E` (part 142) | (109.22, 26.60) | 95.97 | | `0x40B5008F` (part 143) | (109.47, 26.60) | 95.72 | | `0x40B50090` (part 144) | (109.72, 26.60) | 95.47 | | `0x40B50091` (part 145) | (109.97, 26.60) | 95.22 | | `0x40B50092` (part 146) | (110.22, 26.60) | 94.97 | | `0x40B50093` (part 147) | (110.47, 26.60) | 94.72 | | `0x40B50094` (part 148) | (110.72, 26.60) | 94.47 | | `0x40B50095` (part 149) | (110.97, 26.60) | 94.22 | Each cyl: `radius=0.80, height=0.80, state=0x00000000`. The entity also has a BSP part `obj=0xB5008900 gfx=0x01000C16 radius=2.645 pos=(109.30, 26.30, 95.75)` but it's effectively non-physics (`hasPhys=False bspR=0.00 vAabbR=0.82`) — the `vAabbR` here is the **visual** AABB radius being borrowed as a cylinder fallback because the underlying `GfxObj` has no physics BSP. ### What's blocking the player Sphere at `(112.115, 25.995, 94.00)` wants to move west. The closest cyl `0x40B50095` is at `(110.97, 26.60, 94.22)`: - `distXY = 1.295m` (just barely outside reach `0.80 + 0.48 = 1.28m`) - But during sub-stepping the sphere center crosses 1.28m → cyl overlaps - Radial normal direction from cyl center to sphere: `(0.884, -0.467, 0)` — matches observed phantom hits `(0.88, -0.47)`, `(0.86, -0.51)`, etc. The cyl is **too tall (0.80m) to step over** under A6.P6's grounded step-over check (step-up budget = 0.60m). Falls through to the wall-slide branch which produces the diagonal radial normal that drives the sphere's slide tangent into the perpendicular cell wall, then re-blocks. Net: stuck. ### Why A6.P7 doesn't help A6.P7 gates the cyl branch on `(state & 0x10000) != 0`. These stair cyls have `state=0x00000000` — bit not set. Guard does NOT fire. Cyls are tested. Sphere blocks. --- ## What this session needs — retail investigation **Mandate:** report-only research, NO implementation. Use the `/investigate` skill. The fix design comes in a subsequent session once the retail behavior is settled. ### Question 1 — What does retail DO at this exact staircase? **Use cdb.** The toolchain in `CLAUDE.md` "Retail debugger toolchain" is ready. The matching binary + PDB are verified. Concrete experiment: 1. Have the user run the retail acclient.exe (Microsoft AC official build v11.4186) at the same world location (cells `0xA9B40159` + `0xA9B4015A`, XY ≈ (110, 26)). The user needs to be IN the building, AT the foot of these stairs. 2. Attach cdb with breakpoints: - `acclient!CCylSphere::collides_with_sphere` at `0x53a880` — counter `$t0`, log every 100 hits with the `this` pointer and the moving sphere's position, `gc`. Auto-detach after 5000. - `acclient!CCylSphere::intersects_sphere` (the dispatch from `CPhysicsObj::FindObjCollisions` cyl branch) — counter `$t1`, log entity address. - `acclient!CObjCell::find_env_collisions` — counter `$t2`. Tells us if retail uses cell BSP for stair collision. - `acclient!CPartArray::FindObjCollisions` — counter `$t3`. Confirms BSP dispatch path. 3. Have the user walk straight into the broken stairs from outside, then try to climb them. Capture 30 seconds. 4. Detach. Analyze: - Does `CCylSphere::collides_with_sphere` fire on the stair entity? If yes → retail's cyls ARE active here, and retail somehow handles them differently (different step-up threshold? cell-context-aware?). If no → retail's cyls are excluded by something we don't replicate. - Does `CObjCell::find_env_collisions` fire heavily? If yes → retail might be using cell BSP polygons for the stairs (and the entity cyls are decorative/click-targets only). ### Question 2 — What's the Setup ID? Compare retail's PhysicsObj construction Our `[resolve-bldg]` lines show the entity is built from GfxObj `0x0100081A` with `hasPhys=False`. **What's the Setup ID for entity `0x0040B500`?** Trace through our streaming code to find which Setup emitted the 150-part build. Steps: 1. Grep `src/AcDream.App/Rendering/GameWindow.cs` for the `BuildInteriorEntitiesForStreaming` path (CLAUDE.md says it hydrates EnvCell static objects with id `0x40xxxxxx`). 2. Add a temporary `[entity-source]` probe that logs the Setup id when an entity gets registered. Or check existing diagnostic output — the `gfxObj=0x0100081A` is the part's GfxObj, but we need the parent Setup. 3. With the Setup id in hand, look up retail's behavior: - Decompile / grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` for `CPhysicsObj::InitPartArrayFromSetup` or similar to see how retail builds the part_array from a Setup. Does retail include every part as a collision shape, or filter by some flag? ### Question 3 — Why is `vAabbR` becoming a cylinder? The `[resolve-bldg]` line shows `gfxObj=0x0100081A hasPhys=False bspR=0.00 vAabbR=0.82`. We registered a `r=0.80` cyl. The 0.80 ≈ 0.82 match is suspicious — we're using the **visual AABB radius** as a fallback cyl radius when there's no physics BSP. Steps: 1. Find the code path in our tree that does this fallback. Likely in `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` `FromSetup` or in `RegisterMultiPart`. Look for cases where `GfxObj.PhysicsBSP` is null and a cyl is synthesized. 2. Cross-reference retail: does retail synthesize a cyl from visual bounds when physics is null? Or does retail skip such parts entirely for collision (visual-only)? 3. ACE check: `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` — how does ACE construct the part_array from a Setup with mixed physics/visual-only parts? ### Question 4 — Cell BSP fallback If retail's stairs are walked via cell BSP polygons (not entity cyls), what's in cell `0xA9B40159`'s BSP at this XY/Z? Is there a walkable polygon staircase that we're not iterating? Steps: 1. Use `ACDREAM_DUMP_CELLS=0xA9B40159,0xA9B4015A` to dump the cell BSPs to JSON. (Confirm the env var path; see existing `CellDump` infra near issue #98's apparatus.) 2. Look for inclined polygons in the dump that form the staircase. If present → retail likely uses these for collision; our entity cyls are either a setup misinterpretation or redundant. --- ## Files to read FIRST next session | Path | Why | |---|---| | `docs/ISSUES.md` (#101) | The filed issue with severity + acceptance | | `docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md` | A6.P7 background (closed; companion bug) | | `docs/research/named-retail/acclient_2013_pseudo_c.txt:276776` | `CPhysicsObj::FindObjCollisions` | | Setup dat reader path in `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | Cyl synthesis from Setup; the suspected fallback | | `src/AcDream.App/Rendering/GameWindow.cs::BuildInteriorEntitiesForStreaming` | Entity hydration for EnvCell statics | | `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` | ACE PartArray construction | | `references/ACE/Source/ACE.Server/Physics/Common/Setup.cs` | ACE Setup → PartArray pipeline | --- ## Tests that must stay green Same as A6.P7 list: ``` dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --no-build -c Debug --filter "FullyQualifiedName=AcDream.Core.Tests.Physics.CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.CornerSlide_AlcoveEastToCottageNorth_ShouldBlock|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.Geometric_DoorSlabAtSphereHeight_OverlapsInZ|FullyQualifiedName=AcDream.Core.Tests.Physics.DoorBugTrajectoryReplayTests.InsideOut_Tick3254_WithCottageWalls_ShouldBlock|FullyQualifiedName~BSPQueryTests.FindCollisions_Path5|FullyQualifiedName~CellTransitTests.A6P5|FullyQualifiedName~DoorCollisionApparatusTests.Apparatus_DeadCenter|FullyQualifiedName~A6P7DispatchRulesTests" ``` Expected: 20/20 pass. --- ## Things NOT to do (do-not-retry) 1. **Don't lower step-up height** to make A6.P6's grounded step-over fit the 0.80m cyl. Step-up budget = 0.60m is retail-faithful. Tweaking it would regress every other surface where 0.60m is correct (curbs, low ledges). 2. **Don't extend A6.P7's `BspOnlyDispatch` to entities with `state=0`.** That gate is retail-specific (`HAS_PHYSICS_BSP_PS`). Skipping cyls purely because peer parts exist with BSP would diverge from retail and break NPC cyl-only entities. 3. **Don't disable cyl fallback when `hasPhys=False` without checking retail.** Until we know how retail handles `GfxObj` with no physics BSP, "just skip the cyl" might break other content (small decorative items that DO collide in retail). 4. **Don't add per-entity workarounds** ("if entity id 0x0040B500, skip cyls"). Per CLAUDE.md no-workarounds rule. 5. **Don't enlarge the sphere's step-up budget for tall cyls.** Retail's threshold is what it is. If retail steps over 0.80m cyls in this scenario, the mechanism is something else. --- ## Three fix-shape candidates (for the FOLLOWING session, not this one) Listed in rough order of retail-faithfulness based on the limited evidence we have. The retail investigation will decide which is right. 1. **Don't synthesize cyls from visual AABB when `GfxObj.PhysicsBSP` is null.** Suppress at registration time in `ShadowShapeBuilder.FromSetup`. Retail-anchored: if retail's `CPartArray` doesn't include such parts in the collision list, our registration shouldn't either. The cell BSP would then be the only collision source. 2. **Use cell BSP polygons** for stair geometry; entity cyls are decorative-only for this entity class. Requires: (a) confirming cell `0xA9B40159` BSP has walkable stair polys, (b) ensuring our cell BSP query iterates them. Likely a no-op on our side once (1) is done. 3. **Make `step_sphere_up` cyl-height-tolerant** — if the sphere is on a walkable plane and a cyl is detected, attempt step-up even when cyl height > step-up budget IF a walkable surface exists at the top of the cyl. Retail-anchored ONLY if cdb shows retail does this on these specific stairs. --- ## Pickup prompt for next session ``` A6 — Broken stairs cyl investigation (issue #101). Investigation-only session. Read first (in this order): 1. docs/research/2026-05-25-stairs-cyl-investigation-handoff.md (this file — full context, captures, geometry, do-not-retry list) 2. docs/ISSUES.md #101 3. docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md (A6.P7 background — closed; companion bug) State both altitudes: Currently working toward: M1.5 — Indoor world feels right Current phase: A6 — broken-stairs investigation (issue #101) Session mandate: retail investigation, NOT implementation. Use the /investigate skill. Specific questions (each must be answered with cited evidence — retail line numbers, cdb traces, dat dumps): 1. Does retail's CCylSphere::collides_with_sphere fire on the stair-step cylinders at cells 0xA9B40159/0xA9B4015A when a player walks in to climb them? If yes — how does retail walk past 0.80m-tall cyls? If no — what excludes them? 2. What's the Setup ID for entity 0x0040B500? Trace from GameWindow.cs::BuildInteriorEntitiesForStreaming. Cross-reference how retail's CPhysicsObj::InitPartArrayFromSetup (or equivalent) builds the collision shape list — does retail include parts with hasPhys=False? 3. Why does our ShadowShapeBuilder synthesize an r=0.80 cyl from vAabbR=0.82 when GfxObj.PhysicsBSP is null? Identify the code path. Does retail do this? 4. Dump cell 0xA9B40159's BSP polygons (ACDREAM_DUMP_CELLS). Does the cell BSP have walkable stair polygons? If yes — retail's stair collision is the cell BSP, not the entity cyls. Deliverable: a short report (~2-3 pages) covering the 4 questions with retail line numbers, cdb trace excerpts, code citations. Then propose which of the 3 fix-shape candidates is most retail-faithful (or a fifth shape that emerges from the research). DO NOT implement the fix this session. Save it for the session after. Do-not-retry list (in handoff doc) — read it before starting. Tests to keep green if any code changes happen (none expected this session): see handoff doc. Reproduction setup for the broken scenario: ACDREAM_PROBE_BUILDING=1 ACDREAM_PROBE_RESOLVE=1 ACDREAM_CAPTURE_RESOLVE=.jsonl walk to cells 0xA9B40159/A in Holtburg (XY ≈ 110, 26) ```