From 842bd89c1679550296a61d5f2159710c5d90bd18 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 15 Jul 2026 22:14:21 +0200 Subject: [PATCH] fix(world): match retail portal passage and exit warp --- docs/architecture/acdream-architecture.md | 15 ++- .../retail-divergence-register.md | 1 - docs/plans/2026-04-11-roadmap.md | 2 +- docs/plans/2026-05-12-milestones.md | 5 +- ...26-07-15-retail-portal-space-pseudocode.md | 56 ++++++++++- src/AcDream.App/Rendering/GameWindow.cs | 27 +++-- .../Rendering/PortalTunnelCamera.cs | 30 ++++-- .../Rendering/RetailChaseCamera.cs | 26 +++++ .../Rendering/TeleportViewPlaneController.cs | 98 +++++++++++++++++++ .../Rendering/PortalTunnelAssetTests.cs | 83 +++++++++++++++- .../Rendering/RetailChaseCameraTests.cs | 40 ++++++++ 11 files changed, 355 insertions(+), 28 deletions(-) create mode 100644 src/AcDream.App/Rendering/TeleportViewPlaneController.cs 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]