fix(world): match retail portal passage and exit warp

This commit is contained in:
Erik 2026-07-15 22:14:21 +02:00
parent eab23cbdd1
commit 842bd89c16
11 changed files with 355 additions and 28 deletions

View file

@ -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

View file

@ -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) |

View file

@ -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.

View file

@ -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`.

View file

@ -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.

View file

@ -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

View file

@ -6,7 +6,7 @@ namespace AcDream.App.Rendering;
/// Retail portal-space camera from <c>gmSmartBoxUI::PostInit</c>
/// (<c>0x004D6D8A</c>) and <c>CreatureMode::SetCameraDirection_Degrees</c>
/// (<c>0x00453760</c>). 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.
/// </summary>
public sealed class PortalTunnelCamera : ICamera
{
@ -20,8 +20,9 @@ public sealed class PortalTunnelCamera : ICamera
/// <summary>
/// Retail <c>CreatureMode::UseSmartboxFOV</c> 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.
/// </summary>
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);
}
}

View file

@ -247,6 +247,32 @@ public sealed class RetailChaseCamera : ICamera
PlayerTranslucency = ComputeTranslucency(d);
}
/// <summary>
/// Retail <c>SmartBox::set_viewer(playerPosition, reset_sought: 1)</c>
/// (<c>0x00452C40</c>), called by
/// <c>SmartBox::PlayerPositionUpdated</c> after a teleport
/// (<c>0x00453870</c>). 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.
/// </summary>
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;
}
/// <summary>
/// Adjust the camera distance (zoom) by a delta, clamped to
/// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors

View file

@ -0,0 +1,98 @@
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Ports retail's teleport projection transition. <c>gmSmartBoxUI::UseTime</c>
/// (<c>0x004D6E30</c>) eases SmartBox's view-plane distance between the
/// active game's value and <c>TRANSITION_VIEW_PLANE_DISTANCE = 0.001</c>.
/// <c>Render::set_vdst</c> (<c>0x0054B240</c>) 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.
/// </summary>
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;
/// <summary>
/// Retail <c>BeginTeleportAnimation</c> captures
/// <c>SmartBox::GetOverrideFovDistance</c> once when starting from Off.
/// For a standard perspective matrix, M22 is exactly
/// <c>cot(verticalFov / 2)</c>, retail's view-plane-distance value.
/// </summary>
public void Begin(Matrix4x4 gameProjection)
{
float distance = gameProjection.M22;
if (!float.IsFinite(distance) || distance <= 0f)
distance = 1f;
_gameViewPlaneDistance = distance;
CurrentViewPlaneDistance = distance;
Enabled = false;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Rebuild a perspective projection with retail's active view-plane
/// distance while preserving the source projection's aspect and far
/// plane. <c>Render::set_vdst</c> uses
/// <c>FOV = 2 * atan(1 / distance)</c> and
/// <c>znear = max(0.1, distance * 0.25)</c>.
/// </summary>
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);
}

View file

@ -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()

View file

@ -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]