Wires the dormant RenderingDiagnostics.ProbeVisibilityEnabled flag (added
2026-05-25 by Task 6 of the original A8 plan, no probe code) to per-frame
[vis] log lines around the render-frame branch decision. Captures camera
position, cameraInsideCell (lenient grace-aware), the strict PointInCell
result, the visibility CameraCell id, and VisibleCellIds count/list.
Enable via ACDREAM_PROBE_VIS=1.
Used during A8 RR0 falsification spike (2026-05-26) — see
docs/research/2026-05-26-a8-rr0-falsification-findings.md. Kept as long-
term diagnostic for the upcoming RR8/RR10 visual verification gates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R3.5 v1 only gated the stencil branch on `cameraReallyInside`; the
depth-clear-if-inside at line ~7129 stayed on `cameraInsideCell`. During
grace frames after exit:
cameraInsideCell = true (grace, holds previous cell for 3 frames)
cameraReallyInside = false (PointInCell on camera pos returns false)
So depth-clear FIRED (writing depth = 1.0 globally) but the OUTDOOR branch
ran (single Draw(All) on every entity). With depth cleared, terrain's
depth = 1.0 — every entity below terrain (cellar geometry, basement
GfxObjs, anything at world Z < terrain Z) won the depth test and rendered
THROUGH the ground. User reported: "stand outside or pass outside → flicker
where objects are visible through ground and walls of other buildings are
missing."
v2 fix: unify depth-related gates on `cameraReallyInside`. During grace
frames depth-clear is now ALSO skipped; terrain depth survives; the
outdoor pass renders normally with proper terrain occlusion. Sky /
lighting / particles continue to use `cameraInsideCell` for smooth
grace-aware transitions.
The two-flag split is now explicit:
cameraInsideCell → sky, lighting (smooth, grace-aware)
cameraReallyInside → depth-clear, stencil branch (strict, no grace)
Closes the persistent transition flicker observed in R4 visual
verification after v1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the transition-flicker symptom observed during R4 visual verification:
brief 1-3 frames after exiting a building where outdoor scenery rendered
with wrong stencil mask, "walls disappear and buildings show under ground"
shimmer, and sky stayed suppressed.
Root cause: CellVisibility.FindCameraCell holds the previous CameraCell
for ~3 grace frames after the camera physically exits the cell volume
(see _cellSwitchGraceFrames). The grace mechanism prevents flicker at
the doorway threshold for sky/lighting/depth-clear, but the new R3
stencil branch was using `cameraInsideCell` directly — so during grace
frames it ran MarkAndPunch with the previous cell's portals (now behind/
beside the camera) and the IndoorPass + stencil-gated outdoor produced
the garbage frame.
Fix: compute `cameraReallyInside` via the stricter
CellVisibility.PointInCell containment check and use it (instead of
`cameraInsideCell`) as the gate for the stencil branch. Sky, depth-clear,
lighting, and particles continue to use `cameraInsideCell` so their
smooth grace-aware behavior is unchanged.
Handoff item #10 (docs/research/2026-05-26-a8-revert-handoff.md) flagged
this exact concern: "Likely the CellSwitchGraceFrameCount = 3 interacting
with stencil setup timing." Confirmed and closed.
Visual-verification of the fix is part of R4 (re-run).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the pre-A8 single dispatcher call with the WB RenderInsideOut
order when cameraInsideCell:
1. Terrain draws normally (color + depth)
2. depth-clear-if-inside (depth = 1.0 globally)
3. MarkAndPunch — stencil bit 1 at camera's-own-cell exit portals
4. IndoorPass — cell mesh + cell statics + building shells, stencil OFF
5. EnableOutdoorPass + re-draw terrain + OutdoorScenery, stencil-gated
6. DisableStencil + LiveDynamic, depth-test only
Outdoor (cameraInsideCell == false) path unchanged: single Draw(set: All).
Step 5 (WB's 3-stencil-bit cross-cell-portal pipeline) is DEFERRED — we
mark only the camera's own cell's exit portals via [visibility.CameraCell],
not the BFS-extended VisibleCellIds. Trade-off documented in
docs/research/2026-05-26-a8-entity-taxonomy.md §"open questions".
Adds IndoorCellStencilPipeline field + ctor wiring + Dispose. Field types
the partition consumers from R2; the ParentCellId / IsBuildingShell /
ServerGuid distinctions are now consumed at runtime.
Visual verification at cottage interior / cottage cellar / inn interior /
dungeon is R4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshapes the dormant EntitySet enum from binary IndoorOnly/OutdoorOnly to
a three-way taxonomy-aware partition:
IndoorPass — cell mesh + cell statics + building shells
(ParentCellId.HasValue OR IsBuildingShell), live-dynamic
excluded
OutdoorScenery — outdoor scenery only (ParentCellId == null AND
!IsBuildingShell), live-dynamic excluded
LiveDynamic — ServerGuid != 0 (player, NPCs, dropped items)
Centralizes the membership predicate in EntityMatchesSet to keep the three
call sites (two in WalkEntitiesInto, one in WalkEntitiesForTest) DRY.
R1's IsBuildingShell flag is now consumed at render time. Integration into
the render frame ships in R3.
Tests rebuilt from scratch — 7 cases cover the new partition truth table.
Existing dispatcher tests (Tier 1 cache, etc.) continue to pass under the
default EntitySet.All.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a bool flag at the WorldEntity data layer set by LandblockLoader from
the source dat array: LandBlockInfo.Buildings → true (cottage walls, inn
walls, smithy walls); LandBlockInfo.Objects → false (trees, lampposts,
rocks, hitching posts).
Retail anchor: CLandBlock::init_buildings reads a separate BuildInfo**
array from objects (acclient.h:31893 num_buildings / buildings field;
acclient_2013_pseudo_c.txt:313854 init_buildings entry). WorldBuilder
preserves the same distinction via SceneryInstance.IsBuilding
(StaticObjectRenderManager.cs:334). Today acdream's loader reads both
arrays into the same WorldEntity pool with no tag, destroying the
distinction (the comment at GameWindow.cs:5175 already acknowledges this
gap for scenery suppression). This commit closes the gap.
Render-time consumption arrives in R2 (EntitySet partition refactor).
Two new LandblockLoader tests lock the tagging behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second visual verification surfaced three depth-ordering bugs all from
one cause: the IndoorOnly dispatcher Draw ran BEFORE MarkAndPunch, so
the far-depth punch (gl_FragDepth = 1.0 at stencil=1 portal silhouettes)
overwrote any indoor depth that had been written there. Result:
• Closed doors leaked outside terrain — door mesh wrote depth 0.6 at
the portal silhouette, then the punch overwrote it to 1.0, then
terrain at 0.99 won the depth test.
• Walls between rooms leaked the far-side door/window opening —
same mechanism: wall depth at the far-portal silhouette destroyed
by the punch.
• Animated character body bled to terrain where it overlapped a
portal silhouette on screen — same mechanism: character depth
destroyed by the punch.
Re-reading WB's RenderInsideOut (VisibilityManager.cs:73-239) confirms
the correct order is mark-and-punch FIRST, then indoor cells. Indoor
geometry drawn AFTER the punch wins the depth test against 1.0 and
correctly occludes the subsequent stencil-gated outdoor pass.
The swap is a single block move; MarkAndPunch was already correctly
leaving the GL state stencil-disabled for the indoor pass to follow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visual verification of A8 (commit 41c2e67) surfaced a showstopper:
player + NPCs disappeared when the camera entered a building. Root
cause: live server-spawned entities (animated player/NPCs/monsters)
have ParentCellId == null. The EntitySet partition classified them
as "outdoor" and stencil-gated them in the OutdoorOnly pass — so
they only rendered where stencil bit 1 was set (portal silhouettes),
producing partial-body and head-backwards artifacts at doorway
transits and full invisibility everywhere else inside.
Fix: animatedEntityIds overrides the ParentCellId-based partition.
Animated entities always belong in the IndoorOnly pass (stencil OFF),
never in OutdoorOnly. Three changes:
- WalkEntitiesInto full-walk path: compute isAnimated up front, use
it in both partition checks
- WalkEntitiesInto animated-only path: skip the entire path on
OutdoorOnly (every iterated entity is animated by definition)
- WalkEntitiesForTest: add optional animatedEntityIds parameter,
mirror the new partition logic
Two new tests cover:
- EntitySet_IndoorOnly_IncludesAnimatedEntitiesEvenWithNullParentCellId
- EntitySet_OutdoorOnly_ExcludesAnimatedEntities
Known remaining limitation: dropped items / static-but-live objects
have ParentCellId == null AND are NOT in animatedEntityIds, so they
still classify as outdoor scenery and stencil-gate. Addressing this
requires a "live entity" flag on WorldEntity — deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds EntitySet { All, IndoorOnly, OutdoorOnly } and a Draw parameter to
partition the per-entity walk by ParentCellId presence. EntitySet.All
preserves pre-A8 behavior; IndoorOnly drops null-ParentCellId entities;
OutdoorOnly drops ParentCellId.HasValue entities. The visibleCellIds
filter is still applied on top.
Used by Task 7 to split the render frame's single Draw call into two
(indoor stencil-OFF, outdoor stencil-gated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small improvements from Task 5 code review:
- MarkAndPunch now enables DepthTest explicitly (was relying on
GameWindow's startup enable; this makes the method self-contained).
- Uniform location fields marked readonly (set once in ctor).
- AllocateVbo gets a comment noting that mid-session reallocation is
safe because the VAO bakes the VBO association at ConfigureVao time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pipeline class owns the portal_stencil shader + a dynamic VBO/VAO
for per-frame portal triangle uploads. MarkAndPunch runs WB's two-step
stencil setup (mark portals = 1, then write gl_FragDepth=1.0 into
stencil=1 regions). EnableOutdoorPass switches to read-only stencil
for the subsequent terrain + outdoor-entity passes.
PortalMeshBuilder.BuildTriangles is the pure-math triangle-fan
extractor — unit-testable without a GL context. Only exit portals
(OtherCellId == 0xFFFF) are emitted; inner portals are skipped to
prevent outdoor geometry from bleeding into adjacent rooms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WB's PortalStencil.vert has a pos.w clamp for the camera-coplanar-with-
portal degenerate. We exclude it per spec (matches retail intent), but
the file should note the omission so future readers don't wonder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the pos.w clamp in portal_stencil.vert and the FragColor
declaration in portal_stencil.frag added in 2d31d49. Both were
speculative defensive code not in the spec or the WB reference. The
shaders now match the spec verbatim (except the locally-conventional
`core` profile qualifier which is correct).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Minimal pair for the indoor-cell stencil pipeline (#78). Vert transforms
world-space portal polygon vertices through uViewProjection; includes a
near-zero pos.w guard for coplanar-camera robustness (matches WB pattern).
Frag either passes through gl_FragCoord.z or writes gl_FragDepth=1.0
based on uWriteFarDepth; FragColor declared but suppressed via ColorMask
on the CPU side.
Matches WorldBuilder's PortalStencil.vert/.frag at
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/.
Uses #version 430 core consistent with acdream's mesh_modern shaders.
Deployed to bin/ via existing Rendering\Shaders\*.* .csproj glob.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the ACDREAM_PROBE_VIS=1 env-var-toggleable flag for the indoor-cell
visibility culling pipeline (#78). Mirrors the existing ProbeIndoor*
pattern. DebugVM checkbox follows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BuildLoadedCell now reads the full portal polygon vertices from
cellStruct.Polygons[portal.PolygonId].VertexIds and stores them in
local-space on the LoadedCell. Empty arrays for unresolved polygons.
Same source as the ClipPlane block; no new dat read.
Unit test covers the data-class invariant (parallel indexing) since
the full integration is exercised only at runtime with live dat data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First slice of the indoor-cell visibility culling pipeline (#78). Adds
PortalPolygons: List<Vector3[]> to LoadedCell, parallel-indexed to the
existing Portals + ClipPlanes lists. Empty arrays for portals whose
polygon could not be resolved. Field is populated in Task 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retired in favour of Task 1's retail-faithful terrain shader Z nudge.
Pure removal — ~50 LOC of dead surface area across:
- src/AcDream.Core/Terrain/LandblockMesh.cs (drop parameter +
cell-collapse block)
- src/AcDream.Core/World/LoadedLandblock.cs (drop field)
- src/AcDream.Core/World/LandblockLoader.cs (drop method + call)
- src/AcDream.App/Rendering/GameWindow.cs (3 sites)
- src/AcDream.App/Streaming/GpuWorldState.cs (6 ctor sites)
- src/AcDream.App/Streaming/LandblockStreamer.cs (1 ctor site)
- tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs (drop test)
- tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs (drop test)
No retail anchor — the deleted mechanism never had one; this commit
rolls our code back to the actual retail behaviour established in
the prior commit's shader nudge.
ISSUES.md #100 moved to Recently closed.
Cross-ref:
docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md
docs/superpowers/plans/2026-05-25-issue-100-terrain-cutout.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subtract 0.01 from every terrain vertex Z in the modern terrain vertex
shader, matching retail's per-draw nudge applied inside
ACRender::landPolysDraw(arg2=2). Coplanar building floors now always win
the depth test against the rendered terrain, so the visual "ground at
the building floor" reads as the building's floor, not as Z-fighting.
Constant 0.01f bit-equals retail's float literal 0.00999999978 when
rounded to single precision.
Render-only — physics reads the un-nudged heightmap via
TerrainSurface.SampleZ / SampleZFromHeightmap. The same render-vs-
physics split is already established for EnvCell render lift
(+0.02m at GameWindow.cs around the cell-mesh draw).
Retail anchors:
docs/research/named-retail/acclient_2013_pseudo_c.txt:1120769
docs/research/named-retail/acclient_2013_pseudo_c.txt:702254
Cross-ref:
docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md
docs/superpowers/plans/2026-05-25-issue-100-terrain-cutout.md
Followed by Task 2 (delete the hiddenTerrainCells / BuildingTerrainCells
plumbing). Visible result of this commit alone: building floors stop
Z-fighting, but the 24m x 24m transparent rectangles persist until the
plumbing is removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 10 stair-step cyls (entities 0x40B5008A..0x40B50095 in Holtburg
cells 0xA9B40159/A) are synthesized by the mesh-aabb-fallback path
from the visual mesh AABB of GfxObj 0x0100081A — which has
HasPhysics=False and no PhysicsBSP. Retail's CPartArray::InitParts
emits no collision in this case; acdream now matches that by
consulting PhysicsDataCache.IsPhantomGfxObjSource (added in the
previous commit) and skipping synthesis when the predicate fires.
The actual staircase collision is on entity 0x40B50089 (GfxObj
0x01000C16, hasPhys=True, BSP radius 2.645m) — same staircase BSP
that retail uses. After this fix, only that BSP fires; the
phantoms are gone.
Visual verification pending (next step in plan); the BSP dump
from ACDREAM_DUMP_GFXOBJS=0x01000C16 will confirm whether
0x01000C16 has walkable inclined polys for the climb to actually
land. If not, a follow-up issue is needed; the cyl phantom is
closed either way.
Also updates PhysicsDataCache.cs XML doc line reference from
6116 to 6127 (drifted by the 11-line isPhantomGfxObj block
inserted above the guarded if).
Refs docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retail's CPartArray::InitParts emits collision shapes only from
Setup-level CylSpheres/Spheres or per-Part PhysicsBSP — never
from visual mesh AABBs. The predicate captures the retail rule:
a stab whose source is a GfxObj (high byte 0x01) with no cached
GfxObjPhysics is phantom (no collision). Wired into GameWindow's
mesh-aabb-fallback synthesis in the next commit.
Refs docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Retail's CCylSphere::intersects_sphere at acclient_2013_pseudo_c.txt
:324626-324641 routes the Contact-state branch through step_sphere_up
(line 324516), not slide. The step-up check at line 324519-324524:
cyl_clearance = sphere.radius + cyl.height - offset.z
if (step_up_height < cyl_clearance) → slide (cyl too tall)
else → DoStepUp, on failure → step_up_slide
For the cottage door's foot cyl (h=0.20m, r=0.10m) at standing height,
cyl_clearance = 0.30m and player step_up_height = 0.60m, so the sphere
steps over the cyl easily — no radial push-out.
Pre-fix bug (live trace door-phantom.utf8.log 2026-05-25 PM):
when the player slid along the closed cottage door's slab face, the
foot cyl fired Slid with radial outward push at the door's middle X
(cn=(0.64,0.77,0) etc.) — a "phantom collision" that broke the slide.
Cause: A6.P5's cellSet expansion made the door reliably visible from
all approach angles, exposing this pre-existing behavior. Pre-A6.P5
the cyl wasn't visible from many approach angles so the phantom rarely
fired; the underlying mismatch with retail was always there.
Fix: in CylinderCollision, when oi.Contact && !sp.StepUp && !sp.StepDown
and engine is non-null, compute cyl_clearance, and if step_up_height
allows it, call DoStepUp with the cyl's radial collision normal. On
success the sphere is repositioned past the cyl (returns OK). On
failure (no walkable surface beyond — e.g., a wall behind the cyl),
fall back to StepUpSlide which uses SlideSphereInternal's crease
projection — smoother tangent slide than the radial push.
Conformance:
- All A6P5 unit tests + Path 5 tests + Apparatus_50cmOffCenter_* +
Apparatus_DeadCenter_* + Directional_OutsideIn/InsideOut + issue #98
LiveCompare_FirstCap_FixClosesCottageFloorCap pass in isolation.
- Full Core suite failure count unchanged (17 baseline → 17 with-fix);
diff is documented static-leak flakiness, no real regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retail's CObjCell::find_cell_list at acclient_2013_pseudo_c.txt:308742-
308869 walks vtable[0x80] on every cell in the array and adds portal-
reachable cells unconditionally — without testing each portal plane
against the sphere. Our exit-portal branch in FindTransitCellsSphere
gated outdoor inclusion on sphere-plane overlap (exitOutside fired
only when the sphere physically straddled the exit portal plane).
That gate produced the cottage-door over-penetration bug verified in
A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell: BFS from
indoor cell 0xA9B4013F expanded to 0xA9B40150 (which has an exit
portal) but the sphere — in 0xA9B4013F's volume — wasn't at 0xA9B40150's
exit portal plane, so exitOutside stayed false and the door's outdoor
cell 0xA9B40029 wasn't added to the cellSet. The cell-crossing tick's
collision query missed the door and the sphere committed 0.27 m INTO
the slab.
Fix: exit portals contribute exitOutside=true by topology
(OtherCellId == 0xFFFFu), not by sphere overlap. AddAllOutsideCells
is deduped to once per BFS so the radial pattern is added exactly
once when any BFS-visited cell has an exit portal.
Conformance: A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell
now passes. A6P5_BuildCellSetFromAlcove_AlsoReachesDoorOutdoorCell
(regression guard for the previously-sometimes-working case) stays
green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One [cellset-build] line per call when ACDREAM_PROBE_CELLSET=1: seed cell,
sphere world XY, candidate count, full candidate id list. Used to prove
the cellSet for the player's start cell doesn't include the door's outdoor
cell across the over-penetration tick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retail's CPolygon::pos_hits_sphere at
acclient_2013_pseudo_c.txt:322974-322993 records the polygon pointer
(*arg5 = this at line 00539509) on STATIC overlap BEFORE the front-
face cull (dot(N, movement) >= 0 return 0 at line 0053952f). So when
a sphere statically overlaps a wall but is moving parallel/away from
the wall normal, retail returns 0 (no full hit) but the polygon
pointer IS set so Path 5's set_neg_poly_hit dispatch at
acclient_2013_pseudo_c.txt:0053a6ea fires and the outer
transitional_insert loop slides the sphere along the wall.
Pre-fix our PosHitsSphere set hitPoly only when both the static-
overlap AND the front-face cull passed. Near-miss polygons were
dropped → Path 5's `if (hitPoly0 is not null)` branch never fired →
NegPolyHit stayed false → outer loop never slid → inside-out cottage
doors let spheres squeeze through walls they were touching.
The handoff (docs/research/2026-05-25-door-bug-cdb-retail-trace-findings.md)
hypothesized swept-sphere intersection + closest-considered-polygon
tracking. Reading the actual retail decomp of pos_hits_sphere AND
polygon_hits_sphere_slow_but_sure (acclient_2013_pseudo_c.txt:322504-
322635) showed both functions are STATIC tests; the motion vector is
used only for the front-face cull. The fix is a one-line reordering.
Adds 3 unit tests in BSPQueryTests covering:
- Sphere overlaps wall + moves parallel → NegPolyHit fires (RED→GREEN)
- Sphere overlaps wall + moves away → NegPolyHit fires (RED→GREEN)
- Sphere overlaps wall + moves into → Slid (regression guard, already
passed)
Verification:
* 3 new Path 5 tests pass.
* Full Core suite: 14 failures with-fix vs 17 failures baseline-no-fix.
The with-fix failure set is a STRICT SUBSET of baseline — zero
regressions. The 14 remaining failures are pre-existing static-leak
flakiness between test classes (documented in CLAUDE.md) and 2 stale-
capture LiveCompare_* document-the-bug tests.
* All handoff "must-stay-green" tests pass:
- Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace
- Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace
- CornerSlide_AlcoveEastToCottageNorth_ShouldBlock
- Geometric_DoorSlabAtSphereHeight_OverlapsInZ
- CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap
(issue #98 CRITICAL — no regression).
Per CLAUDE.md: needs visual verification at Holtburg cottage door
inside-out off-center (~50 cm) scenario before the A6.P4 phase is
marked complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cdb attached to retail at a Holtburg cottage door while user walked the
inside-out off-center scenario. Three trace iterations identified that
retail's collision-recording happens via SPHEREPATH::set_neg_poly_hit
(fires hundreds of times during inside-out walk), NOT via the more
obvious-named COLLISIONINFO setters (which fire 0 times). Apparatus
scripts at tools/cdb/door-inside-out-v[1-3].cdb + symbol-probe.cdb.
Our codebase has NegPolyHitDispatch defined but never called. The
downstream TransitionalInsert NegPolyHit handler was a stub. Two-part
fix landed:
1. BSPQuery.FindCollisions Path 5 (Contact branch) restructured —
distinguishes full hit (hit0 == true → StepSphereUp) from near-miss
(hit0 == false but hitPoly0 != null → NegPolyHitDispatch). Mirrors
retail BSPTREE::find_collisions at
acclient_2013_pseudo_c.txt:0053a630-0053a6fb.
2. Transition.TransitionalInsert NegPolyHit handler — dispatches to
step_up + step_up_slide (NegStepUp=true) or records collision
normal + returns Collided (NegStepUp=false). Mirrors retail
CTransition::transitional_insert at
acclient_2013_pseudo_c.txt:0050b7af-0050b7e6.
Tests: all 11 fix-relevant + regression tests pass including issue #98.
VISUAL VERIFICATION (user-driven inside-out off-center): still squeezes
through. Diagnostic [neg-poly-dispatch] probe shows ZERO hits in
production. The Path 5 restructuring doesn't surface NegPolyHit
because our SphereIntersectsPolyInternal only sets hitPoly on FULL
hits — retail's sphere_intersects_poly sets var_5c (closest polygon)
even on near-misses via BSP-traversal side effect.
Remaining fix (next session): add near-miss polygon recording to
SphereIntersectsPolyInternal. Once it sets hitPoly on near-miss BSP
traversal, the Path 5 NegPolyHit dispatch (this commit) will fire
and the TransitionalInsert handler (this commit) will block.
Full handoff with cdb trace table + next-step plan:
docs/research/2026-05-25-door-bug-cdb-retail-trace-findings.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CellTransit.AddAllOutsideCells assumed sphere coords were absolute world
coords (subtracting lbXf = 0xA9 * 192 = 32448 from the sphere position).
Production has used landblock-local coords since Phase A.1
(streaming-center landblock at world origin), so the subtraction
produced localX = -32316, gridX = -1346 → out-of-range → early return
→ ZERO outdoor cells added.
For outdoor primary cells the bug was masked by GetNearbyObjects's
radial sweep. For indoor primary cells (where #98 gates the outdoor
sweep), the door's outdoor cell 0xA9B40029 never reached
portalReachableCells, the door's BSP was never queried, and the player
walked through Holtburg cottage doors unimpeded.
Fix: AddAllOutsideCells treats worldSphereCenter as landblock-local
directly. Matches retail CLandCell::add_all_outside_cells which uses
the per-cell 6-byte landblock-relative position struct.
Existing CellTransitAddAllOutsideCellsTests + CellTransitFindCellSetTests
updated to use landblock-local sphere coords (they were the only callers
using the world-coord convention; production never did).
Apparatus shipped:
- DoorBugTrajectoryReplayTests — live-capture-driven replay harness
that pinpointed the bug per-field at unit-test speed (<500ms iteration)
- AddAllOutsideCells_LandblockLocalSphere_AddsDoorOutdoorCell — direct
unit test that demonstrates the fix
- FindTransitCellsSphere_IndoorExitPortal_AddsOutsideForCapturedSpherePos
— verifies cell-portal traversal at the captured sphere position
- DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection
— dat-direct EnvCell + Environment.Cells + portal-poly inspector
- Fixture: tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl
(tick 13558 walkthrough + tick 22760 outdoor block)
Visual verification (user-driven at Holtburg cottage door, ~50cm off-center):
- outside→inside RUN: now BLOCKS (was: walks through)
- outside→inside WALK: presumed blocks (not retested)
- inside→outside RUN: PARTIAL — body intersects door, sphere slides through
- inside→outside WALK: same partial behavior
The remaining inside→outside asymmetry is a SEPARATE bug in BSP
collision response for two-sided polygons. The [bsp-test] probe now
fires 245 times for the door entity from indoor (was 0 pre-fix) —
door IS being queried; the BSP polygon-level collision response is
the new bug. Handoff at
docs/research/2026-05-25-door-bug-partial-fix-shipped.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visual verification of Task 7 ship: doors block at dead-center (the
small Cylinder catches) but the BSP slab doesn't catch off-center
or inside-walking-out approaches. Probe-instrumented live capture
proves multi-part registration is correct — every door spawns with
shapes=cyl1+bsp1, and the BSP part is visited 135 times for a single
door at player approaches as close as 0.42 m, with cacheHit=True.
But zero [resolve-bldg] attributions for the BSP shape.
Three artifacts added:
1. TransitionTypes.cs — new [bsp-test] probe in the BSP collision
dispatch, fires BEFORE the cache lookup. Mirrors [cyl-test] on
the Cylinder branch. Distinguishes "cache miss → silent skip"
from "queried but no hit" (the latter doesn't show up in
[resolve-bldg] which only fires on attributed hits).
2. DoorCollisionApparatusTests.cs — new grounded test
(Apparatus_Grounded_50cmOffCenter_*) attempts to reproduce the
production bug via a seeded PhysicsBody (Contact + OnWalkable
+ ContactPlane + WalkablePolygon). Currently doesn't reproduce
because the apparatus's stub-terrain + synthetic-floor setup
diverges from production's real Holtburg geometry. Captured as
"documents-the-bug" — flip the assertion shape when the fix
lands.
3. docs/research/2026-05-24-door-collision-task7-shipped-but-bug-remains.md
— full session handoff. Identifies the remaining bug as a Path 5
(Contact branch + StepSphereUp) misbehavior at thin tall
obstacles, not in the multi-part registration we just shipped.
Leading hypothesis: DoStepUp's downward probe finds the same
flat floor on the OTHER side of the door (Holtburg cottages have
no Z change between exterior and interior floor), declares
step-up success, BSP collision returns OK, sphere walks through.
Recommended next move: relaunch with ACDREAM_DUMP_STEPUP=1 to
verify the hypothesis.
What this commit DOES NOT do: fix the remaining step-up bug. The
A6.P4 multi-part registration foundation is correct and stays.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the M1.5 "doors don't block in production" bug (alongside the
foundation fix at 3b7dc46). Server-spawned entities (doors, NPCs,
chests, items) now register one ShadowEntry per collision shape —
matching retail's CPhysicsObj-with-CPartArray model
(acclient_2013_pseudo_c.txt:286236) — instead of one Cylinder
approximation per entity.
Before:
RegisterLiveEntityCollision picked ONE shape via a CylSphere → Radius
→ Sphere cascade, registered as a single Cylinder. Doors got a
14 cm × 20 cm cylinder from setup.Radius — far too narrow to span
the doorway gap. Players could walk through closed doors.
After:
- ShadowShapeBuilder.FromSetup emits N shapes:
• one Cylinder per CylSphere
• one Cylinder per Sphere (only when no CylSpheres — retail
convention)
• one BSP shape per Part with a non-null PhysicsBSP
- Caller substitutes the real BoundingSphere.Radius from
PhysicsDataCache for BSP shapes (pure builder's 2.0 placeholder
is tightened to the actual cached value).
- setup.Radius fallback preserved: if the builder produces zero
shapes but Radius > 0, register a Radius-based Cylinder so simple
decorative props don't silently lose collision.
- ShadowObjects.RegisterMultiPart adds N rows, all sharing
entity.Id so the existing UpdatePhysicsState (ETHEREAL flip on
door Use) propagates to every part without changes.
Door 0x020019FF (Holtburg cottage) now registers:
- Cylinder r=0.10 h=0.20 (from the single Sphere)
- BSP from Part 0 = GfxObj 0x010044B5, the 6-face 1.925 m × 0.261 m
× 2.490 m two-sided slab confirmed by
DoorSetupGfxObjInspectionTests
Parts 1 + 2 (GfxObj 0x010044B6, the visual leaves) are visual-only
in the dat by retail design and correctly skipped.
Test impact: 53/53 pass in the shape / registry / door /
cellar-replay scope. App-layer 41/41 pass.
Visual verification needed: launch the client, walk into a closed
Holtburg cottage door from outside (dead center AND ~50 cm
off-center), then walk into it from inside. Door should block all
three approaches. Use the door (click + Use) → door swings open →
walking through passes (ETHEREAL flip via existing SetState path).
Foundation fix dependency:
3b7dc46 fix(phys): GetNearbyObjects dedup-by-entityId silently
drops multi-part shadows
Without 3b7dc46 in place, the BSP shape registered here would be
dropped by GetNearbyObjects's dedup. They land together.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apparatus test (DoorCollisionApparatusTests) loads door GfxObj 0x010044B5
from the real dat, builds the door entity's shape list via
ShadowShapeBuilder, registers via RegisterMultiPart, and sweeps a player
sphere into the door from three angles. Pre-fix: all three assertions
fail — the sphere walks straight through. The [cyl-test] probe fires
every tick (the small Sphere shape is queried) but no [resolve-bldg] —
the per-Part BSP entry is never reached.
Root cause: ShadowObjectRegistry.GetNearbyObjects deduplicates on
entry.EntityId via HashSet<uint>. Pre-RegisterMultiPart each entity had
exactly one shadow row, so dedup-by-entityId correctly suppressed
multi-cell duplication. After Task 4's RegisterMultiPart introduced
multi-shape rows (1 Sphere + 1 per-Part-BSP for doors; potentially more
for creatures + items), the dedup silently drops everything after the
first. ShadowShapeBuilder emits Sphere shapes before Part-BSPs, so the
Sphere wins and the BSP is dropped — exactly the "Task 7 produced zero
[resolve-bldg] hits" finding from the 2026-05-24 evening handoff.
Fix: dedup on the full ShadowEntry. record-struct equality compares
all fields (EntityId, GfxObjId, Position, Rotation, Radius,
CollisionType, CylHeight, Scale, State, Flags, LocalPosition,
LocalRotation). Distinct shapes of the same entity are not equal and
make it through; the same shape registered in multiple cells (its
fields identical across calls) dedups exactly as before.
Apparatus verification post-fix: all 4 tests pass.
- Dead-center front approach: BLOCKED at Y=11.5 normal=(0,-1,0).
- 50 cm off-center: BLOCKED at Y=11.5 normal=(0,-1,0).
- Back approach from inside: BLOCKED at Y=12.8 normal=(0,+1,0).
- Diagnostic dump: BSP fires at tick 5.
What this fix DOES NOT do: switch live RegisterLiveEntityCollision to
use ShadowShapeBuilder + RegisterMultiPart. That's Task 7 of the
original plan, still reverted. With this foundation fix in place,
Task 7 should now actually deliver door blocking in production.
Test impact: 44/44 in the shape/registry/door scope pass. The broader
Physics suite shows the pre-existing PhysicsResolveCapture
static-state flakiness documented in CLAUDE.md — 6 baseline failures
without my new tests, 10 with them (4 extra are my apparatus tests'
IsPlayer-flag resolves getting captured by a concurrent Capture-test
race). Independent of this fix; verified by isolating each test
class.
Findings + apparatus reasoning:
docs/research/2026-05-24-door-dat-inspection-findings.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a one-line diagnostic per Cylinder ShadowEntry tested in
FindObjCollisions, gated on ProbeBuildingEnabled. Useful for the
door-collision investigation surfaced 2026-05-24: tells us whether
the broadphase returned a candidate door AND what CylinderCollision
decided (OK / Collided / Adjusted / Slid).
Off in normal play (probe flag off by default). General-purpose; not
door-specific.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-part entities cached via RegisterMultiPart's _entityShapes now
recompose all part transforms on UpdatePosition (called when the server
broadcasts UpdatePosition (0xF748) for a moving entity). Legacy
single-shape path preserved unchanged for tests + entities that never
went through RegisterMultiPart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-shape entity registration matching retail's CPhysicsObj model: one
logical entity emits N ShadowEntry rows (one per CylSphere / Sphere /
Part-BSP), all sharing the entity's EntityId. _entityShapes caches the
original shape list per entity for UpdatePosition to recompose part
transforms when the entity moves.
Existing UpdatePhysicsState / Deregister / GetObjectsInCell /
AllEntriesForDebug work unchanged — they iterate by EntityId; multiple
matching entries get handled automatically.
AllEntriesForDebug updated to enumerate all parts per entity (not just
the first) by iterating the first cell that holds entries for each entity.
Single-shape callers that previously relied on deduplicated-by-EntityId
behavior are unaffected since they register exactly one entry per entity.
Six new tests: AllShareEntityId, EmptyShapeList_NoOp,
Deregister_RemovesAllParts, UpdatePhysicsState_PropagatesEtherealToAllParts,
PartsAcrossMultipleCells_AllCellsListed, Register_SingleShapeCompat_Unchanged.
All 24 existing ShadowObjectRegistry tests pass via the unchanged
single-shape Register API. 11/11 CellarUpTrajectoryReplayTests pass.
7/7 ShadowShapeBuilderTests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Local-to-entity transform fields, default-valued so existing single-shape
callers keep working unchanged. RegisterMultiPart (next commit) populates
them per part so UpdatePosition can rebuild the entry's world Position +
Rotation when the entity moves.
All 24 existing ShadowObjectRegistry tests pass (including the 2 new
slice 1 tests from b49ed90).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure function translating Setup -> IReadOnlyList<ShadowShape>. Walks
CylSpheres + Spheres (only when no CylSpheres) + Parts (only when the
GfxObj has a non-null PhysicsBSP), using PlacementFrames in the same
Resting -> Default -> first-available priority as SetupMesh.Flatten.
Six tests pin the behavior: door setup produces 4 shapes (0+1+3), sphere
local offset matches Setup data, parts without BSP are skipped, creature
setups with CylSpheres skip Spheres, scale factor multiplies all radii
and offsets, empty setup returns empty list, null setup throws.
No callers in this commit; RegisterMultiPart + the GameWindow callers
follow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Standalone record representing one collision-bearing shape attached to
a logical PhysicsObj. Foundation for the per-part BSP collision fix
that closes the M1.5 "doors don't block" bug. Spec at
docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md.
No callers in this commit; integration follows in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#99 (run-through doors regression from b3ce505).
The b3ce505 stopgap for #98 gates the outdoor 24m radial sweep on indoor
primary cells. Combined with ShadowObjectRegistry.GetNearbyObjects'
"skip outdoor ids" filter on the cellScope-pass loop, this meant doors
registered at outdoor cells (default cellScope=0u for server-spawned
entities at GameWindow.cs:3139) were invisible to spheres on the indoor
side of a doorway threshold — walk-through.
Pre-flight reads found that CellTransit.FindCellSet already adds
outdoor cells to its candidate set when the sphere straddles an
OtherCellId=0xFFFF exit portal (via AddAllOutsideCells triggered by
exitOutside=true inside the indoor-seed BFS). The fix is to stop
filtering those outdoor ids out before iterating, and rename the param
to portalReachableCells to reflect what the set actually contains.
- Q1: Indoor EnvCell.VisibleCellIds is indoor-only in all 16 cottage
fixtures (low 16 bits ≥ 0x0100). OtherCellId=0xFFFF on portals
marks "exit to outdoor world" without naming a specific cellId; the
specific outdoor cell is computed by AddAllOutsideCells from world
XY when the sphere straddles the exit portal.
- Q2: GameWindow.cs:3139 ShadowObjects.Register for server-spawned
entities passes no cellScope → default 0u → outdoor 24m grid
registration. UpdatePosition (line 145) does the same on movement.
Doors are confirmed outdoor-registered.
Slice 1 makes a smaller change than the spec proposed (no new
parameter; just drop the existing filter), because FindCellSet's
existing exit-portal logic already exposes the needed outdoor cells.
The retail-faithful registration-side BuildShadowCellSet refactor and
the b3ce505 gate removal stay scheduled for slices 2-3.
Verification:
- 24/24 ShadowObjectRegistryTests pass (incl. two new slice 1 tests:
IndoorPrimary_OutdoorCellInPortalSet_DoorReturned closes#99;
IndoorPrimary_IndoorOnlyPortalSet_OutdoorRadialStillSkipped
regression-pins #98)
- 11/11 CellarUpTrajectoryReplayTests pass (LiveCompare_FirstCap_
FixClosesCottageFloorCap stays green)
- dotnet build AcDream.slnx: 0 errors, 0 warnings
- Pre-existing 6-8 static-state-leakage failures in serial physics
suite verified unchanged by stash+retest baseline check
Visual verification pending: walk Holtburg cottage doorway from both
sides; door blocks both directions; cellar still climbable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cellar-up cap was caused by ShadowObjectRegistry.GetNearbyObjects
running its outdoor 24m-grid radial query unconditionally — including
when the moving sphere's primary cell is indoor. The landblock-baked
cottage GfxObj 0x01000A2B (registered with cellScope=0u, i.e.
landblock-wide) was returned for a sphere inside the cellar EnvCell,
and its downward-facing cottage-floor poly at world Z=94 head-bumped
the sphere from below, capping ascent at foot Z=92.74.
Diagnosis this session via the live capture in
a6-issue98-resolve-capture-2.jsonl (92K records, 132 cap events all
with body on the ramp polygon) FALSIFIED the prior "stale ramp
contact plane" hypothesis: the contact plane is correctly the ramp's
plane because the sphere IS on the ramp at the cap. The cap is a
proximate consequence of the cottage GfxObj being queried at all from
an indoor primary cell.
Retail decomp anchor (acclient_2013_pseudo_c.txt):
- 308751-308769: CObjCell::find_cell_list branches on the moving
object's m_position.objcell_id — INDOOR adds only that cell +
portal-visible neighbors via CELLARRAY::add_cell; OUTDOOR adds
all overlapping outdoor cells via CLandCell::add_all_outside_cells.
Object-position-driven, not sphere-radius-driven.
- 309560: CEnvCell::find_collisions calls find_env_collisions
(own cell BSP only) THEN CObjCell::find_obj_collisions on `this`.
- 308916: CObjCell::find_obj_collisions iterates this->shadow_object_list
— strictly per-cell, never landblock-wide.
Combined: a landblock-baked static like the cottage building is added
to outdoor cells' shadow_object_list only (its m_position resolves to
an outdoor cell). An indoor EnvCell's shadow_object_list never
contains the cottage. CEnvCell::find_collisions therefore never tests
the sphere against the cottage. Retail-faithful behavior.
Falsification spike (this session): scoping the cottage to a single
distant outdoor cell instead of landblock-wide caused the harness
LiveCompare_FirstCap test to stop reproducing the cn=(0,0,-1) cap,
confirming the cap is caused by the radial sweep returning the
cottage to an indoor primary.
The fix:
- Add optional `primaryCellId` parameter to
ShadowObjectRegistry.GetNearbyObjects. When indoor (>= 0x0100),
skip the outdoor radial sweep entirely after the indoorCellIds
branch runs. Default 0u preserves prior behavior for
cell-unaware callers (existing tests pass unchanged).
- Transition.FindObjCollisions passes sp.CheckCellId.
- Harness LiveCompare_FirstCap_* flipped to documents-the-fix form
(asserts the downward-facing cottage-floor cap does NOT fire).
Deletes the residual-X-motion test that documented a post-cap
edge-slide — irrelevant once the cap is gone.
This same gate should close the other "Finding 3 family" indoor/outdoor
collision bugs (#97 phantom collisions, indoor sling-out). Visual
verification by the user is the remaining acceptance check before
closing #98.
Verification:
- 11/11 CellarUpTrajectoryReplayTests pass in isolation
- 55 ShadowObjectRegistry + TransitionTypes + PhysicsEngine
+ CellPhysics + CellTransit tests pass
- 8 pre-existing static-state-leakage failures in serial physics
suite are unchanged (verified by stash + retest on baseline)
- dotnet build clean, 0 warnings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the existing ACDREAM_DUMP_CELLS pattern for GfxObj-owned geometry:
when ACDREAM_DUMP_GFXOBJS lists a hex GfxObj id, the first
PhysicsDataCache.CacheGfxObj for that id writes the full resolved
polygon table to a JSON fixture under
tests/AcDream.Core.Tests/Fixtures/issue98/0x{id:X8}.gfxobj.json (override
dir via ACDREAM_DUMP_GFXOBJS_DIR).
Motivation: the existing [resolve-bldg] probe captures GfxObj-level
metadata (id, BSP root radius, entity origin) but emits
"hitPoly: n/a (BSP path — side-channel not written)" because the
BSPQuery wire site that would populate LastBspHitPoly never landed.
A polygon-level dump at cache time bypasses that gap — one capture run
yields the FULL polygon table, fixture-loadable by the harness's
RegisterCottageGfxObj helper (next commit).
See docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md
for the cottage GfxObj 0x01000A2B context: landblock-baked static at
entity origin (130.5, 11.5, 94.0), responsible for the head-sphere cap
from below at world Z=94.0 that issue #98 is documenting.
Test baseline: 1183 + 8 pre-existing failures (serial run; +5 new tests
all pass; was 1178 + 8 pre-session).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apparatus only — no fix attempt. Per the systematic-debugging skill's
"3+ failures = question architecture" rule, the 6 hypotheses we
tested speculatively on the harness's airborne-at-tick-1 bug all
failed because we kept guessing what state the harness lacks. This
commit ships the evidence-driven path: capture the EXACT player
ResolveWithTransition call (every input + body-before + body-after +
result) into a JSON Lines fixture, then a comparison test loads the
fixture and replays it against the test engine. The first per-field
divergence pinpoints the missing apparatus state — no more guessing.
Adds:
- src/AcDream.Core/Physics/PhysicsResolveCapture.cs — new static module
with CapturePath (env var ACDREAM_CAPTURE_RESOLVE), PhysicsBodySnapshot
record, JSON Lines writer (thread-safe, flushes per record), process-
exit hook for clean shutdown.
- PhysicsEngine.ResolveWithTransition probe wiring: snapshot body at
method entry, snapshot again before return, refactor the two returns
into one path so the capture call site is single. Filtered to
IsPlayer mover flag so NPC/remote DR calls don't pollute.
- CellarUpTrajectoryReplayTests.cs:
• Capture_WritesJsonLinesRecordsWhenIsPlayerAndEnabled — drives 3
ticks with capture on, reads file back, verifies round-trip of
inputs + body-before/after snapshots.
• Capture_SkipsNonPlayerCalls — drives 3 NPC-style ticks (no
IsPlayer flag), confirms the file is not created.
Off by default. Set ACDREAM_CAPTURE_RESOLVE=<path> to a writable file
path; capture starts on the next player ResolveWithTransition call.
Test baseline: 1172 + 8 pre-existing failures + 2 new smoke tests
that pass = 1174 + 8. Verified by stashed-baseline comparison.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds PhysicsGlobals.ContactPlaneFlatThreshold = 0.99f and uses it at
both BSPQuery.AdjustSphereToPlane call sites that previously set CP
unconditionally on any walkable polygon found by FindWalkableInternal.
Backed by the retail cdb capture in cellar_up_capture_1: across 161
set_contact_plane writes during 5 seconds of cellar-up climbing,
EVERY write lands on a flat (Normal.Z = 1.0) plane — cellar floor at
world Z=90.95 or cottage floor at world Z=94. The cellar ramp
(Normal.Z = 0.695, walkable per FloorZ but sloped ~46 degrees) is
never set as CP in retail.
Acdream's prior behavior of setting CP=ramp caused two cascading
issues at the top of the ramp:
1. AdjustOffset's slope-projection produced +Z gain per call (correct
in isolation) but inflated step-up's responsibility to "find the
next walkable below the lifted check position".
2. step-up's downward step-down probe found no walkable within 0.6m
below the proposed check (cottage floor at Z=94 is ABOVE, not
below), so step-down rejected, sphere rolled back. Infinite freeze
at world Z ~= 92.80.
With CP only set on flat polygons, sloped surfaces drive collision
detection and walkable-poly tracking (via path.SetWalkable) but
don't override the resting CP. The sphere should now climb the ramp
via step-up over the ramp polygon, with CP staying on the flat
cellar floor until the sphere reaches the flat cottage floor.
Tests: 1167 + 8 baseline maintained. No regression. The Issue98
replay tests still pass — they document the failing-frame geometry
(sphere world Z=92.01 below cottage floor), which doesn't change;
the fix prevents the sphere from getting STUCK at that altitude in
the first place. Live visual verification required next.
If the live test shows new failure modes (sphere stuck somewhere
else, doesn't climb at all, climbs but slides off, etc), the
threshold (0.99) or the gating approach itself may need refining.
This is the conservative empirical version of Shape 1; the named-
decomp research did not conclusively prove the exact retail gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>