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>
39 KiB
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 ≥ 1−F_EPSILON ⇒ instant), rotation alpha likewise from r_stiffness; (e) Frame::interpolate_origin(&result, ¶m_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, ¶m_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, ¶m_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 pivot→sought 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 localpublishedEyeonly; :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 pivot→sought (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):
-
Struct claim checks out: acclient.h:35193
Position viewer, :35194CObjCell *viewer_cell, :35196Position viewer_sought_position— the sought eye IS a cell-qualified Position in SmartBox. -
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. AfterCTransition::find_valid_positionfails, fallback-1 isCPhysicsObj::AdjustPosition(&local_120, &viewer_sphere.center, &local_170, 0, 1); on successset_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.) -
The sought really does carry the published viewer's cell: SmartBox::PlayerPhysicsUpdatedCallback (Ghidra 0x00452d60; raw disasm shows
CALL 0x00456660at 0x00452d75, inputLEA EAX,[ESI+0x8]= &this->viewer, result objcell_id[EAX+4]stored to[ESI+0x5c]= viewer_sought_position.objcell_id) doesviewer_sought_position = CameraManager::UpdateCamera(camera_manager, &local_48, &this->viewer). CameraManager::UpdateCamera (Ghidra 0x00456660) tail:local_158.objcell_id = param_1->objcell_idbefore interpolate_origin/interpolate_rotation; ALL three return paths (interpolated, convergence-snapPosition::Position(ret, param_1), degenerate) copy param_1's (published viewer's) objcell_id into the returned Position. The claimed citation is exact. -
The cell context is genuinely load-bearing inside retail AdjustPosition (Ghidra 0x00511d80):
(objcell_id & 0xffff) < 0x100selects the branch; indoor branch searches the SEED cell's stab list (CEnvCell::find_visible_child_cellonCObjCell::GetVisible(objcell_id)); outdoor branch interprets the (landblock-relative) origin in the seed's landblock viaLandDefs::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
_dampedEyeis a Vector3 with no cell; PhysicsCameraCollisionProbe.cs:94-99 fallback 1 seeds AdjustPosition withcellId(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 globalanglethe 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.