acdream/docs/research/2026-05-29-a8f-camera-collision-handoff.md
Erik 9757818e95 docs(render): Phase A8.F — correct camera handoff; retail DOES collide the camera
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>
2026-05-29 17:51:44 +02:00

22 KiB
Raw Blame History

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 empty OutsideView while inside a building now draws no outdoor terrain/scenery (empty mask = "no outdoors visible," not "all outdoors"). Previously the else branch 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 behind ACDREAM_A8_INDOOR_BRANCH=1; default play unaffected.
  • "Bug B" (builder under-produces) is substantially NOT real. The pv-dump census showed PortalVisibilityBuilder produces correctly narrowed OutsideView regions for most cells (0172/0173/0162/015E/0165/016Fpolys=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.Build now emits a CAMPORTAL[i] census under ACDREAM_A8_DUMP_PV=1: per camera-cell portal, before the BFS guards, it logs other=, 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 its cells=1 tris=0).
  • tools/A8CellAudit portals <cellId> now replicates BuildLoadedCell's polygon-vertex resolution and prints BUILDER_SEES=OK/EMPTY per 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)

  1. Cellar walls solid (Bug A fix confirmed). ✓
  2. Flap: buildings/ground disappear when passing from inside to outside.
  3. Cellar entrance no longer covered by terrain (Bug A fix). ✓
  4. Looking into certain windows from outside: back walls missing.
  5. 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:

  1. Camera-cell + portal BFS_cellVisibility.ComputeVisibility(camPos) (GameWindow.cs:7323) → FindCameraCell via PointInCell(camPos, …). The BFS portal side-test (CellVisibility.cs:466-481) culls portals the eye is on the wrong side of.
  2. 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.
  3. Per-portal interior-side cull in the recursive-clip builderPortalVisibilityBuilder.CameraOnInteriorSide(cell, i, cameraPos) (PortalVisibilityBuilder.cs:196-203; cull site ~:124): transforms cameraPos to 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 as SmartBox::viewer_sought_position (a Position, 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 of viewer_sought_position — runs it through a swept-sphere CTransition: makeTransitioninit_object(player, 0x5c)init_sphere(1, &viewer_sphere, 1f)init_path(cell, pivot, sought)find_valid_position → on success set_viewer(sphere_path.curr_pos) (the STOPPED position). Fallbacks: CPhysicsObj::AdjustPosition, then snap to the player's position. viewer_sphere is a global CSphere, 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) calls CPhysicsObj::SetTranslucencyHierarchical(player, …) (decomp :97679/97698/97725/97737) — opaque at ≥0.45 m, transparent at ≤0.20 m. acdream already ports this as RetailChaseCamera.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 default RetailChaseCamera.cs (damped). Selected by CameraController.Active (CameraController.cs:20-33) via CameraDiagnostics.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 as Position + 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*10 clamped).
    • 5-frame velocity-averaged, slope-aligned heading (the jump/slope feel): :94-107, :290-314, ComputeHeading:211-257 ↔ retail old_velocities[5] :95644-95677.
    • Mouse low-pass filter (input lag on flicks): FilterMouseDelta:164-177 + FilterMouseAxis:340-358 ↔ retail CameraSet::FilterMouseInput (0x00457530, :96250-96279); wired at GameWindow.cs:1171-1199.
  • Constants (from retail CameraSet::SetDefaultOffsets 0x00458F80, :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 SetInHead 0x00458CE0 unported).
  • Spec: docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md explicitly 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-step FindEnvCollisions (:1933, indoor branch fetches GetCellStruct(cellId)cellPhysics.BSP.RootBSPQuery.FindCollisions).
  • BSPQuery primitives (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 full Transition (which carries unwanted step-up / walkable / gravity semantics).
  • CellPhysics (PhysicsDataCache.cs:511-564): per-cell BSP (collision), CellBSP (point-in-cell), polys/planes, transforms. Fetched via GetCellStruct(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::UpdateCamera damps → viewer_sought_position; SmartBox::update_viewer then collides it). So in acdream, sweep from pivotWorld (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 into RetailChaseCamera (currently GL-free, no engine ref); App→Core is the allowed dependency direction, so inject PhysicsEngine or a narrow ICameraCollisionProbe interface. Check whether acdream has a find_valid_position equivalent (retail uses that, not the movement sweep) or whether FindTransitionalPosition must 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):

  1. Keep the existing damped desired-eye computation (RetailChaseCamera).
  2. 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 Transition swept-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.
  3. 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

  1. find_valid_position equivalent: retail uses CTransition::find_valid_position (place/validate a sphere, not the movement sweep). Confirm acdream has an equivalent, or adapt Transition.FindTransitionalPosition / a BSPQuery-level cast. (Behavior question is settled — this is the API question.)
  2. Sweep radius: retail's viewer_sphere is 0.3 m — start there to stay faithful; tune only if the eye hugs walls / near-plane clips in tight rooms.
  3. Which primitive: retail uses the full CTransition (init_object on the player + find_valid_position). A purpose-built BSPQuery.FindCollisions ray/sphere cast may be a leaner fit but diverges from retail's exact call — prefer matching retail's Transition path first.
  4. 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 GfxObj 0x01000A2B etc.). A cell-BSP-only sweep fixes the indoor case but misses outdoor shells (the FindObjCollisions / ShadowEntry path would be needed for those). Decide scope.
  5. 1st-person fallback: none today; a spring arm must no-op at distance 0 if 1st-person is added.
  6. Interaction with the ported translucency fade (ComputeTranslucency) — verify the fade still behaves once the eye stops clipping.
  7. 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. the CAMPORTAL[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 with BUILDER_SEES=OK/EMPTY per 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 9417d3c plus 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 drives PointInCell / CameraOnInteriorSide via camPos at 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 m viewer_sphere via CTransition::find_valid_position from 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 the Transition swept-sphere engine. Slot the sweep in after RetailChaseCamera.cs:131 (damp), before :136 (publish): sweep pivotWorld_dampedEye, radius 0.3 m, with retail's fallbacks (AdjustPosition, then snap to player). Check whether acdream has a find_valid_position equivalent or must adapt FindTransitionalPosition. 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.csUseRetailChaseCamera, 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.csCameraOnInteriorSide :196-203, cull :124, CAMPORTAL census in Build.
  • AcDream.App/Rendering/CellVisibility.csComputeVisibility :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 as viewer_sought_position.
  • SmartBox::update_viewer @ 0x00453ce0 (:92761-92892) — THE camera collision: swept viewer_sphere via CTransition (init_sphere/init_path/find_valid_position) pivot→sought; set_viewer(sphere_path.curr_pos); fallbacks AdjustPosition then snap to player.
  • viewer_sphere — global CSphere, radius 0.3 m, center (0,0,0) (:93308-93314; decl :1144645).
  • CameraSet::UpdateCamera @ 0x00458AE0 (:97643-97742) — player-fade SetTranslucencyHierarchical :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 to viewer_sought_position (collision happens later, in update_viewer).
  • CameraManager struct acclient.h:35238-35263 — no collision fields (the collision lives in update_viewer, not CameraManager); viewer / viewer_sought_position are Positions (acclient.h:35193/35196).