diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 51c73089..f35c88e9 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -609,10 +609,21 @@ and no world point lights for this pass. Chat, gameplay windows, toolbar,
cursor, and input continue normally above it. The synthetic object never
enters `LiveEntityRuntime`, collision, picking, radar, or server GUID state.
+`TeleportViewPlaneController` is the App-layer projection adapter for retail
+`SmartBox::SetOverrideFovDistance` / `Render::set_vdst`. It captures the active
+game projection at teleport begin, then applies the same table-eased
+view-plane distance and near plane to the portal and world viewports through
+the four fade states. Portal camera direction is a roll around its local AC
+`+Y` forward axis, so the animated scene remains a passage rather than yawing
+away from it. Destination placement also calls the retail chase camera's
+`set_viewer(player, reset_sought=1)` equivalent; the normal damped/swept camera
+path then re-extends from the arriving player.
+
The destination residency gate supplies retail's `EndTeleportAnimation` edge
once asynchronous streaming is ready. Player placement remains authoritative
-and separate from presentation; arrival does not reset the character animation
-sequence. See `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
+and separate from presentation; arrival resets camera viewer state but does
+not reset the character animation sequence. See
+`docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
## Roadmap Model
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 90fd320d..74c0f3c8 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -96,7 +96,6 @@ accepted-divergence entries (#96, #49, #50).
| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) |
| AD-36 | `IMotionDoneSink.MotionDone` consumed for CREATURE-class entities only: R3-W2 binds the seam to the entity's `MotionInterpreter.MotionDone` (player via `PlayerMovementController.Motion`, remotes via `RemoteMotion.Motion`, resolved at fire time); interp-less entities (statics that never receive a UM/UP and so never get a `RemoteMotion`) keep a diagnostic-recorder-only target — retail gives every CPhysicsObj a MovementManager/CMotionInterp (R2-Q4 seam, narrowed R3-W2, 2026-07-02; R5-V5 gave every `RemoteMotion`/player ONE literal `MovementManager` facade, so the residue is only the no-RemoteMotion class) | `src/AcDream.App/Rendering/GameWindow.cs` (TickAnimations MotionDoneTarget bind) | Motion for entities without a `RemoteMotion` (never UM/UP-touched) completes via the manager queue alone; nothing consumes their MotionDone until every sequencer-owning entity gets a host/`RemoteMotion` (doors DO have one since the R4-V5 door fix — first UM creates it) | An entity behavior depending on pending_motions bookkeeping in that no-RemoteMotion class (none known) would silently no-op | `CPhysicsObj::MotionDone` 0x0050fdb0; retire when every sequencer-owning entity constructs a `RemoteMotion`/host (post-M1.5 entity-class unification; R5-V5 closed the facade half) |
| AD-37 | Camera rotation state is a forward VECTOR (nlerp + normalize; roll always 0, up = world Z); retail's sought carries a full Frame and slerps quaternions (`Frame::interpolate_rotation` shortest-path slerp with 2e-4 nlerp fallback). The dead-band compares forward-vector distance against the same 2e-4 epsilon retail applies per quaternion component | `src/AcDream.App/Rendering/RetailChaseCamera.cs` (`_dampedForward`, `ApplyConvergenceSnap`) | The chase camera never rolls (heading frames are Z-up by construction), so a forward vector spans the reachable rotation space; identified (not introduced) during the #180 UpdateCamera tail reading | If a future camera mode needs roll (death cam, cutscene) the vector state can't represent it; large-angle per-frame turns nlerp (chord) vs slerp (arc) — imperceptible at 0.45-stiffness step sizes | `Frame::interpolate_rotation` 0x00535390, `Frame::close_rotation` 0x00455d70; pseudocode doc 2026-07-06-camera-sought-position |
-| AD-38 | First camera frame seats sought = viewer = the full-length target eye (starts converged); retail hard resets both to the PLAYER's position (`set_viewer(pos, reset_sought=1)`) at login/teleport/cell-loss, so the retail camera visibly re-extends outward from the head over ~1 s | `src/AcDream.App/Rendering/RetailChaseCamera.cs` (`_initialised` branch) | acdream teleports don't reset the camera object today; the pivot-anchored sweep self-heals in one convergence, and the login zoom-out is cosmetic. Identified (not introduced) during the #180 reading | Login/teleport first frames show a fully-extended boom where retail shows a zoom-out; if a teleport lands the stale sought behind distant geometry the first sweeps clip until convergence (sub-second) | `SmartBox::set_viewer` 0x00452c40 (reset_sought=1 sites pc:92775, pc:92886); retire by wiring teleport → sought reset |
| AD-39 | The `frames_stationary_fall` ladder + fsf≥3 UP-contact-plane manufacture runs AFTER acdream's fused LKCP-restore/contact-marking block, deriving retail's `_redo` as `cleanAdvance \|\| OnWalkable`; retail (ACE Transition.cs:1029-1061) interleaves the fsf block BETWEEN the LKCP-restore (sets `_redo`) and the contact-marking (reads the manufactured plane) (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` fsf tail) | acdream deliberately fused ACE's separate LKCP-restore + contact-mark blocks (the L.2.3c/L.2.4/A6.P3 contact-retention divergences); running the ladder after them and re-marking grounding inside the manufacture branch is semantically equal (a grounded wall-slide is not a stuck-fall in either arrangement) without disturbing those hard-won fixes | If a future contact-retention change alters when OnWalkable is set relative to the ladder, `_redo` could misclassify a frame (grounded-jam mistaken for stuck-fall → spurious velocity zero, or vice-versa) — the fsf conformance tests pin the current arrangement | `CTransition::validate_transition` 0x0050aa70 pc:272625-656; ACE Transition.cs:1029-1061 |
| AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.App/Input/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) |
| AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.App/Input/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) |
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 924ab690..4654d416 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -593,7 +593,7 @@ Research: R9 + R12 + R13.
- **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys.
- **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload.
- **Indoor portal-based cell tracking (follow-up to Indoor walking Phase 1 / issue #87).** Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's `CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses a cell portal boundary, `CellId` propagates through the `CEnvCell` portal connectivity graph. Prerequisite for wall collision from outside (#85) and the remaining #84 threshold symptom. PDB symbols and `acclient.h` `CCellStructure` refs are in place (see #87). **Unblocks G.3.**
-- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/fades; visual gate pending 2026-07-15). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
+- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/fades, forward-axis roll, projection warp, and destination viewer reset; corrective visual gate pending 2026-07-15). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 453ada72..dbedf4f2 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -305,8 +305,9 @@ capture-harness-first).
temporary black tunnel cover is retired. F751 transit now renders retail's
client-enum-resolved DAT Setup `0x02000306` with animation `0x030005AC` at
40 frames/s in a dedicated CreatureMode-equivalent viewport, using retail's
-camera, distant light, random eased yaw, frame-aligned exit window, and exact
-100-sample fade curve. The viewport and fades compose below retained UI, so
+camera, distant light, random eased forward-axis roll, frame-aligned exit
+window, exact 100-sample fade curve, view-plane/FOV warp, and destination
+viewer reset. The viewport and fades compose below retained UI, so
chat, panels, toolbar, cursor, and input remain live throughout travel. See
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
diff --git a/docs/research/2026-07-15-retail-portal-space-pseudocode.md b/docs/research/2026-07-15-retail-portal-space-pseudocode.md
index 3c44bf30..1ada51f2 100644
--- a/docs/research/2026-07-15-retail-portal-space-pseudocode.md
+++ b/docs/research/2026-07-15-retail-portal-space-pseudocode.md
@@ -16,6 +16,7 @@ Named Sept-2013 retail symbols:
- `gmSmartBoxUI::UseTime` `0x004D6E30`
- `CreatureMode::CreatureMode` `0x00454390`
- `CreatureMode::SetCameraDirection_Degrees` `0x00453760`
+- `Frame::rotate` `0x004525B0`
- `CreatureMode::AddObject` `0x004556D0`
- `UIGlobals::Init` `0x004EE470`
- `UIGlobals::GetAnimLevel` `0x004EE540`
@@ -24,6 +25,9 @@ Named Sept-2013 retail symbols:
- `SmartBox::PlayerPositionUpdated` `0x00453870`
- `CPhysicsObj::teleport_hook` `0x00514ED0`
- `CommandInterpreter::PlayerTeleported` `0x006B32B0`
+- `SmartBox::SetOverrideFovDistance` `0x00451BC0`
+- `SmartBox::GetOverrideFovDistance` `0x00451BE0`
+- `Render::set_vdst` `0x0054B240`
Constants were checked against static x86 disassembly. The Binary Ninja
pseudocode loses several x87 operands in `UseTime`; disassembly is authoritative
@@ -93,7 +97,12 @@ sequence animations, plays `Sound_UI_ExitPortal`, and enters `WorldFadeIn`.
## Camera motion
-The camera rotates around AC's vertical `+Z` axis. Each segment is independent:
+The camera rolls around its own AC `+Y` forward axis. It does not yaw around
+world `+Z`. `SetCameraDirection_Degrees` resets the frame to identity, converts
+the vector to radians, then passes `(0, angle, 0)` to `Frame::rotate`, whose
+input is an axis-angle rotation vector. The forward direction therefore stays
+down the portal passage while local `+Z` up rolls around it. Each segment is
+independent:
```text
if now >= rotationStart + rotationDuration:
@@ -184,6 +193,39 @@ World/tunnel fades use `GetAnimLevel(elapsed / FadeTime)`. The fade belongs
between the 3-D viewport and the retained UI. It must never cover or disable
the retained UI.
+## View-plane warp
+
+Retail couples every fade state to a projection transition. On begin it
+captures the current SmartBox view-plane distance; for a perspective
+projection this is `cot(verticalFov / 2)`. The transition endpoint is the
+named constant `TRANSITION_VIEW_PLANE_DISTANCE = 0.001` at `0x007BD260`.
+
+```text
+fadeLevel = GetAnimLevel(elapsed / FadeTime) / 1024
+
+WorldFadeOut or TunnelFadeOut:
+ currentDistance = gameDistance
+ + (0.001 - gameDistance) * fadeLevel
+
+TunnelFadeIn or WorldFadeIn:
+ currentDistance = 0.001
+ + (gameDistance - 0.001) * fadeLevel
+
+SmartBox.SetOverrideFovDistance(true, currentDistance)
+```
+
+`Render::set_vdst` turns the distance back into projection values:
+
+```text
+verticalFov = 2 * atan(1 / currentDistance)
+znear = max(0.1, currentDistance * 0.25)
+```
+
+At `0.001`, the view is nearly 180 degrees wide. Black covers the singular
+endpoint; as `WorldFadeIn` clears, the FOV eases back to the captured game
+projection. This is retail's characteristic destination-world warp, separate
+from both the alpha fade and chase-camera movement.
+
## Player placement boundary
```text
@@ -195,7 +237,7 @@ PlayerPositionUpdated(isTeleport=true):
clear position-update/teleport bookkeeping
player.teleport_hook()
commandInterpreter.PlayerTeleported()
- move world viewer to player
+ SmartBox.set_viewer(player.position, reset_sought=true)
update CellManager position
player.teleport_hook():
@@ -212,6 +254,11 @@ PlayerTeleported():
SendMovementEvent()
```
+The viewer reset copies the player's complete position into both `viewer` and
+`viewer_sought_position`. Subsequent ordinary camera updates extend the chase
+boom outward through the same damped and swept path used after camera
+collision. Reusing the source-world viewer is not retail behavior.
+
The retail teleport hook does not clear the character's animation sequence.
The arrival presentation is instead hidden by the portal scene and its final
world fade while the normal motion/update stream settles. Therefore acdream
@@ -227,6 +274,11 @@ must not add an arrival-only animation reset.
- During tunnel states the portal scene replaces world pixels before retained
UI draws. The black fade also draws before retained UI. Input dispatch and
`RetailUiRuntime.Tick` continue unchanged.
+- The projection owner captures the active camera's M22 view-plane distance at
+ teleport begin and applies `Render::set_vdst` semantics to both the tunnel
+ and world viewport during the four fade states.
+- Destination placement resets the retail chase camera's published and sought
+ positions to the player before the normal update path resumes.
- Destination residency remains acdream's asynchronous adaptation. It supplies
the same `EndTeleportAnimation` edge retail receives when its blocking cell
load completes; it does not alter presentation ordering.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 50faf792..d334bf52 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -6922,6 +6922,7 @@ public sealed class GameWindow : IDisposable
// fires Place (materialize) and FireLoginComplete (regain control + ack the server).
// Replaces the old TeleportArrivalController hold/place machine.
private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new();
+ private readonly TeleportViewPlaneController _teleportViewPlane = new();
private bool _teleportInProgress;
private System.Numerics.Vector3 _pendingTeleportPos;
private uint _pendingTeleportCell;
@@ -7022,11 +7023,12 @@ public sealed class GameWindow : IDisposable
_playerController.Yaw = AcQuaternionToYaw(_pendingTeleportRot);
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
- _retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
- playerVelocity: System.Numerics.Vector3.Zero,
- isOnGround: true,
- contactPlaneNormal: System.Numerics.Vector3.UnitZ,
- dt: 1f / 60f);
+ // SmartBox::PlayerPositionUpdated (0x00453870) calls
+ // set_viewer(player_pos, reset_sought=1): the viewer and sought
+ // position both restart at the player, then the ordinary per-frame
+ // camera path re-extends the boom. Updating directly from the stale
+ // source-world viewer made the destination bend across the reveal.
+ _retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw);
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}");
@@ -7055,6 +7057,8 @@ public sealed class GameWindow : IDisposable
_teleportInProgress = true;
_teleportHoldSeconds = 0f;
_teleportForced = false;
+ _teleportViewPlane.Begin(
+ _cameraController?.Active.Projection ?? System.Numerics.Matrix4x4.Identity);
_teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal);
Console.WriteLine($"live: teleport started (seq={sequence})");
}
@@ -8900,6 +8904,7 @@ public sealed class GameWindow : IDisposable
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
var (snap, evts) = _teleportAnim.Tick((float)dt, ready, tunnelFrame);
+ _teleportViewPlane.Update(snap);
// The fade is a viewport transition below retained UI. If the
// installed DATs are corrupt and the retail portal scene could
// not be built, retain an opaque world cover during transit.
@@ -8934,6 +8939,7 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
_teleportInProgress = false;
_pendingTeleportCell = 0u;
+ _teleportViewPlane.Reset();
_teleportFadeAlpha = 0f;
break;
default:
@@ -9570,7 +9576,8 @@ public sealed class GameWindow : IDisposable
if (_cameraController is not null)
{
var camera = _cameraController.Active;
- var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection);
+ var worldProjection = _teleportViewPlane.Apply(camera.Projection);
+ var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
// Extract camera world position from the inverse of the view
// matrix — needed by the scene-lighting UBO (for fog distance)
@@ -9799,7 +9806,7 @@ public sealed class GameWindow : IDisposable
// Phase A8: update EnvCellRenderer's frustum. The per-frame shell snapshot
// is prepared after the portal-visible cell filter is known.
- var envCellViewProj = camera.View * camera.Projection;
+ var envCellViewProj = camera.View * worldProjection;
_envCellFrustum?.Update(envCellViewProj);
// MP-Alloc: reuse _animatedIdsScratch instead of `new`ing a
@@ -10422,7 +10429,7 @@ public sealed class GameWindow : IDisposable
}
_debugDrawLogOnce++;
}
- _debugLines.Flush(camera.View, camera.Projection);
+ _debugLines.Flush(camera.View, worldProjection);
}
// Count visible vs total for the perf overlay.
@@ -10484,10 +10491,12 @@ public sealed class GameWindow : IDisposable
// CreatureMode portal-space viewport. This replacement scene and
// its black transition draw BEFORE retained UI, so chat, windows,
// cursor, and toolbar remain visible and interactive in transit.
+ var portalProjection = _teleportViewPlane.Apply(
+ _cameraController!.Active.Projection);
_portalTunnel?.Draw(
_window!.Size.X,
_window.Size.Y,
- _cameraController!.Active.Projection);
+ portalProjection);
_fadeOverlay?.Draw(_teleportFadeAlpha);
// Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the
diff --git a/src/AcDream.App/Rendering/PortalTunnelCamera.cs b/src/AcDream.App/Rendering/PortalTunnelCamera.cs
index f6d54eb6..2d3dad91 100644
--- a/src/AcDream.App/Rendering/PortalTunnelCamera.cs
+++ b/src/AcDream.App/Rendering/PortalTunnelCamera.cs
@@ -6,7 +6,7 @@ namespace AcDream.App.Rendering;
/// Retail portal-space camera from gmSmartBoxUI::PostInit
/// (0x004D6D8A) and CreatureMode::SetCameraDirection_Degrees
/// (0x00453760). Identity looks along AC +Y with +Z up; the animated
-/// angle rotates that view around +Z.
+/// angle rolls that view around its own +Y forward axis.
///
public sealed class PortalTunnelCamera : ICamera
{
@@ -20,8 +20,9 @@ public sealed class PortalTunnelCamera : ICamera
///
/// Retail CreatureMode::UseSmartboxFOV reads the active SmartBox
- /// projection each draw. Recover its vertical field of view from that
- /// perspective matrix while retaining this private scene's near/far planes.
+ /// projection each draw. Recover its vertical field of view and near
+ /// plane from that perspective matrix while retaining this private
+ /// scene's bounded far plane.
///
public void UseSmartBoxFov(Matrix4x4 smartBoxProjection)
{
@@ -32,6 +33,15 @@ public sealed class PortalTunnelCamera : ICamera
float fov = 2f * MathF.Atan(1f / verticalScale);
if (float.IsFinite(fov) && fov > 0f && fov < MathF.PI)
FovRadians = fov;
+
+ // System.Numerics perspective matrices encode near as M43/M33.
+ // During teleport fades this carries Render::set_vdst's
+ // max(0.1, viewPlaneDistance * 0.25) result.
+ float near = smartBoxProjection.M33 != 0f
+ ? smartBoxProjection.M43 / smartBoxProjection.M33
+ : float.NaN;
+ if (float.IsFinite(near) && near > 0f && near < Far)
+ Near = near;
}
public Matrix4x4 View
@@ -39,9 +49,17 @@ public sealed class PortalTunnelCamera : ICamera
get
{
float radians = DirectionDegrees * (MathF.PI / 180f);
- Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, radians);
- Vector3 forward = Vector3.Transform(Vector3.UnitY, rotation);
- return Matrix4x4.CreateLookAt(RetailEye, RetailEye + forward, Vector3.UnitZ);
+ // CreatureMode::SetCameraDirection_Degrees (0x00453760) resets
+ // the Frame to identity, converts (0, angle, 0) to radians, and
+ // passes that rotation vector to Frame::rotate (0x004525B0).
+ // Frame::rotate interprets the vector as axis * angle, so this is
+ // a roll around AC +Y -- the identity camera's FORWARD axis --
+ // not a yaw around world +Z. The passage therefore stays ahead
+ // while the authored tunnel rolls around the viewer.
+ Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, radians);
+ Vector3 forward = Vector3.UnitY;
+ Vector3 up = Vector3.Transform(Vector3.UnitZ, rotation);
+ return Matrix4x4.CreateLookAt(RetailEye, RetailEye + forward, up);
}
}
diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs
index 8ef3daf2..c2eefc8b 100644
--- a/src/AcDream.App/Rendering/RetailChaseCamera.cs
+++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs
@@ -247,6 +247,32 @@ public sealed class RetailChaseCamera : ICamera
PlayerTranslucency = ComputeTranslucency(d);
}
+ ///
+ /// Retail SmartBox::set_viewer(playerPosition, reset_sought: 1)
+ /// (0x00452C40), called by
+ /// SmartBox::PlayerPositionUpdated after a teleport
+ /// (0x00453870). Both the published viewer and its sought
+ /// position restart at the player; subsequent camera updates then
+ /// re-extend the boom through the ordinary damped/swept path.
+ ///
+ public void ResetViewerToPlayer(Vector3 playerPosition, float playerYaw)
+ {
+ Vector3 playerForward = new(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f);
+
+ _publishedEye = playerPosition;
+ _soughtEye = playerPosition;
+ _dampedForward = playerForward;
+ _initialised = true;
+
+ Position = playerPosition;
+ ViewerCellId = 0u; // retail set_viewer clears viewer_cell at the teleport boundary
+ View = Matrix4x4.CreateLookAt(
+ playerPosition,
+ playerPosition + playerForward,
+ Vector3.UnitZ);
+ PlayerTranslucency = 0f;
+ }
+
///
/// Adjust the camera distance (zoom) by a delta, clamped to
/// ... Mirrors
diff --git a/src/AcDream.App/Rendering/TeleportViewPlaneController.cs b/src/AcDream.App/Rendering/TeleportViewPlaneController.cs
new file mode 100644
index 00000000..2accef56
--- /dev/null
+++ b/src/AcDream.App/Rendering/TeleportViewPlaneController.cs
@@ -0,0 +1,98 @@
+using System.Numerics;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Ports retail's teleport projection transition. gmSmartBoxUI::UseTime
+/// (0x004D6E30) eases SmartBox's view-plane distance between the
+/// active game's value and TRANSITION_VIEW_PLANE_DISTANCE = 0.001.
+/// Render::set_vdst (0x0054B240) converts that distance back to
+/// FOV and adjusts the near plane. This is the wide projection warp visible
+/// while the destination world emerges from black.
+///
+public sealed class TeleportViewPlaneController
+{
+ public const float TransitionViewPlaneDistance = 0.001f;
+
+ private float _gameViewPlaneDistance = 1f;
+
+ public bool Enabled { get; private set; }
+ public float CurrentViewPlaneDistance { get; private set; } = 1f;
+
+ ///
+ /// Retail BeginTeleportAnimation captures
+ /// SmartBox::GetOverrideFovDistance once when starting from Off.
+ /// For a standard perspective matrix, M22 is exactly
+ /// cot(verticalFov / 2), retail's view-plane-distance value.
+ ///
+ public void Begin(Matrix4x4 gameProjection)
+ {
+ float distance = gameProjection.M22;
+ if (!float.IsFinite(distance) || distance <= 0f)
+ distance = 1f;
+
+ _gameViewPlaneDistance = distance;
+ CurrentViewPlaneDistance = distance;
+ Enabled = false;
+ }
+
+ ///
+ /// Apply the four retail fade-state branches. Their projection blend is
+ /// numerically the same table-driven level as the black fade: normal to
+ /// 0.001 on fade-out, and 0.001 back to normal on fade-in.
+ ///
+ public void Update(TeleportAnimSnapshot snapshot)
+ {
+ Enabled = snapshot.State is TeleportAnimState.WorldFadeOut
+ or TeleportAnimState.TunnelFadeIn
+ or TeleportAnimState.TunnelFadeOut
+ or TeleportAnimState.WorldFadeIn;
+
+ CurrentViewPlaneDistance = Enabled
+ ? Lerp(_gameViewPlaneDistance, TransitionViewPlaneDistance, snapshot.FadeAlpha)
+ : _gameViewPlaneDistance;
+ }
+
+ public void Reset()
+ {
+ Enabled = false;
+ CurrentViewPlaneDistance = _gameViewPlaneDistance;
+ }
+
+ ///
+ /// Rebuild a perspective projection with retail's active view-plane
+ /// distance while preserving the source projection's aspect and far
+ /// plane. Render::set_vdst uses
+ /// FOV = 2 * atan(1 / distance) and
+ /// znear = max(0.1, distance * 0.25).
+ ///
+ public Matrix4x4 Apply(Matrix4x4 baseProjection)
+ {
+ if (!Enabled)
+ return baseProjection;
+
+ float aspect = baseProjection.M11 != 0f
+ ? baseProjection.M22 / baseProjection.M11
+ : 1f;
+ float far = baseProjection.M33 != -1f
+ ? baseProjection.M43 / (baseProjection.M33 + 1f)
+ : 5000f;
+
+ if (!float.IsFinite(aspect) || aspect <= 0f)
+ aspect = 1f;
+ if (!float.IsFinite(far) || far <= 0.1f)
+ far = 5000f;
+
+ float distance = MathF.Max(CurrentViewPlaneDistance, TransitionViewPlaneDistance);
+ float fov = 2f * MathF.Atan(1f / distance);
+ float near = MathF.Max(0.1f, distance * 0.25f);
+ if (near >= far)
+ near = MathF.Min(0.1f, far * 0.5f);
+
+ return Matrix4x4.CreatePerspectiveFieldOfView(fov, aspect, near, far);
+ }
+
+ private static float Lerp(float from, float to, float amount) =>
+ from + (to - from) * Math.Clamp(amount, 0f, 1f);
+}
diff --git a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
index 11926271..f58702ab 100644
--- a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
@@ -54,7 +54,7 @@ public sealed class PortalTunnelAssetTests
}
[Fact]
- public void PortalTunnelCamera_UsesRetailEyeAndRotatesAroundAcUpAxis()
+ public void PortalTunnelCamera_RollsAroundForwardAxisWithoutLeavingPassage()
{
var camera = new PortalTunnelCamera
{
@@ -68,30 +68,103 @@ public sealed class PortalTunnelAssetTests
camera.DirectionDegrees = 90f;
Assert.True(System.Numerics.Matrix4x4.Invert(camera.View, out var rotatedWorld));
+ System.Numerics.Vector3 forward = System.Numerics.Vector3.Normalize(
+ System.Numerics.Vector3.TransformNormal(-System.Numerics.Vector3.UnitZ, rotatedWorld));
System.Numerics.Vector3 up = System.Numerics.Vector3.Normalize(
System.Numerics.Vector3.TransformNormal(System.Numerics.Vector3.UnitY, rotatedWorld));
- Assert.Equal(0f, up.X, precision: 5);
+
+ // Frame::rotate(identity, (0, pi/2, 0)): local +Y remains the
+ // camera's forward direction; local +Z rolls onto global +X.
+ Assert.Equal(0f, forward.X, precision: 5);
+ Assert.Equal(1f, forward.Y, precision: 5);
+ Assert.Equal(0f, forward.Z, precision: 5);
+ Assert.Equal(1f, up.X, precision: 5);
Assert.Equal(0f, up.Y, precision: 5);
- Assert.Equal(1f, up.Z, precision: 5);
+ Assert.Equal(0f, up.Z, precision: 5);
+ }
+
+ [Fact]
+ public void TeleportViewPlane_FadeOutPortsRetailDistanceFovAndNearPlane()
+ {
+ var baseProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
+ MathF.PI / 3f,
+ 16f / 9f,
+ 0.1f,
+ 5000f);
+ var controller = new TeleportViewPlaneController();
+ controller.Begin(baseProjection);
+
+ controller.Update(new TeleportAnimSnapshot(
+ TeleportAnimState.TunnelFadeOut,
+ FadeAlpha: 0.5f,
+ ShowTunnel: true,
+ ShowPleaseWait: false));
+ System.Numerics.Matrix4x4 projection = controller.Apply(baseProjection);
+
+ float gameDistance = baseProjection.M22;
+ float expectedDistance = gameDistance
+ + (TeleportViewPlaneController.TransitionViewPlaneDistance - gameDistance) * 0.5f;
+ float expectedNear = MathF.Max(0.1f, expectedDistance * 0.25f);
+ float actualNear = projection.M43 / projection.M33;
+
+ Assert.True(controller.Enabled);
+ Assert.Equal(expectedDistance, controller.CurrentViewPlaneDistance, precision: 5);
+ Assert.Equal(expectedDistance, projection.M22, precision: 5);
+ Assert.Equal(expectedNear, actualNear, precision: 5);
+ }
+
+ [Fact]
+ public void TeleportViewPlane_WorldFadeInStartsAtRetailWideProjectionAndRestoresGameProjection()
+ {
+ var baseProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
+ MathF.PI / 3f,
+ 16f / 9f,
+ 0.1f,
+ 5000f);
+ var controller = new TeleportViewPlaneController();
+ controller.Begin(baseProjection);
+
+ controller.Update(new TeleportAnimSnapshot(
+ TeleportAnimState.WorldFadeIn,
+ FadeAlpha: 1f,
+ ShowTunnel: false,
+ ShowPleaseWait: false));
+ System.Numerics.Matrix4x4 wideProjection = controller.Apply(baseProjection);
+
+ Assert.Equal(
+ TeleportViewPlaneController.TransitionViewPlaneDistance,
+ wideProjection.M22,
+ precision: 5);
+ Assert.Equal(0.1f, wideProjection.M43 / wideProjection.M33, precision: 5);
+
+ controller.Update(new TeleportAnimSnapshot(
+ TeleportAnimState.Off,
+ FadeAlpha: 0f,
+ ShowTunnel: false,
+ ShowPleaseWait: false));
+
+ Assert.False(controller.Enabled);
+ Assert.Equal(baseProjection, controller.Apply(baseProjection));
}
[Theory]
[InlineData(45f)]
[InlineData(60f)]
[InlineData(90f)]
- public void PortalTunnelCamera_UsesActiveSmartBoxFieldOfView(float degrees)
+ public void PortalTunnelCamera_UsesActiveSmartBoxFieldOfViewAndNearPlane(float degrees)
{
float expected = degrees * (MathF.PI / 180f);
var smartBoxProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
expected,
16f / 9f,
- 0.1f,
+ 0.35f,
5000f);
var camera = new PortalTunnelCamera();
camera.UseSmartBoxFov(smartBoxProjection);
Assert.Equal(expected, camera.FovRadians, precision: 5);
+ Assert.Equal(0.35f, camera.Near, precision: 5);
}
private static string? ResolveDatDir()
diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
index 44a92015..32675ee3 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
@@ -8,6 +8,46 @@ namespace AcDream.App.Tests.Rendering;
public class RetailChaseCameraTests
{
+ [Fact]
+ public void ResetViewerToPlayer_TeleportReseedsViewerAndReextendsFromPlayer()
+ {
+ bool savedColl = CameraDiagnostics.CollideCamera;
+ float savedT = CameraDiagnostics.TranslationStiffness;
+ float savedR = CameraDiagnostics.RotationStiffness;
+ try
+ {
+ CameraDiagnostics.CollideCamera = false;
+ CameraDiagnostics.TranslationStiffness = 0.45f;
+ CameraDiagnostics.RotationStiffness = 0.45f;
+
+ var camera = new RetailChaseCamera();
+ camera.Update(
+ Vector3.Zero, 0f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f,
+ cellId: 0x100u);
+
+ var destination = new Vector3(200f, -300f, 12f);
+ float yaw = MathF.PI / 2f;
+ camera.ResetViewerToPlayer(destination, yaw);
+
+ Assert.Equal(destination, camera.Position);
+ Assert.Equal(0u, camera.ViewerCellId);
+
+ camera.Update(
+ destination, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f,
+ cellId: 0x12340100u);
+
+ float firstStep = Vector3.Distance(camera.Position, destination);
+ Assert.InRange(firstStep, 0.001f, 0.3f);
+ Assert.Equal(0x12340100u, camera.ViewerCellId);
+ }
+ finally
+ {
+ CameraDiagnostics.CollideCamera = savedColl;
+ CameraDiagnostics.TranslationStiffness = savedT;
+ CameraDiagnostics.RotationStiffness = savedR;
+ }
+ }
+
// ── Heading source ────────────────────────────────────────────────
[Fact]