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>
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>
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>
Synthetic C0->C1->C2(exit) chain; two camera poses straddle the C0->C1
side-test boundary by a few cm. Pre-fix, pose B hard-culls C0->C1 and the
exit cell drops -> OutsideView empties (the flap). Gates the U.4c fix.
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>
Adds the GPU mechanism to clip drawing to a per-cell screen-space convex
region via gl_ClipDistance, consumed by the mesh + terrain vertex shaders.
This is the MECHANISM only — every instance defaults to slot 0 (no-clip /
pass-all) and terrain to count 0, so the running game renders IDENTICALLY to
pre-U.3 (verified: offline launch compiles both shaders and reaches steady
state; no GL errors). U.4 populates real clip data from portal visibility.
Binding contract (define once, both sides obey):
- mesh_modern.vert: SSBO binding=2 CellClip[] (shared per-frame regions, slot 0
reserved no-clip) + SSBO binding=3 uint[] per-instance slot, indexed by the
IDENTICAL gl_BaseInstanceARB+gl_InstanceID used for binding=0. binding=0/1
untouched.
- terrain_modern.vert: UBO binding=2 TerrainClip { int count; vec4 planes[8]; }
for the single OutsideView region (UBO namespace; SceneLighting is UBO
binding=1, so binding=2 is free and does not collide with the mesh SSBO
binding=2). count 0 = ungated.
- Both redeclare out gl_PerVertex { vec4 gl_Position; float gl_ClipDistance[8]; }
and set unused planes (i >= count) to +1.0 so they pass everything.
CellClip std430 layout (144 bytes/slot): count@0, 3 pad uints@4/8/12,
planes[8]@16 (vec4 stride 16). Terrain UBO std140: count@0 (padded to 16),
planes[8]@16 → 144 bytes. Verified by ClipFrameLayoutTests (8 new tests).
Pieces:
- ClipFrame: per-frame container + uploader for the SHARED clip data (binding=2
SSBO + terrain UBO). NoClip() = slot 0 + terrain count 0. AppendSlot /
SetTerrainClip pack std430/std140 bytes for U.4. UploadShared binds both.
- WbDrawDispatcher + EnvCellRenderer: each owns its binding=3 zero buffer
(all-zeros sized to its instance count → slot 0), re-binds binding=2 from the
shared ClipFrame id (or an internal no-clip fallback if unwired) before MDI.
gl_ClipDistance is per-vertex, so the single glMultiDrawElementsIndirect per
group is preserved — no draw splitting.
- TerrainModernRenderer: binds the terrain clip UBO (shared or no-clip fallback)
before its draw.
- GameWindow: glEnable(GL_CLIP_DISTANCE0..7) once at init (unused planes pass-all
so always-on avoids per-draw thrash); per frame builds ClipFrame.NoClip(),
UploadShared, and hands the buffer ids to the three renderers (tiny diff; U.4
swaps NoClip() for the real portal-visibility frame).
Gate: dotnet build green; App suite 134/134; offline launch confirms both
shaders compile + link with no GL errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the durable per-frame visibility probe apparatus that #103 lacked, so
the Phase U portal-visibility builder can be validated on live frames before
any GL/visual work.
EmitVis(rootCellId, visibleCells, outsidePolyCount, outsidePlaneCount,
perCellPlaneCounts, scissorFallbacks) prints ONE concise [vis] line gated on
root-cell CHANGE (private _lastVisRootCellId tracker; no-op when the root is
unchanged or ProbeVisibilityEnabled is false — one bool compare per frame when
off). Line format:
[vis] root=0x… cells=N ids=[…] outside(polys=…,planes=…) percell=[0x…:N,…] fallbacks=…
Reuses the existing Phase A8 ProbeVisibilityEnabled flag (env ACDREAM_PROBE_VIS,
already DebugPanel-mirrored via DebugVM.ProbeVisibility) rather than adding a
parallel owner — Code Structure Rule 5 (one diagnostic owner per subsystem).
Property doc repurposed from the abandoned A8 two-pipe stencil semantics to the
Phase U unified pipeline.
Decoupling note: RenderingDiagnostics lives in AcDream.Core, which must not
reference AcDream.App (Code Structure Rule 2). The plan's EmitVis signature took
an App-layer CellView; this lands the equivalent as pre-computed primitives
(outsidePolyCount + outsidePlaneCount) so the owner stays in Core. The U.4a call
site supplies OutsideView.Polygons.Count and the OutsideView ClipPlaneSet.Count.
TDD: 3 new tests in RenderingDiagnosticsVisibilityTests (no-op when disabled,
fires-once-per-new-root + suppressed-on-unchanged, env-default contract), each
self-contained via internal ResetVisibilityProbeForTests + Console.Out capture
to avoid the documented static-leak flakiness. Core suite +3 tests, no new
failures (flaky physics/input static-leak set unchanged at 16, untouched area).
Courtesy: removed the dangling RenderInsideOutAcdream comment reference (deleted
in U.1) + the AcDream.App.Rendering.Wb doc cref (a Core→App layer inversion).
The emit SITE wiring (per-frame call from the render loop) lands in U.4a; this
task lands only the owner members + formatter + test. GameWindow untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CellView convex polygon edges → clip-space planes (nx,ny,0,d) for gl_ClipDistance,
≤8 with collinear-edge merge. Multi-polygon or >8-edge regions degrade to the union
AABB scissor (over-include, never hide); empty regions are distinct (draw nothing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code review caught a CRITICAL under-inclusion: ApplyReciprocalClip scanned for the
first OtherCellId match, so a cell with two portals to the same neighbour clipped both
near-side openings against the FIRST reciprocal polygon — hiding geometry through the
second opening (real on Holtburg cellar cells 0x148<->0x149). Plumb the dat's
OtherPortalId back-link through CellPortalInfo + BuildLoadedCell and index the reciprocal
directly (retail arg2->other_portal_id, 433557). Skip (degrade to over-include) when the
index is unresolvable — never clip against a guessed polygon. Adds a disjoint two-back-
portal regression test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clip the portal opening against the neighbour's matching back-portal polygon
before propagating, so a cell's clip region is the intersection of the opening
seen from both sides. Closes the M-4 stub in ISSUES #102. Can only tighten,
never under-include; degrades to prior behavior when no back-portal is found.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PortalVisibilityFrame gains OrderedVisibleCells (closest-first). Replace the FIFO +
MaxReprocessPerCell cap with a distance-priority queue and a grow-watermark fixpoint
(retail InsCellTodoList 433183 / AddViewToPortals 433446) so cyclic dungeon graphs
converge without duplicate-cell blow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove IndoorCellStencilPipeline + portal_stencil shaders, RenderInsideOutAcdream,
RenderOutsideInAcdream, the A8-perf instrumentation, the cameraInsideBuilding /
ACDREAM_A8_INDOOR_BRANCH branch, and the dead EntitySet partition values. Collapse
the render branch to the default Draw(All) path (U.4a replaces it with the gated
unified pass). Keep all audited EnvCellRenderer / BuildingLoader / CellVisibility /
camera-collision fixes.
Also deleted with the partition: the two test-only walk helpers
(WbDrawDispatcher.WalkEntitiesForTest / WalkEntitiesForTestByCellIds) and their
test files (WbDrawDispatcherEntitySetTests, WbDrawDispatcherCellIdsOverloadTests),
which existed solely to exercise the removed IndoorPass/OutdoorScenery/
BuildingShells/LiveDynamic partition. EntityMatchesSet / IsShellScopedSet collapse
to the All-path constants; the set: parameter is retained as a seam for the
unified pass.
Note: the depth-clear-if-inside default-path workaround was removed per the
U.1 task list — any current indoor-wall degradation persists until a later
Phase U task lands the unified pass (expected, not a regression introduced here).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Visual verification showed the camera vibrating/bouncing when pressed against a
wall. Cause: the sweep wrote its clamped result back into _dampedEye, so the
next frame's damping lerped from the wall toward the target and the sweep
re-clamped it — a per-frame feedback loop. Retail keeps viewer_sought_position
(damped, uncollided) separate from viewer (the published collided eye). Fix:
collide into a separate publishedEye for Position/View/fade and leave _dampedEye
as the clean sought position. New regression test
Update_CollisionDoesNotCorruptDampedState (clamp-then-release → full recovery).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Retail's CTransition::find_transitional_position (:273613) has no step
cap. calc_num_steps (:272149) has a dedicated viewer branch `if ((state
& 4) != 0)` at :272181 for sight/viewer objects (ObjectInfoState.IsViewer
= 0x4). The existing acdream cap correctly had a comment "Sight objects
bypass this" but the bypass was never wired — no IsViewer caller existed
until the A8.F camera spring-arm.
With radius 0.3 m the cap fires at ~9 m. The spring-arm sweeps up to
40 m (≈134 steps), so zoomed-out cameras snapped to the player's head
instead of sweeping through geometry. The fix adds `&& !ObjectInfo.IsViewer`
to the guard; non-viewers keep the 30-step safety net (player spheres
~0.48 m radius never exceed 14 m/tick).
Conformance test: radius=0.3, dist=12 (40 steps > 30 cap) over flat
terrain. Normal mover bails (Assert.False). Viewer proceeds to target
(Assert.True + CurPos.X > from.X). RED → GREEN.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code-review follow-ups for Task 3: wrap the flag-off test's CollideCamera reset
in try/finally so an assert failure can't poison downstream tests; add
Update_ProbePullsEyeInClose_FullyFadesPlayer covering retail stage-3 (collided
eye 0.1 m from pivot → PlayerTranslucency 1); tighten probe.Calls assertion to ==1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add ICameraCollisionProbe? CollisionProbe { get; init; } to RetailChaseCamera.
Extend Update() with optional cellId/selfEntityId params (default 0) so all
existing callers compile unchanged. After the exponential-damping block (step 5)
and before publishing Position/View (step 6), sweep _dampedEye through the
probe when CameraDiagnostics.CollideCamera is true and a probe is wired in
(step 5b). The fade computation in step 7 then naturally uses the collided eye.
Null probe and cellId=0 both short-circuit cleanly. Three new xUnit tests
cover: probe-wired+flag-on publishes collided eye, flag-off skips probe,
null probe doesn't throw. All 30 RetailChaseCameraTests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Code review found the probe passed ObjectInfoState.None; retail's
SmartBox::update_viewer calls init_object(player, 0x5c) =
IsViewer|PathClipped|FreeRotate|PerfectClip (pseudo-C :92864). PathClipped makes
the sweep hard-stop at first contact (TransitionTypes.cs:811) instead of
edge-sliding around corners (which would re-trigger the A8.F camera-cell
instability); IsViewer lets the eye pass through creatures, colliding only with
world geometry. Resolves the spec's slide-vs-stop open question. Also reset
CollideCamera in the Defaults_AreRetailValues baseline test (review: maintenance
trap). Spec §5.1/§11.1 synced.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Task 0 baseline cleanup. Removes the temporary A8 step-disable diag
toggles (A8Diag* properties + ACDREAM_A8_DIAG_* env reads) that the A8
batch left behind in RuntimeOptions, and unwraps their guards in
GameWindow.RenderInsideOutAcdream so every guarded draw (Step 2 punch,
Step 3 EnvCell-opaque + IndoorPass, Step 4 terrain + outdoor scenery,
portal depth-clamp) now runs unconditionally. RuntimeOptionsTests drops
the matching assertions. The ACDREAM_PROBE_VIS apparatus
(EmitDrawOrderProbe / EmitStencilProbe / EmitBuildingsProbe /
EmitEnvCellProbe) is preserved untouched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lands the working A8 indoor-rendering and streaming fixes accumulated this
session. User has verified these visually to some degree (e.g. lifestone /
translucent meshes confirmed fine under the FrontFace flip; bridge / wall /
collision regressions confirmed fixed after travel); not every path has been
exhaustively gated. The cellar-flap defect remains OPEN and will be solved
the retail-faithful way via a dedicated brainstorm (see handoff docs).
Rendering core (reviewed, high confidence):
- EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of
the 80B CPU InstanceData struct the shader never expected — fixes the
transform/texture "explosion" for any draw with >1 instance (cells that
dedupe to a shared cellGeomId). Real root cause.
- WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI
layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into
same-cull runs with absolute uDrawIDOffset per run).
- EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
WorldEntity.BuildingShellAnchorCellId so building shells scope to their
dat-derived building cell instead of rendering everywhere.
- RenderOutsideInAcdream (look into buildings from outside) +
CollectVisiblePortalBuildings frustum cull of portal bounds.
- Sky-when-inside-building + per-cell audit probe + GL-state probe.
Streaming / perf (test-covered; not independently code-reviewed this session):
- Near/far priority queues so near work wins over far; PromoteToNear carries
full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids
rebuilding the animated-lookup dict in the hot draw path. Fixes the
bridge-not-appearing / missing-walls / broken-collision-after-travel
regressions and improves post-transition FPS.
Tooling + docs:
- tools/A8CellAudit: offline dat cell/portal/building dumper (portals +
buildings modes) — reproduces the cellar-flap investigation with no launch.
- docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil
double-duty finding + the WB-recursive design decision + brainstorm prompt),
entity-taxonomy, replan, issue-78 visibility investigation.
Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert
provisional pos.w clamp, and the probe families are kept (env-var gated, zero
cost when off) because the pending option-2 cellar-flap brainstorm needs them.
Strip in the option-2 ship commit.
Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8
visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The post-Wave-5 indoor branch chaos (flickering, missing walls, GPU 100%,
~10 FPS) is caused by two interconnected pool-management bugs in
EnvCellRenderer that line-by-line WB comparison surfaced in 30 minutes.
Neither was found by the five post-Wave-5 speculative fixes because none
of them inspected the pool path.
Bug #1 — GetPooledList missing list.Clear():
The reuse branch returned pool lists with prior-frame data still inside.
PrepareRenderBatches' merge phase pattern `gfxDict[k] = list; list.AddRange(...)`
assumes empty lists. Without Clear(), lists grow unbounded each frame, GPU
draws cumulative instance counts, and per-instance transforms become a stew
of past + present data. Mirrors WB ObjectRenderManagerBase.cs:1221-1233.
Bug #2 — Render uses snapshot.BatchedByCell.Count instead of PostPreparePoolIndex:
The snapshot author dropped WB's PostPreparePoolIndex field calling it
"scenery-only," then "compensated" in Render by setting _poolIndex to the
cell count. The cell count has no relation to the pool — Prepare may have
used 50+ pool lists for an 18-cell scene. Render's filter-path GetPooledList
then returns lists that ARE in snapshot.BatchedByCell, corrupting the snapshot
mid-Render. Restoring PostPreparePoolIndex (WB VisibilitySnapshot.cs:31)
correctly places Render's pool cursor past the snapshot's owned region.
Bug #3 (minor) — PopulateRecursive hardcoded isSetup:false for nested parts:
Setup IDs use high-byte 0x02 (per retail). WB ObjectRenderManagerBase.cs:813
checks `(partId >> 24) == 0x02` to detect nested Setups. Our port always
passed isSetup:false, silently dropping any nested Setup (its TryGetRenderData
returns IsSetup=true, Render's `!IsSetup` guard skips the draw). Probably
rare in EnvCells but fixed for completeness.
Regression coverage:
- GetPooledList_ReusedList_IsClearedBeforeReturn — would have failed pre-fix
- GetPooledList_FreshList_IsAlwaysEmpty — sanity check
- Snapshot_PostPreparePoolIndex_IsInitSettable — compile-time guarantee
- Snapshot_PostPreparePoolIndex_DefaultsToZero — defensive default
86/86 App tests pass. Build green. The fix is the audit's primary
deliverable; the GL state probe option-1 apparatus follows in a separate
commit as defense-in-depth for any unidentified residual issue.
Full audit + WB cross-reference in
docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The core port. 1013 LOC of WB-faithful rendering algorithm:
- GetEnvCellGeomId : WB EnvCellRenderManager.cs:94-103 verbatim
- PrepareRenderBatches : WB EnvCellRenderManager.cs:247-373 verbatim
(parallel frustum-cull, per-cell slow path,
ThreadLocal merge, atomic snapshot swap)
- Render(filter:) : WB EnvCellRenderManager.cs:395-511 verbatim
(filter-driven gfxObj group + draw call build)
- RenderModernMDIInternal : WB BaseObjectRenderManager.cs:709-848
(single-slot variant; resize buffers,
group by cull mode + additive, MDI draw)
- PopulatePartGroups : WB EnvCellRenderManager.cs:572-580 verbatim
(Setup part recursion via PopulateRecursive)
- RegisterCell / FinalizeLandblock / RemoveLandblock — streaming seam
(no WB analog; bridges acdream's existing StreamingController +
LandblockStreamer to the renderer's per-cell instance store)
Documented deviations from WB:
- Drop _useModernRendering branch (Phase N.5 mandatory modern path)
- Drop SelectedInstance/HoveredInstance highlights (no editor state)
- _activeSnapshotGlobalGroups/GfxObjIds as sibling fields on the class
rather than on the snapshot (EnvCellVisibilitySnapshot per Task 4 spec
only carries BatchedByCell + VisibleLandblocks; global groups only
used in the unfiltered Render(pass) path which we don't take)
- ConcurrentDictionary<uint, EnvCellLandblock> keyed by full 32-bit
landblock id (WB uses ushort packed key; acdream uses full id throughout)
10 unit tests (GetEnvCellGeomId determinism + bit-33 dedup flag +
NeedsPrepare + dispose semantics + RemoveLandblock idempotence). Build
green; 23/23 Wave 1+2 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new public overload accepting an explicit IReadOnlyCollection<uint>
cellIds (the camera-buildings' EnvCellIds) instead of a BFS-derived
visibility set. Used by RR7's IndoorPass to scope indoor rendering to the
camera-buildings' cells, not the full portal BFS (which causes Issues A+C).
Pure-data test helper WalkEntitiesForTestByCellIds added alongside the
production overload, mirroring the WalkEntitiesForTest pattern.
The overload internally delegates to the existing visibleCellIds path —
the dispatcher's semantic stays the same; only the caller's intent differs
(explicit cell list vs visibility-derived).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LoadedCell.BuildingId (init + internal setter) — set exactly once at
landblock load time by BuildingLoader; null when the cell isn't
part of any building (outdoor surface cells; dungeon cells not
enumerated in LandBlockInfo.Buildings).
GameWindow landblock-load path: builds BuildingRegistry from
LandBlockInfo.Buildings; stamps each cell's BuildingId; stores the
registry on _buildingRegistries[landblockId] (GameWindow-level dict)
for render-frame lookups. Note: LoadedLandblock is AcDream.Core.World
(a sealed record) — adding an App-type field there would violate
Code Structure Rule #2, so the registry is stored in a new
GameWindow-level dictionary instead. Cleanup wired in both
removeTerrain lambdas (OnLoad + OnResize paths).
drainedCells dict: the existing _pendingCells drain loop is extended
to also build a local CellId→LoadedCell dict; BuildingLoader.Build
uses this dict for the stamping pass so no second iteration is needed.
New BuildingLoaderTest verifies the stamping path. 5 BuildingLoader
tests total (4 from RR3 + 1 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New per-landblock data model for WB-style per-building cell scoping:
Building — BuildingId, EnvCellIds, ExitPortalPolygons,
occlusion-query state (Step 5 lifecycle)
BuildingRegistry — two-way indexed (by cellId + by buildingId);
single source of truth per landblock
BuildingLoader — static factory from LandBlockInfo.Buildings;
walks interior portals to expand cell sets;
collects exit portal polygons in world space
10 new unit tests cover data invariants + registry indexing + loader
mapping per the algorithm resolved in RR2 findings.
LoadedCell.BuildingId stamping wired in RR4. Render-time consumption
arrives in RR7 (Steps 1-4) + RR9 (Step 5) + RR11 (RenderOutsideIn).
Design: docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md
Spike: docs/research/2026-05-26-a8-buildings-data-shape.md
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>