Replace the A6.P4 Collided shortcut in transitional_insert's neg_step_up==0
branch with the faithful CSphere::slide_sphere. Retail
(acclient_2013_pseudo_c.txt:273350-273351, CTransition::transitional_insert):
neg_step_up==0 (HEAD-sphere near-miss) -> slide_sphere -> continue the insert
loop; neg_step_up==1 (foot) -> step_up_slide. The acdream foot branch already
did that; only the head branch took the shortcut (SetCollisionNormal + return
Collided = dead hard-stop). The slide itself is the existing
SlideSphereInternal (Sphere.SlideSphere port): it strips the into-wall
component and keeps the tangential crease (collisionNormal x contactPlane.N).
Surfaced by the B1 near-miss-gate fix (abbd761): once the grounded mover climbs
onto the cottage floor, its head sphere brushes the cellar stairwell walls and
the old hard-stop wedged it (2026-06-04 live capture: 274 (0,-1,0) + 78
(1,0,0) hits, out==current, dead oscillation). Post-fix capture shows 96
hit-and-advanced frames (the body slides along the walls).
Visual-verified 2026-06-04: closed cottage door still BLOCKS (no walkthrough --
drifts sideways along it, retail-faithful); cellar ascent now works (was always
stuck). An intermittent corner-wedge (slide into the -Y/+X wall corner) remains
-- separate finer issue, under investigation.
Core 1310 pass / 4 fail (pre-existing: 3 door documents-the-bug + D4 airborne).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2026-06-03 handoff localized the failing Core tests to the BSP Path 5
step-up CLIMB (find_walkable/step_sphere_down). An ITestOutputHelper capture
of B1 disproved that: the climb code is correct (matches ACE
Polygon.adjust_sphere_to_plane / BSPTree.step_sphere_down exactly). The real
bug is the A6.P4 near-miss dispatch in FindCollisions' Path 5 (Contact
branch), which diverged from retail three ways:
1. Recorded a near-miss NegPolyHit UNCONDITIONALLY. Retail gates both
set_neg_poly_hit calls behind `if (num_sphere > 1)`
(acclient_2013_pseudo_c.txt:323852).
2. Checked the foot sphere's near-miss before the head's. Retail checks
the head (sphere1) first.
3. Mapped foot->neg_step_up=false / head->true. Retail maps head(index 0)
->false (slide), foot(index 1)->true (step-up), per
SPHEREPATH::set_neg_poly_hit (:323279, neg_step_up = arg2).
For B1's single foot sphere, the spurious near-miss -> outer loop
`!NegStepUp -> SetCollisionNormal + Collided` -> revert: the grounded mover
wedged at x=0.1 and never advanced to the wall to step up. With the verbatim
gate, a single-sphere near-miss records nothing, the sphere advances,
full-hits the wall, and step_sphere_up climbs the 0.25 m step (verified via
probe capture: foot ends at (0.6, 0, 0.25)).
The Holtburg cottage door still blocks faithfully (door slab (0,-1,0) normal,
stops in front of the door) when the scenario has a real floor — confirmed
this change does not regress the door.
The two BSPQueryTests Path5 near-miss tests used a single sphere (the very
non-retail assumption that caused this wedge); converted to the production
2-sphere shape where the head sphere records the near-miss, matching retail.
Core 1312 pass / 4 fail (the 4 pre-existing: 3 door documents-the-bug + D4
airborne, none regressed here); App 177 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cottage doorway 'void' (dark cell + floating entities while the chase camera looks
through the opening): PortalProjection.ProjectToNdc clipped portals on w+z>=0 — the GL
[-1,1] near-plane test — but acdream's camera builds its projection with D3D-convention
Matrix4x4.CreatePerspectiveFieldOfView and a 1.0 m near plane (RetailChaseCamera). Against
that matrix w+z>=0 discards everything within ~0.5 m of the eye, so when the camera orbits
to ~0.1 m from a doorway portal the near edge is clipped, the far edge projects off-screen
([flap] showed p->0171 D=0.10 proj=4 clip=0 ndc Y=-3.5..-6.6), the room behind is culled
(vis=1) and only the tiny vestibule shell draws -> dark void. Rotating away moved the eye
off the portal -> vis=5 -> room rendered.
Fix: clip against the EYE (w > MinW, MinW=0.05 m), near-INDEPENDENT — a portal you're
standing in still projects (covers the screen) so the cell behind stays visible. We only
use the projected x/y for the visibility clip region, so keeping vertices in front of the
near plane is correct. Matches retail PView::GetClip near-clipping the portal before project.
RED->GREEN regression test (doorway 0.1 m from a near=1.0 eye); 177 App tests green; the
existing straddling ±50 bound still holds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase W single-viewpoint V1 (un-split). The render mode decision, indoor root, and portal
side-test now key on the collided-camera viewer cell + eye (RetailChaseCamera.ViewerCellId +
camPos) — retail RenderNormalMode -> DrawInside(viewer_cell) @92675; InitCell side-test vs
viewer.viewpoint @432991. Lighting / seen_outside / playerInsideCell stay on the PLAYER cell
(CurrCell), retail CellManager::ChangePosition @4559B0. The old per-render player-root +
eye-projection split (U.4c) is removed; the flap is avoided by the robust graph-tracked viewer
cell (no AABB, no grace). [flap-cam] probe extended with viewerCell vs playerCell. CurrCell
stays player-only (blue-hole fix intact). App 176 green; Core 1295/5 baseline (no new fails).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update() now always sets ViewerCellId: the camera-collision sweep's swept cell when collision
is on (retail viewer_cell = sphere_path.curr_cell), else the passed player cell. This is the
robust, per-frame, graph-tracked 'which cell is the camera in?' answer that V1 roots the render
on — no AABB, no grace frames (the U.4c flap source). 176 App tests green (2 new).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The camera spring-arm sweep already resolves the collided eye's cell (ResolveResult.CellId
= sp.CurCellId = retail viewer_cell = sphere_path.curr_cell, update_viewer pc:92871).
Return it from SweepEye so the render can root on the viewer cell (Phase W single-viewpoint
V1, Task 1). Pure plumbing — behavior unchanged; callers extract .Eye. 174 App tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
THE doorway flap root cause, found via [flap-cam]/[shell]/[cell-transit] (2026-06-03):
the player spawned + stood still in the room (cell 0171, NO [cell-transit] after teleport),
yet the render rooted at the vestibule (0170) for all 77,951 frames — drawing only 0170's
~8-triangle shell, the rest = GL clear color = the bluish void.
CellGraph.CurrCell IS "the player's cell" (the render root), but it was written by
SetCurrAndReturn inside the PER-ENTITY ResolveWithTransition + ResolveCellId — so EVERY NPC
wrote it. A Holtburg NPC (0x000F4240) jump-looping near the doorway clobbered the player's
render root every tick. Standing still (player makes no resolve calls) the NPC's write wins
→ stuck blue void; moving, player/NPC writes fight → the flap. This is why the membership
pick fix (correct, kept) didn't change the visual — the render root was clobbered regardless.
Fix: CurrCell is now written ONLY by the player. New PhysicsEngine.UpdatePlayerCurrCell is
called from PlayerMovementController.UpdateCellId — the single player-only chokepoint for
CellId (teleport / server snap @ SetPosition + per-frame resolver). Removed the CurrCell
write from SetCurrAndReturn (inlined the 2 resolve call sites to sp.CurCellId) and the 4
ResolveCellId sites. NPCs no longer touch the render root. Teleport→UpdateCellId also covers
spawn/standing-still (CurrCell = the player's spawn cell immediately).
CellGraphMembershipTests rewritten to the new contract (3 tests): UpdatePlayerCurrCell writes
the render root; ResolveCellId does NOT (the blue-hole guard); stale-beats-null preserved.
Full Core suite: 1295 pass / 5 fail = the documented §10 baseline, zero new breakage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ordered pick alone did not stop the cottage doorway flap: the live [cell-transit]
log showed the cell faithfully following a position that cleanly oscillated between two
values at constant Z — the signature of a bistable membership<->collision feedback loop
(user's §4.4 #3, the forked collision).
Root cause: FindEnvCollisions RE-PICKED the cell from the TARGET position (old line 1958)
BEFORE running the primary collision, so the collision geometry (which cell BSP / terrain)
swapped the instant the pick flipped -> position shifts -> pick flips back.
Retail does NOT do this. CEnvCell::find_env_collisions (acclient_2013_pseudo_c.txt:309573)
collides against the cell it was called ON (sphere_path.check_cell, the carried seed) and
picks the NEW containing cell AFTER, in CTransition::check_other_cells (272717->272761:
check_cell = var_4c). Collide-then-pick.
This commit ports that order:
- remove the pre-pick (production); collide against the carried cell (indoor BSP block /
terrain block unchanged);
- new shared RunCheckOtherCellsAndAdvance() runs the ordered FindCellSet pick +
multi-valued CheckOtherCells + the carried-cell advance AFTER the primary collision, for
BOTH indoor and outdoor seeds;
- the outdoor-seed post-step replaces the removed pre-pick's outdoor->indoor re-entry
promotion (CheckBuildingTransit interior cell + its wall collision on the entry frame).
Cache-null unit-test fallback (ResolveCellId) kept. Full Core suite: 1293 pass / 5 fail =
the documented §10 baseline exactly (2 step-up + 3 door-collision), zero new breakage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port CObjCell::find_cell_list (acclient_2013_pseudo_c.txt:308742) faithfully:
- build candidates into an ordered CellArray with the CURRENT cell at index 0
(add_cell @308766);
- EXPAND via a single forward walk over the growing array, mirroring retail's
for(i=0;i<num_cells;i++) cells[i].find_transit_cells loop (308775-308785),
replacing the order-losing Queue/visited BFS;
- PICK in array order with interior-wins-break (308788-308825): current cell at
index 0 wins a boundary straddle, so membership no longer ping-pongs.
Deletes the 5ca2f44 current-first pre-check (the ordered array subsumes it for every
seed). Keeps its guard test (TwoOverlappingCells_CurrentCellWinsTheStraddle) + adds
two conformance tests (current-cell-first ordering; interior-wins over outdoor
fallback). Membership net: 45 pass. Decomp finding: retail stability is emergent from
the ordered pick + carried seed, not a separate portal-crossing detector — see
docs/research/2026-06-03-cell-membership-ordered-cellarray-pseudocode.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Non-behavioral: lets BuildCellSetAndPickContaining pass an ordered CellArray (next
commit) while existing HashSet-passing test callers compile unchanged. HashSet<uint>
and CellArray both implement ICollection<uint>. Core builds; 9 helper tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ports retail CELLARRAY::add_cell (acclient_2013_pseudo_c.txt:701036): ordered list,
dedup by cell_id, append at end. The order is load-bearing for the verbatim
find_cell_list current-cell-first interior-wins pick (next commits) that fixes the
R1 cottage membership flap. Implements ICollection<uint> (helper-facing) +
IReadOnlyCollection<uint> (consumer-facing). 5 unit tests.
Also lands the membership-port pseudocode (workflow step 3) + the Stage-1 plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The flap R1 exposed is a cell-membership ping-pong: the find_cell_list containing-
cell pick (CellTransit.BuildCellSetAndPickContaining) iterated an UNORDERED HashSet
and returned the first interior cell whose BSP contains the sphere center, with no
preference for the current cell. Retail CObjCell::find_cell_list adds the current
cell at index 0 (add_cell, pc:308766) and iterates current-first with interior-wins-
break (pc:308791-308819) — you STAY in your current cell until the center genuinely
leaves it. acdream's HashSet dropped that ordering; once the candidate set churns at
a boundary the enumeration can surface a neighbour before the current cell → the
ping-pong. Restore the explicit, deterministic current-cell-first test (retail's
index-0 hysteresis). + a two-direction regression guard (current cell wins the
straddle).
Diagnosed from the existing [cell-transit] walk log (no new probing): room flips are
the pick non-determinism; stairs flips additionally show the foot Z oscillating
~0.2m/tick (a separate stairs-physics residual, #98 family, to verify after this).
The 2 DoorBugTrajectoryReplay failures are PRE-EXISTING (verified: they fail without
this change too) — 2 of the handoff's '3 door-collision apparatus / A6.P5'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EntityPassesVisibleCellGate no longer returns true unconditionally for outdoor
scenery under a cell filter (was the headline #78 bleed). Outdoor scenery now
draws only via the unfiltered bucket (visibleCellIds: null) + ResolveEntitySlot's
OutsideView routing. The outdoor-root global Draw passes visibleCellIds: null
(no portal-cell scoping outdoors; retires VisibleCellIds as a render gate — peering
into buildings is R5). Updated the EntityClipTests case that pinned the old bypass
(Included -> Excluded). 174/174 App tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GameWindow.OnRender: when clipRoot != null, run only InteriorRenderer.DrawInside
(per-cell shells + per-cell objects + live-dynamics); the global entity pass +
global shell pass are no longer issued indoors. Outdoor scenery drawn clipped to
the doorway (after terrain, before the Z-clear). Outdoor root path unchanged.
pvFrame hoisted so the splice reads OrderedVisibleCells; per-frame 3-bucket
partition built on the indoor root. Retail RenderNormalMode @ 0x453aa0.
InteriorRenderer amended with a DrawableCells membership filter (an IsNothingVisible
cell can be in OrderedVisibleCells but absent from CellIdToSlot — iterate for ORDER,
filter for membership; matches the old envCellShellFilter set exactly).
Build green, 174/174 App tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-cell flood: closest-first over OrderedVisibleCells, per cell draws the closed
shell (EnvCellRenderer.Render(pass,{cellId})) + that cell's objects, then live-
dynamics unclipped, then transparent shells. Reuses the existing dispatcher Draw
per cell (safe to call N x/frame; only diagnostic GPU-timing miscounts). Caller
owns the landscape-through-door + Z-clear. Not yet wired (Task 3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pure helper splitting a frame's entities into live-dynamic / per-cell statics /
outdoor scenery, by the same precedence as WbDrawDispatcher.ResolveEntitySlot
(serverGuid first — live entities have no ParentCellId). Feeds the per-cell
DrawInside loop. 3 unit tests, GL-free.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opus adversarial review caught a real gap: the sky/weather MESH bled full-screen indoors in TerrainClipMode.Scissor (a multi-exit interior, or an OutsideView with >8 edges). The assembler only sets the binding=2 clip-plane UBO in Planes mode; in Scissor mode it leaves count==0, so sky.vert's gl_ClipDistance writes all +1 (no clip) and the mesh draws — which had NO scissor wrapper, only the no-op planes — covered the whole screen. The terrain and particle passes were already scissored; the sky/weather mesh was the one unguarded path.
Fix: scissor the WHOLE sky pre-scene + weather post-scene blocks (mesh + particles) to the OutsideView AABB when indoors. In Planes mode the scissor is a harmless over-include (the per-vertex clip planes are tighter and do the exact doorway clip); in Scissor mode it is the sole confinement, mirroring the terrain Scissor path; outdoors it is skipped (full-screen, bit-identical). Also hoisted the scissor-disable out of the particle null-check (cleaner, leak-free on the no-particle path) and corrected a stale 'weather does not write gl_ClipDistance' comment at the world-bracket close.
The single-convex-doorway case (Holtburg cottage) was already correct (Planes mode); this seals the multi-opening case. Build 0/0; App tests 171/171.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
T4.4: annotate EnvCellRenderer.RegisterCell to document that ceilings are present by
construction. PrepareCellStructMeshData iterates ALL CellStruct.Polygons (floor + walls +
ceiling) with no surface filter; retail PView::DrawCells draws the same closed-box
drawing_bsp. No ceiling filtering confirmed.
T4.2: annotate TerrainModernRenderer.Draw to document that terrain projects from the
passed-in ICamera (uView + uProjection derive from the same camera as all other
renderers). No separate landscape viewpoint exists that could desync from the eye.
T4.5, T5.1, T5.2: pure verification — no code changes (see report).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sky + weather (rain cylinder) are retail's LScape — 'the outside seen through the exit portal.' Retail PView::DrawCells (pseudo_c:432709) draws LScape clipped to the OutsideView when outside_view.view_count>0, then does a conditional Z-buffer-ONLY clear (432731) before the indoor cells. acdream now does the same:
- sky.vert writes gl_ClipDistance against the SAME binding=2 TerrainClip UBO the terrain reads. The OutsideView planes are screen-space (NDC) half-spaces encoded as clip-space planes (nx,ny,0,dw); the test dot(plane,gl_Position)>=0 reduces after perspective divide to nx*ndcX+ny*ndcY+dw>=0 — projection-INDEPENDENT — so the same plane set clips the sky EXACTLY despite its separate dome projection. count==0 (outdoor) → all distances +1 → full-screen, bit-identical. Lighting/fog math untouched.
- GameWindow: relocated the sky pre-scene + weather post-scene draws to their retail LScape positions, each in a local 8-plane clip bracket so sky.vert confines them to the doorway indoors / full-screen outdoors. Added the conditional doorway depth-ONLY Z-clear (no color → no blue hole), scissored to the OutsideView AABB. drawSkyThisFrame = seen_outside policy AND (outdoor OR exit-portal-in-view) — a sealed interior with no exit portal in view draws no sky (kills the full-screen-sky interim regression). Sky pre/post particle passes (particle.vert has no gl_ClipDistance) scissored to the doorway bbox.
- ClipFrameAssembly gains HasOutsideView + OutsideViewNdcAabb (the doorway NDC AABB, computed for BOTH Planes and Scissor terrain modes — unlike TerrainScissorNdcAabb which is Scissor-only).
- The pre-login goto SkipWorldGeometry moved BELOW the sky draw so the live sky still renders during the EnterWorld handshake (clipAssembly is null/no-clip pre-login → full-screen).
Build green; App tests 160/160. Stage 4 tests + verify-annotations follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port CEnvCell::find_visible_child_cell @ 0x0052dc50 (pseudo_c:311397): walk
rootId's StabList + the root itself, return the first EnvCell whose
PointInCell is true for worldPoint. Used to resolve the camera cell in
3rd-person from the physics cell graph rather than a fresh AABB reclassification.
Root is always the player cell (preserves U.4c flap fix). Returns null when
no stab-list cell or root itself contains the query point.
Confirmed (T3.3 Step 1): no production call site uses FindCameraCell for the
camera projection — the only AABB camera resolver is now deleted (T3.1).
FindVisibleChildCell is wired implicitly via Stage 4 (camera-outside-door scenario);
no GameWindow call site needed in Stage 3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ComputeVisibilityFromRoot(null, …) now returns null (outdoor root) instead of
calling FindCameraCell(fallbackPos). Retail CellManager::ChangePosition
(0x004559B0) reads the transition-owned curr_cell — it does NOT re-derive from
a static position. W2a guarantees CurrCell is set from the first tick, so the
AABB fallback is dead. Deleted: FindCameraCell (389–446), _lastCameraCell,
_cellSwitchGraceFrames, CellSwitchGraceFrameCount. GetVisibleCells retains a
brute-force AABB scan for test-compat; ComputeVisibility stays for the same
reason. Updated 3 null-root tests in CellVisibilityFromRootTests to assert the
new null-returns-null behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
xUnit's default parallel execution let diagnostic-harness tests (CellarUp,
DoorBug, DoorCollisionApparatus) mutate PhysicsResolveCapture.CapturePath
and PhysicsDiagnostics probe flags concurrently with victim tests
(MotionInterpreter, PositionManager, PlayerMovementController,
DispatcherToMovement, BSPStepUp), producing a flaky 14-26 failure range.
Fixes:
- Add PhysicsResolveCapture.ResetForTest() + PhysicsDiagnostics.ResetForTest()
as documented test-only reset APIs (never called from production paths).
- Add IDisposable to CellarUpTrajectoryReplayTests with ctor/Dispose calling
both ResetForTest() — prevents CapturePath from leaking between the Capture_*
tests in the same class (the immediate root cause of Capture_SkipsNonPlayerCalls
finding an unexpected file).
- Add xunit.runner.json (maxParallelThreads=1, parallelizeTestCollections=false)
to AcDream.Core.Tests — eliminates parallelism-induced probe-flag leaks across
all test classes without requiring [Collection] boilerplate on every offender.
After: two consecutive runs produce the identical 12-failure set.
Confirmed: LiveCompare_FirstCap_FixClosesCottageFloorCap passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Change A (TransitionTypes.FindEnvCollisions:~1947): replace the unconditional
static ResolveCellId re-derive with the SWEPT find_cell_list pick via
CellTransit.FindCellSet. When DataCache is available (always in production),
the swept pick runs and resolves the containing cell from the portal-graph
candidate set. When DataCache is null (test engines without a cell registry),
the old ResolveCellId fallback is preserved to keep PhysicsEngineTests green.
Change B (CellTransit.BuildCellSetAndPickContaining): replace the containment
loop that silently skipped all outdoor candidates (CellBSP=null) with the
retail CObjCell::find_cell_list interior-wins pick (pseudo_c:308788-308819):
interior EnvCells win first; if no interior cell contains the center, fall
to the outdoor XY-grid column (CLandCell::point_in_cell equivalent). This is
the missing half of find_cell_list that caused the 0xA9B40170↔0xA9B40031
doorway cell-strobe — the swept pick previously always returned currentCellId
for outdoor candidates, letting the static re-derive at :1947 strobe on every
tick from a different result.
DoorwayMembershipReplayTests: two facts, loads doorway-capture.jsonl (364K records,
strobing live run), filters to Y∈[15.5,17.5] seam zone (57 records), verifies
FindCellSet produces exactly 1 transition (enter indoor → stay outdoors) with
zero A→B→A ping-pong across the full window. Second test verifies outdoor-seed
records round-trip correctly via the XY-grid formula. Both pass.
LiveCompare_FirstCap_FixClosesCottageFloorCap: still passes (issue #98 gate intact).
Full Core suite: 15 failures (within documented flaky baseline of 14–19;
all 15 are pre-existing static-leak/document-the-bug tests, zero new regressions
in cell/transit/BSP/physics classes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace ResolveCellId(sp.GlobalSphere[0].Origin, ...) with SetCurrAndReturn(sp.CurCellId)
in both the OK and partial paths of ResolveWithTransition. Retail's
SetPositionInternal reads sphere_path.curr_cell which ValidateTransition
advances only on accepted moves and reverts on blocks — so a push-back or
standing-still tick cannot flip the cell. The static re-derive from the
resting origin strobes between outdoor 0031 and indoor 0170 at doorway
boundaries because the origin lands just outside the indoor BSP volume
after push-back; the swept cell doesn't.
SetCurrAndReturn is kept in both paths so the W2a CellGraph.CurrCell write
that the render root consumes still fires. ResolveCellId is NOT deleted —
it still has one caller at TransitionTypes.cs:1947 (AddAllOutsideCells).
partialCellId is kept as the degenerate fallback when sp.CurCellId==0
(teleport / physics reset before any transition has run).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add ProbeSweptEnabled (ACDREAM_PROBE_SWEPT=1) to PhysicsDiagnostics mirroring
ProbeCellEnabled. Emits one [cell-swept] line per ResolveWithTransition call —
sp.CurCellId and sp.CheckCellId (the transition's swept cells) alongside the
incoming cellId so a doorway capture shows whether the swept cell is stable
where ResolveCellId strobes. No ResolveCellId call in the probe — avoids the
CellGraph.CurrCell side effect. No behavior change.
TDD: ProbeSweptEnabled_DefaultsToFalse RED→GREEN in PhysicsDiagnosticsTests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port retail CObjCell::find_cell_list do_not_load_cells prune
(acclient_2013_pseudo_c.txt:308829-308867) as indoor->outdoor doorway
hysteresis: hold the previous indoor cell when the outdoor candidate is
not in its stab list AND the foot-sphere still overlaps the cell's
containment BSP expanded by DoorwayHoldMargin. Kills the front-door
0170<->0031 ping-pong (handoff §5) the #98 saga never addressed. Fires
only at the front-door seam; the cellar has no exit portal so it never
falls through here (#98 cellar-up untouched).
Three TDD tests in CellGraphMembershipTests: HOLD (the RED->GREEN case,
Y=3.9 inside the 0.2 m margin), RELEASE when fully outside (Y=4.5
exceeds expanded margin), and stab-list gate (outdoor candidate in stab
list releases even near the boundary).
Adds using System.Linq for IReadOnlyList.Contains at the prune site.
SphereOverlapsEnvCell helper mirrors BSPQuery.SphereIntersectsCellBsp
via EnvCell.InverseWorldTransform + ContainmentBsp.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the BFS visibility root to DataCache.CellGraph.CurrCell (the physics
membership answer written in W2 Task 1) rather than resolving independently
from a position via FindCameraCell. Closes the render/physics disagreement
that causes the "world from below" spawn-in flicker.
Changes:
- CellVisibility.GetVisibleCells: extracted BFS body into new private
GetVisibleCellsFromRoot(LoadedCell root, Vector3 cameraPos); existing
GetVisibleCells delegates to it after FindCameraCell (behavior unchanged).
- CellVisibility.ComputeVisibilityFromRoot(LoadedCell? root, Vector3 fallbackPos):
new public entry point; when root is null falls through to ComputeVisibility
(exact today's behavior), otherwise sets _lastCameraCell = root and delegates
to GetVisibleCellsFromRoot — cannot regress below baseline.
- GameWindow (line 7156): replaced ComputeVisibility(visRootPos) with
ComputeVisibilityFromRoot(physicsRoot, visRootPos) where physicsRoot is
resolved from _physicsEngine.DataCache.CellGraph.CurrCell via TryGetCell.
physicsRoot is null whenever CurrCell is null or its id is not yet in the
render registry, so the fallback fires until the cell loads.
- 6 new tests in CellVisibilityFromRootTests: null-root fallback equivalence
(3 cases), registered root → CameraCell == root (3 cases). All 160 App.Tests
pass, 0 regressions.
Visual verification PENDING — behavior change; do not claim it works visually.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add private `SetCurrAndReturn(uint)` helper in PhysicsEngine that looks up
the resolved id in `DataCache.CellGraph` and writes `CurrCell` when the cell
is present. Wrap the four RESOLVED-id return sites in ResolveCellId:
- indoor no-CellBSP return (trust FindCellList)
- indoor sphere-overlaps-CellBSP return
- outdoor→indoor building-transit return (foreach candidate)
- outdoor terrain-grid return
The final no-match `return fallbackCellId;` is intentionally NOT wrapped —
stale beats null (the caller's seed is preserved unchanged).
CurrCell has zero readers in src/ (verified by ripgrep); this is additive
write-only, identical observable behavior to W1. One new unit test
(CellGraphMembershipTests) proves RED→GREEN.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PhysicsDataCache gains a `CellGraph` property (UCG Stage 1). The env-cell
hook is placed at the very top of CacheCellStruct — before the idempotency
guard and the null-PhysicsBSP early-return — so BSP-less cells are included
in the graph even though they are dropped from the legacy _cellStruct map.
PhysicsEngine.AddLandblock/RemoveLandblock mirror terrain registration into
the graph via a null-guarded DataCache?.CellGraph call. Zero behavior change:
CellGraph has no readers this stage.
A using-alias (UcgEnvCell / UcgCellGraph) resolves the EnvCell name
collision between AcDream.Core.World.Cells and DatReaderWriter.DBObjs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Outdoor terrain cell (retail CLandCell) synthesized on demand from a
landblock's TerrainSurface. Factory Synthesize() samples four quad
corners to establish Z bounds; PointInCell() tests the 24 m XY quad
in world-local space. BuildingCellId stub is null (Stage 2).
2/2 tests RED→GREEN.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `EnvCell` (sealed, extends `ObjCell`) with a primitive constructor
and `PointInCell` that uses the cell-containment BSP when present, else
falls back to an AABB test. Retail anchor: CEnvCell (acclient.h:32072).
BSP branch delegates to `BSPQuery.PointInsideCellBsp` (BSPQuery.cs:1034);
the AABB branch is the genuinely new logic. No `FromDat` factory — that is
a separate later task. Consumed by nobody yet (Stage 1 scaffold).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces AcDream.Core.World.Cells namespace with the two foundational
types for the Unified Cell Graph. CellPortal is a readonly struct
unifying the three legacy portal representations; ObjCell is the abstract
base for all traversable cells with the retail id-magnitude IsEnv
discriminator (CObjCell::GetVisible, pseudo_c:308215). Zero consumers;
zero behavior change. 5/5 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A week on the indoor render (Phase U.4 → U.4c → 2026-05-31) fixed the flap but
produced NO shippable progress: walls/ceiling don't seal, outdoor terrain is
visible from inside (#78), the enclosure reads grey/transparent. Root cause is
ARCHITECTURAL, not a bug.
Evidence this session (direct, via the new [shell] probe + screenshots) RULED OUT
every subsystem except the gating architecture: the interior cell shells render
fine (geometry/texture/opaque/depth all correct, zh=0 tr=0); the visibility
traversal computes correct sets + non-empty portal clips; cull mode is fine; the
camera/eye thread was a detour. The residual is that OUTDOOR geometry is not gated
to portal openings when indoors, and acdream enforces visibility THREE inconsistent
ways (TerrainClipMode / per-cell shell clip / entity ParentCellId filter with an
outdoor-stab bypass) instead of retail's ONE PView gate.
This commit is the reset handoff + documentation, not a code fix:
- docs/research/2026-05-31-render-architecture-reset-handoff.md — canonical: honest
state, evidence ledger (ruled-out / do-not-repeat), the mapped 3-gate patchwork,
the retail PView target (one traversal → one gate for ALL geometry), the reset
mission, and a copy-paste pickup prompt.
- docs/architecture/acdream-architecture.md — new "Render Pipeline" SSOT section
(current divergence + unified-PView target + the one rule: compute visibility
once, enforce it once). (Doc has pre-existing corruption below this section —
flagged for separate cleanup.)
- Apparatus: ACDREAM_PROBE_SHELL → [shell] (EnvCellRenderer per-cell prepared/drawn
geometry + flags) added to RenderingDiagnostics + EnvCellRenderer. Throwaway.
- docs/superpowers/specs/2026-05-31-camera-collision-indoor-engagement-design.md —
spec for e099b4c (camera collision; now parked as orthogonal to the seam).
Next session: STOP point-fixing; do the architecture reset to a single PView gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause: ShadowObjectRegistry.GetNearbyObjects gated the outdoor radial sweep
whenever primaryCellId is an indoor cell — this was the issue-#98 fix that stops the
cottage-floor GfxObj from capping the player's head sphere from the cellar below.
But the camera probe (ObjectInfoState.IsViewer, 0x004) also sweeps with an indoor
primary cell, and the only geometry that encloses a Holtburg cottage in acdream's data
model is the landblock-baked exterior-shell GfxObj (registered cellScope=0, outdoor).
Result: the camera's spring-arm sweep found nothing and flew to full chase distance
(eye ~3.4 m back, outside the player's cell 90% of frames — root cause of all three
post-flap residuals: transparent outer walls, terrain-through-floor, grey stairs).
Fix (Option A, retail-faithful): add isViewer parameter (default false, all existing
callers keep the gate) to GetNearbyObjects. Thread oi.IsViewer from FindObjCollisions
(TransitionTypes.cs ~line 2307) through to the gate. When isViewer=true the outdoor
sweep runs regardless of indoor primary cell — matching retail's SmartBox::update_viewer
(:92761) which calls find_obj_collisions (:308918) with no indoor-cell restriction.
The #98 gate remains in force for IsPlayer and all other non-viewer sweeps.
Retail anchors:
- SmartBox::update_viewer @ acclient_2013_pseudo_c.txt:92761 — viewer transition
finds geometry via find_obj_collisions; no indoor gate
- find_obj_collisions @ :308918 — iterates shadow_object_list unconditionally
- CObjCell::find_cell_list @ :308751-308769 — retail's own indoor/outdoor branch
(the model that makes the #98 gate correct for the player)
Also fixes a test-fixture geometry bug: the original RED test had
gfxLeaf.BoundingSphere.Origin in world space (0, ExteriorWallY, 96) instead of
object-local space (0, 0, 0), causing NodeIntersects to return false even when the
gate was bypassed. Corrected to local space; wall polygon vertices/plane also
expressed in local space relative to the GfxObj origin.
Tests (3 new, 1 renamed):
- SweepEye_IndoorCellExteriorGfxObjWall_StoppedByExteriorShell_AfterViewerGateExemption:
was RED (_CurrentlyFails); now GREEN — camera sweep stopped by exterior GfxObj wall
- GetNearbyObjects_IndoorPrimaryCell_NonViewer_DoesNotReturnOutdoorGfxObj: #98 guard
(isViewer=false keeps the gate → GfxObj NOT returned)
- GetNearbyObjects_IndoorPrimaryCell_IsViewer_DoesReturnOutdoorGfxObj: viewer-exempt
guard (isViewer=true bypasses gate → GfxObj IS returned)
App.Tests: 154 pass / 0 fail (was 151/1). Core.Tests: 15 fail (same pre-existing
static-leak flakiness, unchanged from baseline).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause (b): ShadowObjectRegistry.GetNearbyObjects (line 480) returns early
when primaryCellId is an indoor cell, skipping the outdoor radial sweep that
contains the landblock-baked cottage exterior-shell GfxObj. The issue-#98 fix
that prevents the player's head sphere from being capped by the cottage floor
also prevents the IsViewer camera sweep from finding the exterior building shell.
Result: camera passes through exterior walls unimpeded, driving the residual
transparent-walls symptom after the U.4c flap fix.
Evidence: live capture shows eyeInRoot=n ~90% of frames, eye-player distance
3.43m (full chase, no pull-in). RED test deterministically reproduces: synthetic
indoor cell (0xA9B40175) + exterior GfxObj registered at cellScope=0; probe
SweepEye returns pulledIn=0.0000m (full eye distance Y=5.0, wall at Y=4.0).
Fix design: exempt IsViewer from the indoor-primary early-return gate in
GetNearbyObjects — retail's find_obj_collisions (named-retail :308918) has no
indoor/outdoor cell gate; the acdream fix is correct only for IsPlayer.
Apparatus committed:
- tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.cs (RED test)
- docs/research/2026-05-31-camera-collision-indoor-diagnosis.md (findings + design)
- PhysicsCameraCollisionProbe.cs [flap-sweep] diagnostic retained (U.4c spike)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The visibility root + portal-side test now use the PLAYER position (visRootPos) in
player mode instead of the camera EYE; the eye still drives the per-frame projection
(envCellViewProj). Live ACDREAM_PROBE_FLAP evidence: the flap was the 3rd-person eye
drifting out of the player's cell -> FindCameraCell returning the STALE cell for its
grace frames -> the doorway portal culled as behind-the-eye -> exit cell + terrain +
shells dropped (res=Grace eyeInRoot=n terrain=Skip on every flap frame). Retail's
CellManager::ChangePosition (0x004559B0) tracks curr_cell by the player; acdream
already roots lighting at the player (GameWindow:7152) for the same chase-cam reason
— visibility was the lone holdout on the eye. Removed the earlier synthetic builder
flap test, which modeled a disproven (side-test) hypothesis; the fix is integration-
level, validated by the visual gate + [flap] probe. App tests 151/151.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The [flap] line now reports, per root portal, the actual projected NDC vertices and
the Intersect-against-FullScreen result count (clip=N), so a portal that PROJECTS
(proj>=3) but still fails to ADD its neighbour (vis stays low) shows WHY: clip=0 with
ndc inside [-1,1] = winding/self-intersection degeneracy; clip=0 with ndc outside
[-1,1] = genuinely off-screen; the ndc coords expose a near-plane bowtie. Pins the
exact clip-region failure before the root-cause fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-frame (not cell-change-throttled, so it catches the flicker at a stable root):
[flap] line from the builder — root cell's per-portal side-test D + traverse/cull +
NDC projection, plus OutsideView poly count + visible-cell count; localEye exposes
when the eye has crossed an interior portal plane. Paired [flap-cam] line from the
draw site — FindCameraCell resolution branch (CameraCellResolution enum, new),
eyeInRoot AABB flag (stale-root signal), eye + player worldpos, and the frame's
TerrainMode/OutdoorVisible outcome. Disambiguates side-cull vs empty-projection vs
stale-root. Inert when off (gated). Throwaway apparatus to converge the flap fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
VisibleCells (full ids) + SeenOutside, populated at the EnvCell-build site from
envCell.VisibleCells + envCell.Flags. Mirrors retail CEnvCell.stab_list /
seen_outside (acclient.h ~30925). Data already in-process; render path no longer
drops it. Consumed by the builder in U.4c-3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second self-contained-GL-state fix (after uViewProjection): EnvCellRenderer.Render set
BlendFunc per-batch but never the BLEND enable or DepthMask. The opaque shell pass —
drawn after terrain (which sets neither) and after particles / last frame's transparent
pass — inherited whatever left GL_BLEND enabled, making opaque walls composite their
sub-1.0-alpha textures against the bluish clear color (terrain Skip'd indoors) →
"transparent walls / only background," flickering with per-frame ordering. Mirror the
working WbDrawDispatcher: Disable(Blend)+DepthMask(true) opaque, Enable(Blend)+
DepthMask(false) transparent, restore opaque defaults after the draw loop.
Does NOT address the threshold "flap" (OutsideView instability from the per-frame
view-dependent BFS) — that is a distinct, deeper root cause tracked separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the indoor cell-shell SEAM flicker ("transparent walls, oscillating
when moving"): EnvCellRenderer.Render never set uViewProjection — it inherited
WbDrawDispatcher's. But the opaque shell pass draws BEFORE the dispatcher's Draw
(GameWindow ~7411 vs ~7418, the only other setter), so opaque shells rendered with
the PREVIOUS frame's matrix — a stale gl_Position against this frame's clip planes,
yielding pose-dependent clipping that's worst while moving. Make Render self-contained:
stash the view-projection in PrepareRenderBatches and upload it in Render (same matrix
the portal clip planes use). Same self-contained-GL-state precedent as the 2026-05-28
cull-state fix in this file.
Visual re-test confirms this removes the wall-seam flicker. A separate residual
("some houses show only background on interior walls; some flicker remains") is a
distinct root cause, under investigation — NOT this matrix bug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code review flagged the gate-critical per-instance slot resolution as untested.
Add RED→GREEN cases (live=unclipped slot 0, cell-static→cell slot, non-visible→cull,
outdoor-stab→OutsideView/cull, routing-inactive→all slot 0). Note the full-cell-id-space
invariant at ResolveEntitySlot; fix a stale RenderInsideOut comment in EnvCellRenderer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the portal-visibility result through the clip pipeline: build a per-frame
ClipFrame (slot 0 no-clip, slot 1 OutsideView, slot 2..N per visible cell) +
cellIdToSlot from PortalVisibilityBuilder; call the (previously dormant)
EnvCellRenderer.Render for cell shells inside the clip bracket; assign per-instance
clip slots in WbDrawDispatcher (live-dynamic unclipped per retail, cell statics to
their cell slot, outdoor scenery to OutsideView, non-visible culled); gate/scissor/
skip terrain per OutsideView (empty ⇒ no terrain — the bleed fix). Emit ACDREAM_PROBE_VIS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>