acdream/docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md
Erik 5e2f99d08e docs: Phase A comparison + Phase B port plan (holistic building-render investigation)
Deliverable 1: docs/research/2026-06-11-building-render-acdream-vs-retail-
comparison.md - the acdream-vs-retail architecture comparison synthesized
from two ultracode mapping fan-outs (11/12 areas, ~90 agents, every retail
claim Ghidra/pc-cited, every acdream claim file:line, 40/76 divergences
adversarially verified so far; raw per-area evidence committed under
docs/research/2026-06-11-holistic-map/).

Headline findings: (1) retail flattens GfxObjs/cells at load exactly like
us (ConstructMesh + RemoveNonPortalNodes) - the MDI pipeline survives;
(2) the phantom/door mechanism is the skipNoTexture draw-time surface gate
(dat-confirmed); (3) retail never geometrically clips world geometry -
aperture exactness is a DEPTH discipline (punch maxZ1 / seal maxZ2 / gated
clear + far-to-near whole-mesh draws) - reframes #114; (4) flood admission
is already faithful, the trigger/depth/multi-view/cone-culling layers are
missing; (5) #115 root cause verified (boom damping severed from the
published collided viewer); collision A6.P4 design verified with
corrections (signed other_portal_id >= 0 gate).

Deliverable 2: docs/plans/2026-06-11-building-render-port-plan.md - the
phased port plan (BR-1 surface gate, BR-2 depth punch/seal, BR-3 delete
the shell chop, BR-4 draw-driven floods, BR-5 viewconeCheck, BR-6 one
gate, BR-7 collision A6.P4, BR-8 camera/lighting/LOD) with per-phase
acceptance criteria, bug closures, keep-list, and a playable-after-every-
phase migration order. AWAITING USER APPROVAL - no implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 05:54:12 +02:00

