acdream/docs/research/2026-05-25-stairs-cyl-investigation-handoff.md
Erik 8795655250 docs: issue #101 — broken stairs cyl phantom (post-A6.P7 finding)
Visual verification of A6.P7 at Holtburg cottage door passed cleanly
(1187 [cyl-skip-bsp] guard fires, 0 [cyl-test] on doors, 30/30
axis-aligned hits, smooth NE/SE slide along door face). While
exploring post-verification, the user discovered a different
staircase in cells 0xA9B40159 + 0xA9B4015A where the sphere cannot
climb at all.

Captured working baseline (stairs-working.jsonl, cottage cellar
stairs in cells 0xA9B40143/146/147 — clean ↔ Z=90.95-94.00 traversal)
and broken scenario (stairs-broken.jsonl, Z stays at 94.00 the entire
4216-record capture).

Root cause is NOT a regression of A6.P7. It's a different bug shape:
the staircase is built as a multi-part EnvCell entity (entityId
0x0040B500, ~150 parts), with 10 of those parts being 0.80m-radius
cylinders forming the steps. Each cyl carries state=0x00000000 — no
HAS_PHYSICS_BSP_PS — so A6.P7's BspOnlyDispatch guard correctly
doesn't fire. Cyl height 0.80m exceeds A6.P6's step-up budget 0.60m
so grounded step-over fails. Falls through to wall-slide which
produces the same diagonal radial phantom A6.P7 closed for the door.

The [resolve-bldg] lines reveal gfxObj=0x0100081A hasPhys=False
bspR=0.00 vAabbR=0.82 — the underlying GfxObj has NO physics BSP;
we appear to be synthesizing a cyl from the visual AABB radius. That
synthesis path is the suspected misregistration.

Filed as issue #101 with severity HIGH. Investigation handoff written
covering 4 retail-research questions (cdb on retail at this stair
location, Setup trace via entity-source probe, ShadowShapeBuilder
vAabbR fallback audit, cell BSP poly dump), do-not-retry list, and 3
candidate fix shapes (don't synthesize cyl from vAabbR / cell BSP for
stairs / cyl-height-tolerant step-over). The handoff explicitly
defers implementation to a later session pending retail evidence.

Files:
- docs/research/2026-05-25-stairs-cyl-investigation-handoff.md (new)
- docs/ISSUES.md — added #101 entry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:03:58 +02:00

16 KiB

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 (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.
  • 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=<path>.jsonl
  walk to cells 0xA9B40159/A in Holtburg (XY ≈ 110, 26)