Correction after the user (who has played retail and observed the camera pull in at walls) flagged the prior "no camera collision" conclusion. Verified against the decomp: retail's camera collision lives in SmartBox::update_viewer (0x00453ce0), NOT CameraManager::UpdateCamera. The earlier research traced only the producer (UpdateCamera computes the desired/damped eye -> viewer_sought_position) and missed the consumer (update_viewer), which sweeps a 0.3 m viewer_sphere via CTransition::find_valid_position from the head-pivot to that eye and uses the stopped position (fallbacks: AdjustPosition, then snap to player). The player-fade when super close (CameraSet::UpdateCamera -> SetTranslucencyHierarchical) is a SEPARATE stage, already ported as RetailChaseCamera.ComputeTranslucency. Implication: a swept-sphere camera collision is RETAIL-FAITHFUL, not a divergence — no special sign-off needed, and acdream already owns the Transition swept-sphere engine. Updated TL;DR, KEY FINDING, the fix section (was "design decision"), slot-in (collide the damped eye, after RetailChaseCamera.cs:131), open questions, pickup prompt, and reference index. Memory updated likewise. Lesson recorded: when the decomp says "no X" but a domain expert says X exists, trace the CONSUMER of the computed value, not just the producer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
22 KiB
Phase A8.F — camera-collision root cause + handoff (2026-05-29, session 2)
TL;DR
The A8.F "flap" (building walls / ground blinking in and out) and the missing/transparent-wall symptoms are not a builder or enforcement bug. Their root cause is the 3rd-person camera EYE passing through walls: the A8.F renderer computes "am I inside a building?" and "which side of each doorway am I on?" from the camera eye position, so when the eye clips a wall those decisions flip frame-to-frame.
This session: (1) fixed + committed the worst symptom — the cellar terrain
flood (commit 9417d3c); (2) established the recursive-clip builder actually
works for most cells (the prior "Bug B" framing was wrong); (3) reframed the
root cause to the camera, with the user's help; (4) researched the camera
system (Opus agent + my verification).
The fix is retail-faithful (CORRECTED 2026-05-29 PM): retail's camera does
avoid walls — SmartBox::update_viewer (0x00453ce0) sweeps a 0.3 m collision
sphere (viewer_sphere) from the player head-pivot to the desired eye via
CTransition::find_valid_position and uses the stopped position; it also fades
the player to translucent when that eye is super close (CameraSet::UpdateCamera).
Both are retail-faithful, and acdream already owns the Transition swept-sphere
engine to port the collision (the fade is already ported). So the next session
implements a faithful swept-sphere camera collision — no divergence, no sign-off
needed. (An earlier pass in this doc wrongly said "retail has no camera
collision"; that was a research error — see the KEY FINDING section.)
What this session shipped / established
- Bug A FIXED + committed (
9417d3c): an emptyOutsideViewwhile inside a building now draws no outdoor terrain/scenery (empty mask = "no outdoors visible," not "all outdoors"). Previously theelsebranch disabled the stencil and flooded ungated terrain over the cell interior. Visual-confirmed by the user: cottage cellar walls are solid now, no terrain bleed-through. App tests 108/108. All behindACDREAM_A8_INDOOR_BRANCH=1; default play unaffected. - "Bug B" (builder under-produces) is substantially NOT real. The pv-dump
census showed
PortalVisibilityBuilderproduces correctly narrowedOutsideViewregions for most cells (0172/0173/0162/015E/0165/016F→polys=1). The empty cases are mostly legitimate (a windowless cellar can't see daylight) or driven by the camera being in an invalid position (see root cause). The handoff predecessor's Finding 2 ("never narrows") does not hold. - Root cause reframed → the camera. Confirmed below.
- Camera research done (Opus agent, findings verified against the decomp + the code by this session).
Diagnostics added (committed in 9417d3c, all opt-in)
PortalVisibilityBuilder.Buildnow emits aCAMPORTAL[i]census underACDREAM_A8_DUMP_PV=1: per camera-cell portal, before the BFS guards, it logsother=,polyLen=,hasPlane=,interiorSide=,planeN=. This is what let us see that the cottage front-door exit portal was culled by the side test (interiorSide=False) rather than missing.[opaque]probe (ACDREAM_PROBE_ENVCELL=1) — opaque cell-render stats (cells/tris) before the transparent loop overwrites them (the existing[envcells]line reads post-loop and is misleading — ignore itscells=1 tris=0).tools/A8CellAuditportals <cellId>now replicatesBuildLoadedCell's polygon-vertex resolution and printsBUILDER_SEES=OK/EMPTYper portal, so exit-portal validity is checkable offline without a launch.
The visual symptoms (user-observed, 2026-05-29, with ACDREAM_A8_INDOOR_BRANCH=1)
- Cellar walls solid (Bug A fix confirmed). ✓
- Flap: buildings/ground disappear when passing from inside to outside.
- Cellar entrance no longer covered by terrain (Bug A fix). ✓
- Looking into certain windows from outside: back walls missing.
- Inside other buildings: external + internal walls transparent (showing sky / NPCs / particles).
The user's key correlation: items 2/4/5 happen when the camera passes through a door, or when standing inside and panning the camera through a wall. They're intermittent — not always reproducible — which fits a camera-position trigger, not a static rendering bug.
Root cause: the camera eye drives A8.F visibility, and it clips through walls
camPos is the camera eye, extracted from the inverse of the active
camera's View matrix:
GameWindow.cs:7270-7271
Matrix4x4.Invert(camera.View, out var invView);
var camPos = new Vector3(invView.M41, invView.M42, invView.M43); // = the eye
(camera.View is CreateLookAt(_dampedEye, …), so the translation is the
chase-camera eye — not the player avatar position, which is tracked
separately for interior lighting at GameWindow.cs:7415-7417.)
That eye then drives all three A8.F visibility decisions:
- Camera-cell + portal BFS —
_cellVisibility.ComputeVisibility(camPos)(GameWindow.cs:7323) →FindCameraCellviaPointInCell(camPos, …). The BFS portal side-test (CellVisibility.cs:466-481) culls portals the eye is on the wrong side of. - Strict inside-building gate (
GameWindow.cs:7343-7346):cameraInsideBuilding = a8IndoorBranchEnabled && PointInCell(camPos, CameraCell) && CameraCell.BuildingId != null. Picks the inside-out vs outside-in render branch. - Per-portal interior-side cull in the recursive-clip builder —
PortalVisibilityBuilder.CameraOnInteriorSide(cell, i, cameraPos)(PortalVisibilityBuilder.cs:196-203; cull site ~:124): transformscameraPosto cell-local and dot-tests against the portal plane.
The flap mechanism: when the eye damps to a position outside the room (or in
the next room), PointInCell(eye) flips and CameraOnInteriorSide inverts.
The camera-cell ping-pongs, the inside/outside branch switches, and the exit
portal through a doorway is culled-then-uncovered frame-to-frame → walls/ground
blink. The 3-frame CellSwitchGraceFrameCount hysteresis (CellVisibility.cs:167)
only masks single-frame blips; a sustained multi-frame clip defeats it.
Hard evidence (this session's capture): at cottage cell 0xA9B40170 the
front-door exit portal was valid (polyLen=4) but the census showed
interiorSide=False — i.e. the eye was on the outdoor side of the door plane
(0,-0.995,0.105). The plane math puts the cull threshold at eye Y < −8.50 with
the door at Y≈−8.5: the eye had poked out through the front wall while the
player stood inside. The same portal projected fine when the BFS reached
0170 from a deeper room (0173) where the eye was well inside.
KEY FINDING: retail DOES collide the camera (swept sphere) — plus the close-up fade
Retail avoids walls via a swept-sphere spring arm, then fades the player when very close. Three stages — an earlier research pass conflated stages 1+3 and missed stage 2, producing a wrong "no collision" conclusion that this section corrects.
- Stage 1 — compute the desired eye (no collision here).
CameraManager::UpdateCamera(0x00456660, decomp:95505-95953) computes eye =pivot + viewer_offset, damps it (Frame::interpolate_origin,:95922), and stores it asSmartBox::viewer_sought_position(aPosition,acclient.h:35196). This producer does no raycast — which is ALL the earlier pass read, hence its wrong conclusion. - Stage 2 — collide the desired eye (the camera pull-in the user observes).
SmartBox::update_viewer(0x00453ce0, decomp:92761-92892) — the consumer ofviewer_sought_position— runs it through a swept-sphereCTransition:makeTransition→init_object(player, 0x5c)→init_sphere(1, &viewer_sphere, 1f)→init_path(cell, pivot, sought)→find_valid_position→ on successset_viewer(sphere_path.curr_pos)(the STOPPED position). Fallbacks:CPhysicsObj::AdjustPosition, then snap to the player's position.viewer_sphereis a globalCSphere, radius 0.3 m, center (0,0,0) (decomp:93308-93314,:1144645). - Stage 3 — fade the player when the (collided) eye is super close.
CameraSet::UpdateCamera(0x00458ae0) callsCPhysicsObj::SetTranslucencyHierarchical(player, …)(decomp:97679/97698/97725/97737) — opaque at ≥0.45 m, transparent at ≤0.20 m. acdream already ports this asRetailChaseCamera.ComputeTranslucency(RetailChaseCamera.cs:367-376).
Verification (mine, this session): confirmed viewer / viewer_sought_position
are Position structs (acclient.h:35193/35196); read SmartBox::update_viewer in
full and confirmed the CTransition swept-sphere from pivot→sought + the
set_viewer(sphere_path.curr_pos) use of the collided position; confirmed
viewer_sphere.radius = 0.300000012f (:93314); confirmed the player-fade call
sites in CameraSet::UpdateCamera. The earlier "no collision" finding was wrong —
it traced the producer (CameraManager::UpdateCamera) but not the consumer
(update_viewer), where the collision lives. Caught by the user, who has played
retail and observed the camera pulling in at walls. (Lesson worth keeping: when the
decomp says "no X" but a domain expert says X exists, trace the consumer of the
computed value, not just the producer.)
Implication: a swept-sphere camera collision is the retail-faithful fix, NOT a divergence. No special divergence sign-off is needed; this is a straight port, and acdream already owns the swept-sphere machinery.
acdream's current camera (file:line)
All under src/AcDream.App/Rendering/ unless noted.
- Two chase cameras, toggled per-frame: legacy
ChaseCamera.cs(rigid) and defaultRetailChaseCamera.cs(damped). Selected byCameraController.Active(CameraController.cs:20-33) viaCameraDiagnostics.UseRetailChaseCamera(default ON,src/AcDream.Core/Rendering/CameraDiagnostics.cs). - Eye computation (
RetailChaseCamera.Update,:86-142):pivotWorld = playerPos + (0,0,1.5);targetEye = pivotWorld + forward*(-Distance·cosPitch) + up*(Distance·sinPitch)(:113-117); then exponential damping_dampedEye = Lerp(_dampedEye, targetEye, alpha)(:121-133); published asPosition+View = CreateLookAt(_dampedEye, …)(:136-137). No geometry test anywhere between target and publish. - The "input-lag for turning/jumping" port = the RetailChaseCamera's three
smoothing mechanisms, all verified faithful to the decomp:
- Exponential damping (follow-lag):
:121-133+ComputeDampingAlpha:323-329↔ retail:95866-95923(stiffness*dt*10clamped). - 5-frame velocity-averaged, slope-aligned heading (the jump/slope feel):
:94-107,:290-314,ComputeHeading:211-257↔ retailold_velocities[5]:95644-95677. - Mouse low-pass filter (input lag on flicks):
FilterMouseDelta:164-177+FilterMouseAxis:340-358↔ retailCameraSet::FilterMouseInput(0x00457530,:96250-96279); wired atGameWindow.cs:1171-1199.
- Exponential damping (follow-lag):
- Constants (from retail
CameraSet::SetDefaultOffsets0x00458F80,:97916-97967):PivotHeight=1.5,Distance=2.61(=|(0,−2.5,0.75)|),Pitch=0.291 rad, distance clamp[2,40], pitch clamp[−0.7,1.4]. No collision-radius constant exists (no collision is done). - No 1st-person mode (retail
SetInHead0x00458CE0 unported). - Spec:
docs/superpowers/specs/2026-05-18-retail-chase-camera-design.mdexplicitly scopes collision OUT (:454-457: "we don't attempt 'camera collides with wall' — same as retail").
Integration: acdream already has the collision machinery
A camera "spring-arm" sweep can reuse the player's existing swept-sphere engine
(src/AcDream.Core/Physics/):
PhysicsEngine.ResolveWithTransition(curPos, targetPos, cellId, radius, height, stepUp, stepDown, isOnGround, body, moverFlags, entityId)(PhysicsEngine.cs:589) →Transition.FindTransitionalPosition(TransitionTypes.cs:653) → per sub-stepFindEnvCollisions(:1933, indoor branch fetchesGetCellStruct(cellId)→cellPhysics.BSP.Root→BSPQuery.FindCollisions).BSPQueryprimitives (BSPQuery.cs):PointInsideCellBsp(:1034),SphereIntersectsCellBsp(:1077),FindCollisions(:1637),SphereIntersectsPoly(:2085). A purpose-built pivot→eye cast off these is likely a better fit than the fullTransition(which carries unwanted step-up / walkable / gravity semantics).CellPhysics(PhysicsDataCache.cs:511-564): per-cellBSP(collision),CellBSP(point-in-cell), polys/planes, transforms. Fetched viaGetCellStruct(cellId).- Player sweep params for reference:
radius 0.48,height 1.2(PlayerMovementController.cs:1107-1108). A camera probe wants a small radius (e.g.PhysicsGlobals.DummySphereRadius = 0.1),height 0(single sphere),isOnGround:false,body:null(no contact-plane persistence). - Slot-in point: retail collides after damping (
CameraManager::UpdateCameradamps →viewer_sought_position;SmartBox::update_viewerthen collides it). So in acdream, sweep frompivotWorld(RetailChaseCamera.cs:113) to the damped eye and replace the published eye — i.e. after:131(damp), before:136(publish). This requires injecting a collision probe intoRetailChaseCamera(currently GL-free, no engine ref); App→Core is the allowed dependency direction, so injectPhysicsEngineor a narrowICameraCollisionProbeinterface. Check whether acdream has afind_valid_positionequivalent (retail uses that, not the movement sweep) or whetherFindTransitionalPositionmust be adapted.
The fix: a retail-faithful swept-sphere camera collision (no divergence)
Port retail's stage-2 collision (stage 1 = the damped desired eye, and stage 3 = the close-up fade, are both already in acdream):
- Keep the existing damped desired-eye computation (
RetailChaseCamera). - Add the swept-sphere collision (the missing piece): sweep a 0.3 m sphere from
the head-pivot to the damped eye via acdream's
Transitionswept-sphere, and publish the stopped position as the camera eye. Mirror retail's fallbacks (AdjustPosition, then snap to the player) when no valid spot is found. - Verify the already-ported player fade (
ComputeTranslucency) still triggers once the eye stops clipping (it will fade less — the eye now stays out of walls).
This fixes both the render (eye no longer behind walls) and the A8.F visibility (stable camera-cell + side-tests, since the eye stays in valid space). Because it matches retail, no divergence sign-off is needed. Per roadmap discipline, file it as a phase before coding (a short brainstorm to confirm scope is fine, but the behavior question is settled: this is what retail does).
Open design questions for the implementation session
find_valid_positionequivalent: retail usesCTransition::find_valid_position(place/validate a sphere, not the movement sweep). Confirm acdream has an equivalent, or adaptTransition.FindTransitionalPosition/ aBSPQuery-level cast. (Behavior question is settled — this is the API question.)- Sweep radius: retail's
viewer_sphereis 0.3 m — start there to stay faithful; tune only if the eye hugs walls / near-plane clips in tight rooms. - Which primitive: retail uses the full
CTransition(init_objecton the player +find_valid_position). A purpose-builtBSPQuery.FindCollisionsray/sphere cast may be a leaner fit but diverges from retail's exact call — prefer matching retail'sTransitionpath first. - Indoor vs outdoor geometry: indoor walls are in
CellPhysics.BSP(per cell). Cottage exterior shells live in landblock-baked GfxObjs, not cells (cf. issue #98/#101 — cottage floors/walls in GfxObj0x01000A2Betc.). A cell-BSP-only sweep fixes the indoor case but misses outdoor shells (theFindObjCollisions/ ShadowEntry path would be needed for those). Decide scope. - 1st-person fallback: none today; a spring arm must no-op at distance 0 if 1st-person is added.
- Interaction with the ported translucency fade (
ComputeTranslucency) — verify the fade still behaves once the eye stops clipping. - Pivot reference: sweep from
pivotWorld(head,:113), not the feet.
Apparatus / diagnostics (committed 9417d3c; opt-in)
Launch (PowerShell), then walk +Acdream into a Holtburg cottage:
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
$env:ACDREAM_A8_INDOOR_BRANCH="1"; $env:ACDREAM_A8_DUMP_PV="1"; $env:ACDREAM_PROBE_ENVCELL="1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "a8f.log"
ACDREAM_A8_DUMP_PV=1—[pv-dump]per camera cell incl. theCAMPORTAL[i]census (polyLen + interiorSide per portal, before the guards) + the EXIT-CULLED/PROJ/CLIP trace +OUTSIDEVIEW polys=N.ACDREAM_PROBE_ENVCELL=1—[opaque]cell-render stats (reliable; pre-loop).ACDREAM_PROBE_VIS=1—[buildings]/[draworder]/[stencil]/[envcells](heavy: ~17 GL queries × several calls per frame; keep captures short).tools/A8CellAudit portals <cellId>— offline portal dump withBUILDER_SEES=OK/EMPTYper portal.
A future "camera-cell ≠ player-cell" probe would directly confirm the
flap-by-frame: log when PointInCell(eye) disagrees with the player's cell.
Current state / safety
- Default game safe — everything gated behind
ACDREAM_A8_INDOOR_BRANCH=1. - Bug A fix + diagnostics committed (
9417d3c); App tests 108/108. - Builder works (not the bug); camera-collision work not started (awaiting the design decision).
- Tree clean as of
9417d3cplus untracked launch logs (a8f-*.log).
Pickup prompt
Read
docs/research/2026-05-29-a8f-camera-collision-handoff.md. The A8.F flap / missing-walls are caused by the 3rd-person camera EYE clipping through walls and destabilizing the camera-cell + portal side-tests (the eye drivesPointInCell/CameraOnInteriorSideviacamPosat GameWindow.cs:7271). Bug A (cellar terrain flood) is already fixed + committed (9417d3c); the recursive-clip builder works. The fix is retail-faithful (verified against the decomp): port retail's swept-sphere camera collision —SmartBox::update_viewer(0x00453ce0) sweeps a 0.3 mviewer_sphereviaCTransition::find_valid_positionfrom the head-pivot to the (damped) eye and uses the stopped position; the close-up player fade is already ported (RetailChaseCamera.ComputeTranslucency). acdream already owns theTransitionswept-sphere engine. Slot the sweep in afterRetailChaseCamera.cs:131(damp), before:136(publish): sweeppivotWorld→_dampedEye, radius 0.3 m, with retail's fallbacks (AdjustPosition, then snap to player). Check whether acdream has afind_valid_positionequivalent or must adaptFindTransitionalPosition. Watch the open questions (outdoor building shells live in GfxObjs not cells; 1st-person; the fade interaction). A short brainstorm to confirm scope is fine; then file a roadmap phase. No divergence sign-off needed — this is what retail does.
Reference index
acdream code (src/…):
AcDream.App/Rendering/RetailChaseCamera.cs— eye:113-137, damping:121-133/:323-329, heading:211-257, mouse filter:340-358, translucency:367-376.AcDream.App/Rendering/ChaseCamera.cs(legacy),CameraController.cs:20-33,ICamera.cs.AcDream.Core/Rendering/CameraDiagnostics.cs—UseRetailChaseCamera, tunables.AcDream.App/Rendering/GameWindow.cs— eye:7270-7271, visibility:7323,cameraInsideBuilding:7343-7346, camera updates:6851/:6862, mouse-filter:1171-1199.AcDream.App/Rendering/PortalVisibilityBuilder.cs—CameraOnInteriorSide:196-203, cull:124, CAMPORTAL census inBuild.AcDream.App/Rendering/CellVisibility.cs—ComputeVisibility:272,PointInCell:367, BFS side-test:466-481, grace:167.AcDream.Core/Physics/—PhysicsEngine.cs:589,TransitionTypes.cs:653/1933,BSPQuery.cs:1034/1077/1637,PhysicsDataCache.cs:511-564,PlayerMovementController.cs:1107-1108.- Spec:
docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md(collision out-of-scope:454-457).
Retail decomp (docs/research/named-retail/acclient_2013_pseudo_c.txt):
CameraManager::UpdateCamera@ 0x00456660 (:95505-95953) — computes the desired eye (no collision here); damping:95866-95923; velocity ring:95644-95677; result stored asviewer_sought_position.SmartBox::update_viewer@ 0x00453ce0 (:92761-92892) — THE camera collision: sweptviewer_sphereviaCTransition(init_sphere/init_path/find_valid_position) pivot→sought;set_viewer(sphere_path.curr_pos); fallbacksAdjustPositionthen snap to player.viewer_sphere— globalCSphere, radius 0.3 m, center (0,0,0) (:93308-93314; decl:1144645).CameraSet::UpdateCamera@ 0x00458AE0 (:97643-97742) — player-fadeSetTranslucencyHierarchical:97679/97698/97725/97737.CameraSet::FilterMouseInput@ 0x00457530 (:96250-96279).CameraSet::SetDefaultOffsets@ 0x00458F80 (:97916-97967) — pivot (0,0,1.5), viewer (0,−2.5,0.75).SmartBox::PlayerPhysicsUpdatedCallback@ 0x00452d60 (:91842) — writes the damped desired eye toviewer_sought_position(collision happens later, inupdate_viewer).CameraManagerstructacclient.h:35238-35263— no collision fields (the collision lives inupdate_viewer, notCameraManager);viewer/viewer_sought_positionarePositions (acclient.h:35193/35196).