95 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 2.1 Camera and viewer (issue #115 — camera feel in cramped interiors)
## RETAIL
RETAIL CALL CHAIN (one pass per render frame, two phases):
PHASE 1 — physics/update: Client::UseTime (pc:18670, 0x00411c40) → SmartBox::UseTime (pc listing at 0x00455410) → CPhysics::UseTime (call at 0x004554a7; body at Ghidra 0x00509950). CPhysics::UseTime iterates every physics object calling CPhysicsObj::update_object, and immediately after updating THE PLAYER object it calls SmartBox::PlayerPhysicsUpdatedCallback (Ghidra 0x005099ed-0x005099f2; fires once per frame whenever Timer::cur_time advanced — the gate at 0x0050996c only early-outs on zero elapsed). PlayerPhysicsUpdatedCallback (Ghidra-confirmed decompile of 0x00452d60; pc:91836-91844) is three lines: `sought = CameraManager::UpdateCamera(camera_manager, &ret, &this->viewer); viewer_sought_position = sought;`. THE LOAD-BEARING FACT: the third argument — the interpolation ORIGIN — is `&this->viewer`, the PUBLISHED, COLLIDED viewer from the previous frame's sweep. The collided eye feeds back into the damping every frame.
CameraManager::UpdateCamera (Ghidra 0x00456660, full decompile read): (a) dt = Timer::cur_time last_update_time (own clock, real seconds); (b) integrates held-key offset-movement flags into viewer_offset / pivot_offset scaled by dt; (c) builds the TARGET pose — pivot via QueryPivotPosition, heading via LOOK_AT_OBJECT / ALIGN_WITH_PLANE 5-sample velocity ring / LOOK_IN_DIRECTION, target origin = pivot + heading-frame-rotated viewer_offset (Frame::localtoglobal), target rotation = look frame; (d) translation alpha = t_stiffness * dt * 10.0 clamped to [0,1] (Ghidra tail: `fVar19 * (float)local_178 * ___real_4024000000000000`; t_stiffness ≥ 1F_EPSILON ⇒ instant), rotation alpha likewise from r_stiffness; (e) `Frame::interpolate_origin(&result, &param_1->frame /* = published viewer */, &target, t_alpha)` and `interpolate_rotation(..., r_alpha)` — an exponential lerp FROM the published collided viewer TOWARD the full-boom target; (f) convergence snap: when not instant and `Position::distance(result, param_1) < 2*F_EPSILON` and `Frame::close_rotation(result, param_1, F_EPSILON)` → return param_1 unchanged (exact fixed point; F_EPSILON = 0.000199999995). Constructor defaults t_stiffness = r_stiffness = 0.45 (pc:95963-95964, 0x004570b1-0x004570b4). There is NO explicit boom-distance lerp, NO hysteresis constants, NO per-frame max-rate clamp anywhere in update_viewer/set_viewer — the famous "shorten fast, lengthen slow" feel is EMERGENT: the sweep clamps the published eye instantly, and because the next frame's lerp origin IS that clamped eye, re-extension toward the full boom eases out exponentially (~alpha 7.5%/frame at 60fps), and while the player turns, the sought eye hugs the wall instead of orbiting at full radius behind it.
PHASE 2 — draw: the same per-frame SmartBox pass ends in SmartBox::DrawNoBlit (call at 0x0045557a; Ghidra labels the containing function Draw) → SmartBox::update_viewer (Ghidra xref: from 0x00454c34 in DrawNoBlit, UNCONDITIONAL — the sweep re-runs EVERY render frame regardless of whether anything moved). update_viewer (Ghidra-confirmed decompile of 0x00453ce0; pc:92675-92887): (1) player->cell null → reenter_visibility, else set_viewer(player_pos, 1) + viewer_cell = null; (2) pivot = part frame at camera_manager->pivot_part_index (else m_position) + rotated camera_manager->pivot_offset; (3) sweep START cell: outdoor ((objcell_id & 0xffff) < 0x100) player->cell; indoor → CPhysicsObj::AdjustPosition seats the cell at the PIVOT point, falling back to player->cell; (4) sweep target = viewer_sought_position's origin re-expressed in the start cell (Position::localtoglobal); (5) CTransition with init_object(player, 0x5c), init_sphere(1, &viewer_sphere /* 0.3 m, pc:93314 */, 1.0), init_path(startCell, pivotPos, soughtPos), find_valid_position; (6) SUCCESS → set_viewer(&sphere_path.curr_pos, 0) and `viewer_cell = sphere_path.curr_cell` — the published render position IS the raw collided sweep stop, and the viewer cell IS the transition's graph-tracked end cell; (7) fallback 1: AdjustPosition at the raw sought position (which carries viewer_sought_position's own objcell_id context) → set_viewer(sought, 0), viewer_cell = adjusted cell; (8) fallback 2: set_viewer(player->m_position, 1), viewer_cell = null. set_viewer (Ghidra 0x00452c40) copies the Position verbatim into this->viewer (param_2 != 0 additionally resets viewer_sought_position — failure-path re-seed), then re-anchors the viewer light, SoundManager::SetPlayerPosition, LScape::set_sky_position, and SceneTool::SetupCamera(&this->viewer) — there is NO separate smoothed render position; the renderer consumes the collided position raw, paired with the DAMPED rotation (the sought frame's interpolated rotation rides through the sweep unchanged — flags 0x5c include FreeRotate).
INPUT WHILE COLLIDED (Q3): held camera keys are polled per frame in CameraSet::UpdateCamera (Ghidra 0x00458ae0, pc:97625-97745; called per frame from the UI UseTime at 0x004d74b9) → CameraSet::Rotate (0x00458310, pc:97103-97230) rotates the viewer_offset vector around Z by angle = cm->m_rCameraAdjustmentSpeed × (cur_time m_ttLastRotate) (sin/cos at 0x00458609-0x00458629), then SetTargetDirection + SetTargetForOffset; mouse-look reaches the same Rotate with a scale argument (callers at 0x00458ef9). Rotation input ONLY moves the TARGET; no stiffness change during rotate (stiffness is forced to 1.0 only by mode switches: SetScale 0x004578fe, SetInHead 0x00458cfc, LookDown-family 0x00458097/0x00458204). The swing arc therefore collides CONTINUOUSLY: damping eases the sought eye from the published collided pose toward the rotated target each frame, and update_viewer re-sweeps pivot→sought every render frame.
PLAYER FADE (Q4): CameraSet::UpdateCamera (0x00458ae0): InHead → CPhysicsObj::SetTranslucencyHierarchical(player, 1f) (0x00458bb8, fully invisible); otherwise d = Position::distance(pivot, &sbox->viewer) — the PUBLISHED COLLIDED viewer (0x00458beb); d ≥ 0.449999988 → SetTranslucencyHierarchical(player, 0f) (0x00458ca1, opaque); d < 0.45 t = 1 (0.200000003 d)/(0.2 0.45), clamped to [0,1] (0x00458c19-0x00458c53), applied via SetTranslucencyHierarchical (0x00458c6d). So retail fades the player out over the 0.45 m 0.20 m approach band, keyed off the collided eye, applied to the actual player mesh hierarchy every frame.
## ACDREAM
ACDREAM CALL CHAIN (one pass per update tick, all in one phase):
GameWindow's player-mode update (src/AcDream.App/Rendering/GameWindow.cs:6728-6838) runs PlayerMovementController.Update (GameWindow.cs:6791), then updates BOTH chase cameras every frame legacy ChaseCamera (GameWindow.cs:6821-6823) and RetailChaseCamera (GameWindow.cs:6832-6838) passing RenderPosition, yaw, BodyVelocity, IsOnGround, ContactPlane.Normal, frame dt, the player CellId, and LocalEntityId. CameraController.Active picks per-read via CameraDiagnostics.UseRetailChaseCamera (CameraController.cs:20-33), default ON (CameraDiagnostics.cs:27-28); camera collision default ON (CameraDiagnostics.cs:48-49).
RetailChaseCamera.Update (src/AcDream.App/Rendering/RetailChaseCamera.cs:122-209): (1) 5-frame velocity ring + average (:133-134, mirrors retail old_velocities); (2) heading = facing projected on contact plane (ComputeHeading :140-145, :278-324); (3) target eye = pivot (player + 1.5 m, :151) forward·D·cosP + up·D·sinP with Distance default 2.61 / Pitch 0.291 (:57-60); (4) damping: `_dampedEye = Lerp(_dampedEye, targetEye, alpha)` with alpha = stiffness·dt·10 (:167-170, ComputeDampingAlpha :390-396), stiffness defaults 0.45/0.45 (CameraDiagnostics.cs:56-63 matches retail pc:95963), plus the ported convergence snap (:172-176, :408-416, epsilons :97-98); (5) collision: `publishedEye` starts as `_dampedEye`; if CollideCamera and probe set, `swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId, playerPosition)`; publishedEye = swept.Eye; ViewerCellId = swept.ViewerCellId (:188-198). THE LOAD-BEARING DIVERGENCE: the comment block at :179-187 explicitly states the collided result "must NOT feed back into the damped state" and claims retail keeps two non-feeding states `_dampedEye` (:104) only ever lerps from its own previous UNCOLLIDED value (:169); (6) publish Position = publishedEye, View = LookAt(publishedEye, publishedEye + _dampedForward) (:202-203 position collided, rotation damped, same split as retail); (7) PlayerTranslucency = ComputeTranslucency(distance(publishedEye, pivot)) with Far 0.45 / Near 0.20 (:207-208, :454-463 formula matches retail 0x00458c19) but grep over src/ shows PlayerTranslucency has ZERO consumers outside RetailChaseCamera.cs itself (:82, :119, :208); no code applies it to the player mesh.
PhysicsCameraCollisionProbe.SweepEye (src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:24-103, KEEP-LISTED verbatim port): 0.3 m sphere (:18), indoor start-cell seated at the pivot via AdjustPosition (:36-40 = retail pc:92824-92844), ResolveWithTransition with retail's exact 0x5c flags (:68-69), success swept eye + r.CellId (:92 = retail viewer_cell = sphere_path.curr_cell), fallback 1 AdjustPosition at the sought eye (:94-99 seeds with the PLAYER cell rather than the sought position's own cell, a documented small divergence from retail's local_120 carrying viewer_sought_position.objcell_id), fallback 2 snap to player + cell 0 (:102).
Input: held zoom/raise keys integrate Distance/Pitch at CameraAdjustmentSpeed·dt (GameWindow.cs:6743-6754, default 40.0 CameraDiagnostics.cs:78); RMB mouse orbit writes YawOffset = filteredDx·0.004·sens through the ported low-pass FilterMouseDelta (GameWindow.cs:1063-1096, RetailChaseCamera.cs:231-244). Re-collide cadence MATCHES retail: the sweep runs every frame the camera updates, unconditionally (:193-198).
Consumption: GameWindow.OnRender roots the render at the VIEWER cell viewerCellId = _retailChaseCamera.ViewerCellId when player mode + retail cam (GameWindow.cs:7301-7305), visibility = ComputeVisibilityFromRoot(viewerRoot, camPos) (:7313), while lighting keys on the PLAYER CurrCell (:7291-7296, :7337) matching retail's player->cell vs SmartBox->viewer_cell split.
## DIVERGENCES
### [HIGH] boom-no-collided-feedback (confirmed) — Sought eye never re-anchors to the published collided viewer — retail's emergent boom easing is severed
- verifier notes: Re-derived the full retail camera loop from Ghidra decompiles (not BN pseudo-C) and checked every acdream citation against the actual code. All load-bearing elements of the claim survive.
RETAIL (Ghidra): (1) SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60 — `UpdateCamera(this->camera_manager, &ret, &this->viewer)` with the result overwriting `viewer_sought_position`; the interpolation seed passed in is the PUBLISHED viewer, exactly as claimed. (2) CameraManager::UpdateCamera 0x00456660 tail — `local_158.objcell_id = param_1->objcell_id; Frame::interpolate_origin(&local_158.frame, &param_1->frame, target_frame, alpha)` where param_1 IS that published-viewer argument, alpha = t_stiffness·dt·10 clamped to 1 (constant ___real_4024000000000000 = 10.0), plus interpolate_rotation(r_stiffness·dt·10); convergence snap verified: when translation alpha unsaturated AND Position::distance(result, param_1) < F_EPSILON+F_EPSILON AND close_rotation(F_EPSILON), returns a copy of param_1 (published viewer) unchanged matches the claim's "distance < 2·F_EPSILON" wording. (3) SmartBox::update_viewer 0x00453ce0 reads viewer_sought_position, CTransition::init_path(cell, &pivot, &sought) (sweep starts at the PIVOT), init_sphere(viewer_sphere), and on success publishes the clamp RAW: set_viewer(&sphere_path.curr_pos, 0) + viewer_cell = sphere_path.curr_cell. (4) SmartBox::set_viewer 0x00452c40 straight copy into this->viewer, no lerp; param_2!=0 would also reseed sought but the normal publish passes 0. (5) Call sites: update_viewer called unconditionally from SmartBox::DrawNoBlit 0x00454c34 when player != null; PlayerPhysicsUpdatedCallback fired from the physics-object update loop at 0x005099f2 (pc:271646) when the updated object is the player. No hysteresis constants exist; shorten-instant/lengthen-damped is emergent from publish→re-anchor→sweep, as claimed.
ACDREAM: RetailChaseCamera.cs:169 — `Vector3.Lerp(_dampedEye, targetEye, tAlpha)` lerps from its own previous never-collided value; grep confirms _dampedEye is written ONLY at :161 (init) and :175-176 (convergence snap) — the swept result at :195-197 goes into local `publishedEye` + ViewerCellId only, so the collided eye never re-anchors the sought state anywhere in the codebase. The :179-187 comment explicitly forbids feedback and mis-attributes that design to retail — refuted by the 0x00452d60/0x00456660 decompiles. Production-active: CameraDiagnostics.UseRetailChaseCamera (CameraDiagnostics.cs:27-28) and CollideCamera (:48-49) both default ON, so this is the live path, not a flag-gated experiment.
Behavioral consequences check out mechanically: (a) while collided+turning, acdream's sweep target is the full-boom point orbiting behind the wall (publishedEye = fresh per-frame clamp of pivot→far-target → stair-steps along wall features), retail's sought hugs the published clamp (moves only alpha per update toward full boom → clamp point glides); (b) on obstruction clear, acdream's _dampedEye has already converged to full boom during the collision (the lerp toward targetEye is never impeded), so the eye pops the full clamped-to-boom delta in one frame (~2.3 m worst case at Distance 2.61, 0.3 m sphere), where retail eases out exponentially. The #109 render-root amplifier remains a labeled hypothesis (ViewerCellId = render root per GameWindow.cs:7303; a 1-frame eye jump crossing portal planes flipping it is plausible but unverified) — appropriately flagged as such in the original claim.
One framing correction (does not change the verdict): retail's re-anchoring fires per PLAYER-PHYSICS UPDATE (the 0x005099f2 loop), not per render frame, and UpdateCamera computes alpha from real elapsed time (Timer::cur_time last_update_time) — so "~7.5%/frame" is a 60 Hz illustration of a time-constant easing, not a fixed per-render-frame rate. The sweep (update_viewer) runs per render frame; the sought easing runs at physics-update cadence with time-based alpha. The proposed port doing both per render frame with dt-based alpha is behaviorally equivalent. Port shape is sound: retail's sweep starting at the PIVOT (not the previous eye) is what makes publish-feedback a stable fixed point, so the historical oscillation feared in the :183-187 comment (which arose under a different state arrangement) should not reproduce if the ordering (interpolate-from-published → sweep pivot→sought → publish) is ported exactly; the corner-seal replay + cramped-interior visual gate validation step is the right acceptance test.
- blastRadius: PRIMARY #115 suspect ("camera feels draggy/jittery vs retail when turning in cramped interiors — like dragging over walls instead of gliding"). Two symptom modes from one root cause: (a) while turning with the boom collided, acdream's sweep target is the FULL-distance eye orbiting behind walls, so the published eye is a fresh per-frame clamp of the pivot→far-target ray — it jumps discontinuously from wall feature to wall feature (stair-stepping/jitter = "dragging over walls"); retail's sought eye starts each frame AT the collided eye and moves only ~7.5%/frame toward the full boom, so the target hugs the wall and the clamp point glides; (b) the instant the sweep clears an obstruction, acdream's eye snaps out to full boom distance in ONE frame (up to ~2.3 m pop), where retail eases out exponentially over ~10-20 frames. Secondary: a 1-frame eye jump can cross multiple portal planes, flipping ViewerCellId (= the render root, GameWindow.cs:7301-7313) discontinuously — a plausible amplifier for #109 far-door render-root oscillation when the eye sits near a clamp boundary (hypothesis, not verified). Also makes #114-class shell-clip pops more noticeable since the eye teleports rather than glides between clip regimes.
- retailEvidence: SmartBox::PlayerPhysicsUpdatedCallback (Ghidra-confirmed decompile 0x00452d60; pc:91836-91844): `sought = CameraManager::UpdateCamera(cm, &ret, &this->viewer)` — the interpolation origin is the PUBLISHED COLLIDED viewer; result overwrites viewer_sought_position every frame. CameraManager::UpdateCamera (Ghidra 0x00456660 tail): `Frame::interpolate_origin(&result, &param_1->frame, &target, t_stiffness·dt·10)` + interpolate_rotation + convergence snap (distance < 2·F_EPSILON AND close_rotation(F_EPSILON) return param_1). SmartBox::update_viewer (Ghidra 0x00453ce0) then sweeps pivotsought per render frame (xref: unconditional call from DrawNoBlit 0x00454c34) and publishes the clamp RAW via set_viewer(&sphere_path.curr_pos, 0) (Ghidra 0x00452c40 straight copy, no lerp). No hysteresis constants exist; shorten-instant/lengthen-damped is emergent from this loop.
- acdreamEvidence: RetailChaseCamera.cs:169 `_dampedEye = Lerp(_dampedEye, targetEye, alpha)` lerps from its own previous UNCOLLIDED value; :188-198 collided result goes into a local `publishedEye` only; :179-187 the comment explicitly forbids feedback ("must NOT feed back into the damped state") and mis-attributes that design to retail ("retail ... keeps TWO states ... collision ... must NOT feed back") refuted by the Ghidra decompile of 0x00452d60. The collided distance is applied RAW per frame with no easing on re-extension and no wall-hugging of the sweep target.
- portShape: Replicate retail's two-phase loop: keep ONE published viewer state (Position: cell + origin + rotation). Per frame, FIRST compute sought = interpolate_origin/rotation FROM the published viewer toward the full-boom target (alpha = stiffness·dt·10, convergence snap vs the published viewer the existing ApplyConvergenceSnap epsilons are already correct), THEN run the keep-listed sweep pivotsought (probe unchanged), THEN publish the swept result (position + curr_cell) as the viewer that seeds the NEXT frame's interpolation. Delete the separate never-collided _dampedEye; _dampedForward re-anchors from the published frame the same way (rotation is never clamped so behavior is identical). Validate against the historical oscillation note (:183-187) with the corner-seal replay + a cramped-interior visual gate retail's shape is a stable fixed point (sweep starts at the PIVOT, not the previous eye), so the old vibration should not reproduce if the ordering is ported exactly.
### [MEDIUM] player-fade-computed-not-applied (confirmed) — Player-mesh close-camera fade is computed but never applied to the player
- correctedClaim: Claim confirmed as stated, with one immaterial precision: in the d 0.45 branch retail calls SetTranslucencyHierarchical(player, 0) only when the current translucency is > 0 (a redundancy guard at the `if (0.0 < t)` check in 0x00458ae0), not unconditionally every frame — behaviorally identical to the claim. Everything else (thresholds 0.449999988/0.200000003, ramp formula, collided-viewer distance source, InHead → 1.0, per-frame application via gmSmartBoxUI::UseTime, hierarchical part-array application, and acdream computing-but-never-consuming PlayerTranslucency) checks out against Ghidra and the actual acdream call sites.
- verifier notes: RETAIL re-derived from Ghidra decompile (not BN pseudo-C): CameraSet::UpdateCamera @ 0x00458ae0 (header pc:97643) — InHead → CPhysicsObj::SetTranslucencyHierarchical(player, 1.0); else d = Position::distance(pivot-from-CameraManager::QueryPivotPosition, &sbox->viewer) [the published COLLIDED viewer]; if d < CAMERA_MIN_CHAR_DIST2 t = 1.0 (CAMERA_MIN_CHAR_TRANS_DIST d)/(CAMERA_MIN_CHAR_TRANS_DIST CAMERA_MIN_CHAR_DIST2) clamped to [0,1] SetTranslucencyHierarchical(player, t); else (d 0.45) SetTranslucencyHierarchical(player, 0) guarded by `if (0.0 < t)` (reset-on-transition only behaviorally equivalent to the claim's unconditional phrasing). Constants confirmed at pc:956784-956785: CAMERA_MIN_CHAR_DIST2 = 0.449999988, CAMERA_MIN_CHAR_TRANS_DIST = 0.200000003. Live per-frame caller confirmed: gmSmartBoxUI::UseTime (Ghidra xref from 004d74b9; pc:219786). SetTranslucencyHierarchical @ 0x005116c0 (Ghidra decompile) writes this->translucency, calls CPartArray::SetTranslucencyInternal, recurses CHILDLIST children — so retail's fade reaches the player's drawable part hierarchy. ACDREAM verified: RetailChaseCamera.cs:207-208 computes PlayerTranslucency = ComputeTranslucency(Distance(publishedEye, pivotWorld)) from the collided eye; ComputeTranslucency :454-463 matches retail exactly (Far=0.45, Near=0.20, same ramp, 0=opaque/1=invisible convention per :81 doc). Whole-src grep for PlayerTranslucency: ONLY RetailChaseCamera.cs:82 (declaration), :119 (doc), :208 (assignment) — zero consumers. All GameWindow.cs _retailChaseCamera sites (678, 1065-1092, 4921, 6743-6832, 7283-7304, 10442-11682) read ViewerCellId/Position/View or call Update/AdjustDistance/AdjustPitch — none reads PlayerTranslucency. Alternate-mechanism check: TranslucencyKind/SurfOpacity pipeline (GfxObjMesh.cs:201-227, TranslucencyKind.cs:80-121) is static per-SURFACE dat translucency, not per-entity dynamic; grep over src/AcDream.App/Rendering for per-entity/per-instance alpha override → no matches; CameraViewFirstPerson (InputAction.cs:216) has no App-layer consumer that hides the player. Blast radius accurate: DistanceMin = 2f (RetailChaseCamera.cs:85) means only the camera-collision pull-in can bring the eye inside 0.45 m — exactly the #115 cramped-interior scenario. The divergence is real: retail fades the player part hierarchy per frame; acdream computes the identical value and never applies it, leaving the player opaque against the back of the camera. Severity medium is fair (visible artifact class, limited to close-camera interiors).
- blastRadius: In exactly the #115 scenario (cramped interiors, eye pulled within 0.45 m of the head pivot) retail fades the player toward invisible; acdream leaves the player fully opaque, so the camera presses into the back of the player's head/torso and the model fills or clips the view. Contributes to "indoor world feels right" and the cramped-interior feel complaint; also makes the (correct) aggressive collision pull-in look worse than retail's.
- retailEvidence: CameraSet::UpdateCamera (Ghidra 0x00458ae0; pc:97625-97745): d = Position::distance(pivot, &sbox->viewer) at 0x00458beb (collided published viewer); d ≥ 0.449999988 → SetTranslucencyHierarchical(player, 0f) (0x00458ca1); else t = 1 (0.200000003 d)/(0.2 0.45) clamped (0x00458c19-0x00458c53) → CPhysicsObj::SetTranslucencyHierarchical(player, t) (0x00458c6d); InHead → t = 1f (0x00458bb8). Applied to the player part hierarchy every frame.
- acdreamEvidence: RetailChaseCamera.cs:207-208 computes PlayerTranslucency via ComputeTranslucency (:454-463, thresholds and formula match retail exactly, from the collided eye — correct). Grep over src/: the only references are RetailChaseCamera.cs:82 (declaration, doc says "Read by GameWindow"), :119, :208 — no GameWindow or renderer site consumes it; no per-entity translucency override reaches the player's draw.
- portShape: Wire the existing value through: per frame in GameWindow's camera block, push _retailChaseCamera.PlayerTranslucency into the local player entity's render path as an alpha/translucency override on its batches (the surface-metadata table already carries per-batch translucency for the two-pass alpha-test pipeline; a per-INSTANCE override needs the reserved InstanceData highlight/translucency hook or a per-entity skip-draw at t ≥ ~1). Smallest faithful first step: skip drawing the player when t == 1 and treat 0 < t < 1 via the transparent pass.
### [LOW] sought-position-lacks-cell-identity (confirmed) — The sought eye is a bare world-space Vector3; retail's viewer_sought_position is a cell-qualified Position
- verifier notes: RETAIL side re-derived entirely from Ghidra decompiles (BN pseudo-C used only for navigation):
1. Struct claim checks out: acclient.h:35193 `Position viewer`, :35194 `CObjCell *viewer_cell`, :35196 `Position viewer_sought_position` the sought eye IS a cell-qualified Position in SmartBox.
2. SmartBox::update_viewer (Ghidra 0x00453ce0): at function entry `local_120.objcell_id = (this->viewer_sought_position).objcell_id` + frame copy local_120 is initialized from the sought INCLUDING its cell. After `CTransition::find_valid_position` fails, fallback-1 is `CPhysicsObj::AdjustPosition(&local_120, &viewer_sphere.center, &local_170, 0, 1)`; on success `set_viewer(this, &local_120, 0); this->viewer_cell = local_170`. Exactly as claimed. (One wrinkle the claim omits, not load-bearing: before the sweep, `Position::localtoglobal(&local_d8, &local_12c, &local_120)` re-expresses local_120's ORIGIN into the start cell's landblock frame while keeping the sought objcell_id a no-op in the same landblock; the cell-context claim is unaffected.)
3. The sought really does carry the published viewer's cell: SmartBox::PlayerPhysicsUpdatedCallback (Ghidra 0x00452d60; raw disasm shows `CALL 0x00456660` at 0x00452d75, input `LEA EAX,[ESI+0x8]` = &this->viewer, result objcell_id `[EAX+4]` stored to `[ESI+0x5c]` = viewer_sought_position.objcell_id) does `viewer_sought_position = CameraManager::UpdateCamera(camera_manager, &local_48, &this->viewer)`. CameraManager::UpdateCamera (Ghidra 0x00456660) tail: `local_158.objcell_id = param_1->objcell_id` before interpolate_origin/interpolate_rotation; ALL three return paths (interpolated, convergence-snap `Position::Position(ret, param_1)`, degenerate) copy param_1's (published viewer's) objcell_id into the returned Position. The claimed citation is exact.
4. The cell context is genuinely load-bearing inside retail AdjustPosition (Ghidra 0x00511d80): `(objcell_id & 0xffff) < 0x100` selects the branch; indoor branch searches the SEED cell's stab list (`CEnvCell::find_visible_child_cell` on `CObjCell::GetVisible(objcell_id)`); outdoor branch interprets the (landblock-relative) origin in the seed's landblock via `LandDefs::adjust_to_outside`. So sought-cell vs player-cell context can change the fallback's answer.
ACDREAM side — all citations check out: RetailChaseCamera.cs:104 `private Vector3 _dampedEye;` (bare Vector3, no cell; the class's only cell state is ViewerCellId, the PUBLISHED viewer cell, which is never fed back into the sweep). PhysicsCameraCollisionProbe.cs:94-99: fallback-1 calls `_physics.AdjustPosition(cellId, desiredEye)` where `cellId` is the SweepEye parameter; the comment at :96-97 self-documents the substitution. Production call chain confirms `cellId` = the PLAYER's cell: GameWindow.cs:6837 passes `cellId: _playerController.CellId` → RetailChaseCamera.cs:195 forwards it to SweepEye. The probe is stateless — nothing elsewhere supplies the sought eye's own cell.
Divergence is REAL, not behaviorally equivalent: when the sweep fails with the eye in a cell different from the player's (camera trailing through a doorway / across a seam), retail seeds the re-seat with the eye's own cell (outdoor eye → adjust_to_outside succeeds; indoor eye in cell A → A's own stab list), while acdream seeds with the player's cell — and acdream's indoor branch returns (seed, false) outright when the player cell's stab-list miss combines with !SeenOutside (PhysicsEngine.cs:549-551), dropping to fallback-2 (snap viewer to player, viewer cell 0) where retail would have kept the sought eye → one-frame render-root blip, exactly the claimed blast radius. Partial mitigation narrows but doesn't erase it: acdream's AdjustPosition takes a WORLD point and its outdoor path scans all loaded landblocks by the point (PhysicsEngine.cs:557-566), so the retail outdoor landblock-frame dependence is moot by construction, and SeenOutside indoor seeds fall through to the correct outdoor answer. Severity "low" / fallback-only is accurate, and the "becomes load-bearing once boom-no-collided-feedback is ported" rider is supported by the verified sought-derivation chain (0x00452d60 → 0x00456660). Port shape as claimed is consistent with the verified retail flow.
- blastRadius: Fallback paths only: when the sweep fails outright, retail re-seats the sphere at the sought position using the sought position's OWN objcell_id as context; acdream's probe seeds AdjustPosition with the player cell instead (self-documented at PhysicsCameraCollisionProbe.cs:96-97). Wrong cell context across a landblock/indoor seam could pick a wrong fallback cell for one frame (render-root blip). Becomes load-bearing once boom-no-collided-feedback is ported, since the sought state then persists across frames and carries the published viewer's cell.
- retailEvidence: SmartBox::update_viewer (Ghidra 0x00453ce0): local_120 is initialized from viewer_sought_position with its objcell_id and used as the fallback AdjustPosition input; CameraManager::UpdateCamera returns a Position whose objcell_id = param_1->objcell_id (published viewer's cell, Ghidra 0x00456660 tail local_158.objcell_id assignment); acclient.h SmartBox holds viewer / viewer_sought_position as Position (cell + frame) and viewer_cell as CObjCell*.
- acdreamEvidence: RetailChaseCamera.cs:104 `_dampedEye` is a Vector3 with no cell; PhysicsCameraCollisionProbe.cs:94-99 fallback 1 seeds AdjustPosition with `cellId` (the player's cell) and the comment admits "acdream's camera doesn't track the sought-eye's cell separately".
- portShape: Falls out of the boom-no-collided-feedback port for free: once the published viewer (cell + position) is the persistent state and the sought is derived from it each frame, carry the published cell with the sought eye and pass it as the fallback-1 AdjustPosition context.
### [LOW] camera-input-scalars-unverified (adjusted) — Mouse-orbit and held-key input scalars are invented constants, not retail's
- correctedClaim: Mouse-orbit and held-key camera scalars in acdream are partly invented and the integration shape diverges from retail — but one claimed-uncited constant is actually retail-correct, and the retail formula needed two corrections. Retail (Ghidra-verified): CameraSet::Rotate @ 0x00458310 computes value = m_rCameraAdjustmentSpeed × (cur_time m_ttLastRotate), REPLACED (not multiplied) by the caller's scale when scale ≠ 1.0, then rotates viewer_offset around Z by value × angle, where angle is a global = π/(180/8) ≈ 0.13963 rad (8°), data @ 0x0083d034, init $E123 @ 0x006eabf0; gate is F_EPSILON = 0.000199999995 s; mouse-look (MouseLookHandler, call @ 0x00458ef9) passes scale = FilterMouseInput(delta) × ICIDM[0x20] × 1/15 after a 5-sample debounce. m_rCameraAdjustmentSpeed = 40.0 IS extracted (CameraManager ctor, 0x0045710a / pc:95986) — acdream's 40.0 (CameraDiagnostics.cs:78) is retail-correct and only missing a citation, as are its 0.45 stiffness defaults. The REAL divergences: (1) acdream's RMB-orbit scalars 0.004 yaw / 0.003 pitch per count (GameWindow.cs:1091-1092) are invented; retail's per-count yaw = filtered × sensitivity × (1/15) × 0.13963 rad via offset rotation. (2) acdream's held-key pitch ×0.02 (GameWindow.cs:6751-6753) is invented; retail Raise @ 0x00457b00 uses ×0.13963 rad (≈7× faster, modulo untraced caller scale). (3) acdream's held-key zoom is additive meters at 40 m/s clamped 2-40 (GameWindow.cs:6746-6749 + RetailChaseCamera.cs:216-217); retail Closer @ 0x004586d0 is a multiplicative shrink viewer_offset ×= (1 40·dt·0.2) (CAMERA_MOUSELOOK_INC = 0.2 @ 0x0079bc04) — a structural shape difference, not just a scalar. Severity remains low (feel-polish, #115 class). Port shape: replace the 0.004/0.003/0.02 scalars and the additive zoom with retail's value×angle offset-rotation/shrink forms; extract ICIDM[0x20]'s default before finalizing the mouse path.
- verifier notes: RE-CHECKED RETAIL (Ghidra decompile + disassembly, not BN-only):
(1) CameraSet::Rotate @ 0x00458310 — confirmed: F_EPSILON minimum-elapsed gate (the claim's "0.0002 s" is exactly retail F_EPSILON = 0.000199999995, the same constant acdream already cites at src/AcDream.Core/Physics/CellTransit.cs:36); m_ttLastRotate seeded to cur_time 1/SceneTool::m_FramesPerSecond when zero; rotation applied as a sin/cos Z-rotation of cm->viewer_offset (fsin/fcos at 0x004585cb-0x004585cf left branch, 0x00458609-0x0045860d right branch — the claimed 0x00458609-0x00458629 range is the right-branch math). TWO FORMULA CORRECTIONS: (a) param_3 (scale) REPLACES the speed×elapsed product when ≠ 1.0 (`if (param_3 != 1.0) fVar8 = param_3`), it is NOT a multiplicative term as claimed; (b) the resulting value is multiplied by a global `angle` the claim missed — instruction-level verified: static initializer $E123 @ 0x006eabf0 does FSTP [0x0083d034] with value π/(180/8) = 8° in radians ≈ 0.13963 (pc:763151), and Rotate FMULs the SAME address 0x0083d034 (0x004585bd, 0x004585fb).
(2) Mouse-look caller — confirmed: CameraSet::MouseLookHandler (call site 0x00458ef9) passes |FilterMouseInput(raw) × ICIDM[0x20] × 0x3d888889(=1/15)| as Rotate's scale, gated by a 5-sample mouselook_x_extent debounce. So retail per-count yaw = filtered × sensitivity × (1/15) × 0.13963 rad.
(3) m_rCameraAdjustmentSpeed — the claim said "not extracted"; NOW EXTRACTED: CameraManager::CameraManager (Ghidra decompile containing 0x0045710a; pc:95986) sets m_rCameraAdjustmentSpeed = 40.0, and pc:96077 registers it as console var "Camera_AdjustmentSpeed". So acdream's CameraDiagnostics.CameraAdjustmentSpeed = 40.0f is RETAIL-CORRECT, merely uncited — the "invented constant" framing is refuted for this one. (Bonus: same ctor sets t_stiffness = r_stiffness = 0.45, so acdream's 0.45 stiffness defaults at CameraDiagnostics.cs:56/63 are retail-correct too.)
(4) Held-key paths — CameraSet::Raise @ 0x00457b00 (Ghidra): pitch delta = (elapsed × 40) × angle(0.13963) rad, with an extra ×0.25 when ICIDM[0x22].field_0x1 is set; CameraSet::Closer @ 0x004586d0 (Ghidra): zoom is MULTIPLICATIVE — viewer_offset *= (1 elapsed×40×CAMERA_MOUSELOOK_INC), CAMERA_MOUSELOOK_INC = 0.2 (static const @ 0x0079bc04, pc:956772), floored at CAMERA_MIN_CHAR_DIST.
RE-CHECKED ACDREAM: all three citations accurate. GameWindow.cs:1086-1098 — RMB orbit: YawOffset = filteredDx × 0.004f × sens (line 1091) and AdjustPitch(filteredDy × 0.003f × sens) (line 1092); both scalars uncited. GameWindow.cs:6745-6753 — adj = CameraAdjustmentSpeed × dt; CameraZoomIn/Out → AdjustDistance(±adj) which per RetailChaseCamera.cs:216-217 is ADDITIVE METERS clamped 2..40 (i.e. 40 m/s linear); CameraRaise/Lower → AdjustPitch(±adj × 0.02f) = 0.8 rad/s, uncited. CameraDiagnostics.cs:73-78 — "Retail default 40.0" comment with no decomp citation (value now proven right). Acdream's FilterMouseDelta (RetailChaseCamera.cs:231-244) faithfully mirrors retail FilterMouseInput, so the divergence is confined to the post-filter scalars and integration shape.
JUDGMENT: the core divergence is REAL — acdream's 0.004 (yaw/count), 0.003 (pitch/count), and 0.02 (held-key pitch) scalars have no retail provenance, and the held-key shapes diverge structurally: retail pitch rate ≈ 40 × 0.13963 ≈ 5.6 rad/s vs acdream 0.8 rad/s (~7× slower, modulo unverified caller scale params), and retail zoom is an exponential offset shrink vs acdream's linear m/s — both squarely in the #115 "feel" class. Severity low stands (feel-polish only, no rendering/correctness impact). OPEN QUESTIONS: (a) what scale param retail's per-frame held-key callers (UpdateCamera @ 0x00458b27 / OnAction @ 0x0045603b) actually pass — 1.0 assumed, not traced; (b) ICIDM[0x20] default mouse-look sensitivity value not extracted, so an exact retail-equivalent per-count yaw constant cannot be computed yet; (c) semantic of ICIDM[0x22] flag (routes rotate to character-turn motion commands 0x6500000d/0x6500000e and ×0.25 on raise) not pinned.
- blastRadius: Feel-polish only: turn-rate of the RMB orbit and zoom/raise key speed may differ from retail, compounding the #115 "draggy" perception even after the boom fix. Not a correctness or rendering issue.
- retailEvidence: CameraSet::Rotate (Ghidra 0x00458310; pc:97103-97230): rotation angle = cm->m_rCameraAdjustmentSpeed × (Timer::cur_time m_ttLastRotate) × scale, applied as a Z-rotation of viewer_offset (sin/cos at 0x00458609-0x00458629), with a 0.0002 s minimum-elapsed gate and m_ttLastRotate seeded to cur_time 1/FPS; mouse-look callers pass a delta-derived scale (0x00458ef9). The retail value of m_rCameraAdjustmentSpeed and the mouse-delta→scale mapping were not extracted in this sweep.
- acdreamEvidence: GameWindow.cs:1089-1091 — YawOffset = filteredDx × 0.004 × sens (0.004 is uncited); GameWindow.cs:6745-6753 — Distance/Pitch integrate CameraAdjustmentSpeed·dt with an uncited ×0.02 pitch scalar; CameraDiagnostics.cs:78 claims "Retail default 40.0" for CameraAdjustmentSpeed without a decomp citation.
- portShape: Extract cm->m_rCameraAdjustmentSpeed's initialization (CameraManager constructor at 0x004570b1 region / config read) and the mouse-look caller at 0x00458ef9; replace the 0.004 / 0.02 / 40.0 constants with the retail values and route mouse-look through the same offset-rotation shape (rotate viewer_offset by speed×elapsed×scale) instead of direct YawOffset integration.
## OPEN QUESTIONS
- Why acdream's earlier feedback experiment oscillated (the warning at RetailChaseCamera.cs:183-187 says writing the clamped result into _dampedEye caused visible vibration against walls). Retail's exact shape — damp FROM the published viewer in the physics phase, sweep pivot→sought in the draw phase, publish raw — is a stable fixed point on paper (the sweep restarts at the pivot every frame), so the historical vibration likely came from a different feedback wiring (e.g., lerping the clamped eye toward the full target inside the same call, or the InitPath sphere-center Z-offset interacting with the clamp). The port should reproduce retail's ordering exactly and re-run the cramped-interior visual gate rather than assume the old failure generalizes.
- Retail's mouse-look delta→CameraSet::Rotate scale mapping (callers around 0x00458ef9) and the initialization value of CameraManager::m_rCameraAdjustmentSpeed were not extracted; acdream's 0.004·sens yaw scalar, ×0.02 pitch scalar, and CameraAdjustmentSpeed=40.0 are therefore unverified against retail.
- Ghidra labels the function containing the DrawNoBlit call at 0x0045557a as 'Draw' while the BN pseudo-C listing shows that address at the tail of the block starting with SmartBox::UseTime (0x00455410) — the two tools disagree on the function boundary. Either way both run once per frame with CPhysics::UseTime (the damping callback) executing BEFORE DrawNoBlit (the sweep), which is the ordering that matters for the port.
- Retail's default camera pivot: update_viewer composes the pivot from camera_manager->pivot_part_index's part frame plus camera_manager->pivot_offset (Ghidra 0x00453ce0, LAB_00453da5 region); acdream hardcodes pivot = player position + 1.5 m (RetailChaseCamera.cs:71,151). Whether retail's default pivot_offset is exactly (0,0,1.5) with pivot_part_index = -1 for players was not verified in this sweep.
- Whether the hypothesized #109 link (1-frame eye snap-out flipping ViewerCellId across portal planes → render-root oscillation at far doors) actually matches the #109 reproduction — needs a capture correlating the eye-position delta per frame with the ViewerCellId flip sequence before crediting the boom fix with any #109 improvement.