fix(world): remove non-retail portal exit fade

This commit is contained in:
Erik 2026-07-15 23:20:52 +02:00
parent 842bd89c16
commit dded9e6b17
21 changed files with 919 additions and 347 deletions

View file

@ -599,7 +599,7 @@ This is the asynchronous adapter for retail's direct full-`Position` placement
`TeleportAnimSequencer` is the pure Core owner of retail's seven-state
teleport lifecycle, exact 1/2/5-second thresholds, frame-aligned exit window,
and 100-sample `UIGlobals::GetAnimLevel` fade curve. App-layer
and 100-sample `UIGlobals::GetAnimLevel` view-plane curve. App-layer
`PortalTunnelPresentation` owns the synthetic DAT Setup (`0x02000306`), its
40-frame/s animation (`0x030005AC`), `CSequence`, SmartBox-FOV camera, distant light, mesh
references, and animation-hook delivery. It renders through the existing
@ -613,12 +613,32 @@ enters `LiveEntityRuntime`, collision, picking, radar, or server GUID state.
`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
the four retail `FADE` states. Those states do not drive a black alpha layer:
the portal and world 3-D viewports switch directly at the transition view
distance, while retained UI remains independently composed. 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.
One reset seam ends a logical transit on successful completion, replacement
by a newer teleport sequence, or session teardown. It clears the destination,
streaming priority, sequencer edges, projection override, and portal scene
together, preventing transition state from crossing destination or session
lifetimes. `TeleportTransitCoordinator` correlates each fresh F751 sequence
with exactly one accepted `UpdatePosition`. Because the Position can arrive on
either side of F751, a pre-notification packet is buffered only when it advances
retail's `TELEPORT_TS`; after notification, the first accepted matching packet
is consumed even if that timestamp already advanced. Duplicate/older F751
notifications are rejected with wrap-safe ordering. Canonical physics remains
independent, and streaming recentering compares destination identity with the
actual current streaming center rather than the player's still-unplaced source
cell. A live teleport received while acdream's developer fly/orbit camera is
active re-enters the existing player-mode lifecycle first; retail has no
detached camera mode, and destination placement therefore always retains the
canonical local physics controller and chase-camera handoff.
The destination residency gate supplies retail's `EndTeleportAnimation` edge
once asynchronous streaming is ready. Player placement remains authoritative
and separate from presentation; arrival resets camera viewer state but does

View file

@ -66,7 +66,7 @@ accepted-divergence entries (#96, #49, #50).
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted). **#145 refinement (2026-06-22):** an OUTDOOR teleport additionally requires the destination's OWN landblock terrain to be resident (`PhysicsEngine.IsLandblockTerrainResident(destCell)`, which `StreamingController` priority-applies so it flips in ~hundreds of ms) before placing — and `SampleTerrainZ` is short-circuited behind it. On a teleport OUT of a dungeon the collapsed SOURCE dungeon stays resident at the same streaming-local coords and answers `SampleTerrainZ` (terrain 0), so without the load check the resolve ran against the dungeon's cells and rooted the player at a dungeon-frame cell id (ACE then rejected all movement as "failed transition"). Decision in `TeleportWorldReady`; the hold→materialize→regain-control transit is driven by `TeleportAnimSequencer` (the retail fade cover, ticked in `OnUpdate`), which replaced the retired `TeleportArrivalController` | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportWorldReady` ~5512 + the TAS transit tick in `OnUpdate`) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted). **#145 refinement (2026-06-22):** an OUTDOOR teleport additionally requires the destination's OWN landblock terrain to be resident (`PhysicsEngine.IsLandblockTerrainResident(destCell)`, which `StreamingController` priority-applies so it flips in ~hundreds of ms) before placing — and `SampleTerrainZ` is short-circuited behind it. On a teleport OUT of a dungeon the collapsed SOURCE dungeon stays resident at the same streaming-local coords and answers `SampleTerrainZ` (terrain 0), so without the load check the resolve ran against the dungeon's cells and rooted the player at a dungeon-frame cell id (ACE then rejected all movement as "failed transition"). Decision in `TeleportWorldReady`; the hold→materialize→regain-control transit is driven by `TeleportAnimSequencer` (the retail portal/view-plane lifecycle, ticked in `OnUpdate`), which replaced the retired `TeleportArrivalController` | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportWorldReady` ~5512 + the TAS transit tick in `OnUpdate`) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
@ -212,7 +212,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
| ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
| AP-115 | The DAT-authored portal-space viewport and its animation `SoundTweakedHook` are live, but the separate `ClientUISystem` enter/exit sound enums and centered repeating `"In Portal Space - Please Wait..."` display string are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; teleport event switch in `GameWindow.cs` | acdream has no retained cross-stack centered display-string owner or ClientUISystem sound-table-enum resolver yet; routing the line into chat or inventing direct wave IDs would be less faithful | Portal travel has the correct animated wormhole, timing, fades, and animation-authored sound, but lacks retail's short UI cue sounds and center notice | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` |
| AP-115 | The DAT-authored portal-space viewport and its animation `SoundTweakedHook` are live, but the separate `ClientUISystem` enter/exit sound enums and centered repeating `"In Portal Space - Please Wait..."` display string are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; teleport event switch in `GameWindow.cs` | acdream has no retained cross-stack centered display-string owner or ClientUISystem sound-table-enum resolver yet; routing the line into chat or inventing direct wave IDs would be less faithful | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, and animation-authored sound, but lacks retail's short UI cue sounds and center notice | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` |
## 4. Temporary stopgap (TS) — 34 active rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)

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, 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`.
- **✓ 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/view-plane transitions, forward-axis roll, direct portal→world viewport switch, 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

@ -306,8 +306,9 @@ 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 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
window, exact 100-sample view-plane curve, projection warp, and destination
viewer reset. Retail switches the portal and world viewports directly without
a black alpha fade; both 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

@ -91,9 +91,10 @@ show portal viewport
hide world SmartBox viewport
```
At the end of `TunnelFadeOut`, retail restores the world view distance, hides
the portal viewport, shows the world viewport, clears the teleport object's
sequence animations, plays `Sound_UI_ExitPortal`, and enters `WorldFadeIn`.
At the end of `TunnelFadeOut`, retail commits the current transition view
distance, hides the portal viewport, shows the world viewport, clears the
teleport object's sequence animations, plays `Sound_UI_ExitPortal`, and enters
`WorldFadeIn`. That following state restores the ordinary game view distance.
## Camera motion
@ -185,31 +186,35 @@ WorldFadeIn:
-> Off
```
The narrow remaining-time window makes the one-second tunnel fade finish at
The narrow remaining-time window makes the one-second tunnel projection transition finish at
the end of the 120-frame animation. The five-second ceiling is retail's escape
for a missed window.
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.
The four states whose retail enum names contain `FADE` use
`GetAnimLevel(elapsed / FadeTime)`. Instruction-level x86 inspection of
`gmSmartBoxUI::UseTime` (`0x004D7133..0x004D725F`) shows that this value feeds
only `SmartBox::SetOverrideFovDistance`. The function performs no alpha or
black-quad draw; `FADE` names the view-plane transition, not a transparency
compositor. The retained UI remains independent because only the two 3-D
viewports are switched.
## View-plane warp
Retail couples every fade state to a projection transition. On begin it
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
viewPlaneLevel = GetAnimLevel(elapsed / FadeTime) / 1024
WorldFadeOut or TunnelFadeOut:
currentDistance = gameDistance
+ (0.001 - gameDistance) * fadeLevel
+ (0.001 - gameDistance) * viewPlaneLevel
TunnelFadeIn or WorldFadeIn:
currentDistance = 0.001
+ (gameDistance - 0.001) * fadeLevel
+ (gameDistance - 0.001) * viewPlaneLevel
SmartBox.SetOverrideFovDistance(true, currentDistance)
```
@ -221,10 +226,22 @@ 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.
At `0.001`, the view is nearly 180 degrees wide. Retail does not cover that
endpoint with black: it switches directly from the tunnel viewport to the
destination world at this projection, then `WorldFadeIn` expands the world
from the center as the captured game projection is restored. This is retail's
characteristic destination-world warp, independent of chase-camera movement.
Instruction-level checks that disambiguate the Binary Ninja x87 output:
- `SmartBox::GetOverrideFovDistance` `0x00451BE0`: when no override is active,
returns `cot(activeVerticalFov / 2)` (`0x00798088 = 0.5`,
`0x007928C0 = 1.0`).
- `gmSmartBoxUI::UseTime` `0x004D7199` and `0x004D722E`: converts the signed
table result with `0x007BD6A0 = 1/1024` and performs the two lerps above.
- `Render::set_vdst` `0x0054B240`: x87 `fpatan` computes
`atan(1 / distance)`, doubles it, and sets
`znear = max(0.1, distance * 0.25)`.
## Player placement boundary
@ -261,7 +278,7 @@ 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
world view-plane transition while the normal motion/update stream settles. Therefore acdream
must not add an arrival-only animation reset.
## acdream integration translation
@ -272,11 +289,18 @@ must not add an arrival-only animation reset.
consumes the portal object's current frame for retail exit timing and uses
the exact table-driven easing curve.
- During tunnel states the portal scene replaces world pixels before retained
UI draws. The black fade also draws before retained UI. Input dispatch and
UI draws. There is no black compositor. 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.
and world viewport during the four `FADE` states.
- F751 remains a notification gate and does not itself advance `TELEPORT_TS`.
Presentation correlates its sequence with exactly one accepted destination
Position. A Position that arrives before F751 is buffered only when it
advances that timestamp; after F751, the first accepted matching Position is
consumed even if the channel already advanced. Thus reordered delivery and
retransmitted notifications cannot materialize the wrong destination or
strand the transit, while canonical physics retains its own retail gates.
- 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

View file

@ -1,5 +1,10 @@
# Retail Teleport Flow Implementation Plan
> **Historical presentation plan.** Superseded 2026-07-15 by the DAT-authored
> portal viewport and retail view-plane port. `FadeAlpha` and the black overlay
> below are not current behavior. See
> `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the retail-faithful unified teleport flow — a `TeleportAnimState` (TAS) animation state machine + arrival hold that covers **login, logout, death, and portal** with a fade-to-black cover, **holds until the destination landblock is actually loaded** (folding in the #145 unstreamed-arrival cascade fix), and **locks input + camera for the whole transition**.

View file

@ -1,5 +1,10 @@
# Retail Teleport — Priority Residency + Fade Cover — Implementation Plan
> **Historical presentation plan.** Superseded 2026-07-15 by the DAT-authored
> portal viewport and retail view-plane port. `FadeAlpha`/`FadeOverlay` below
> describe the retired temporary adaptation, not current behavior. See
> `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make a teleport materialize the player onto a resident, grounded destination within ~1 s (was 1014 s) with no movement desync, covered by a retail fade — fixing "long transition," "dropped at the wrong position," and "stops at the portal" in one coherent change.

View file

@ -1,5 +1,11 @@
# Design — Retail teleport flow (the unified `TeleportAnimState` controller)
> **Presentation semantics corrected 2026-07-15.** The unified lifecycle and
> residency gate remain, but the proposed alpha-black cover was an adaptation.
> Retail's `FADE` states ease only SmartBox view-plane distance and switch the
> portal/world viewports directly. Current oracle:
> `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
**Date:** 2026-06-21
**Feature:** Work item B from `docs/research/2026-06-21-teleport-issues-handoff.md` — the keystone of the
teleport-issues cluster (#138, #145 residual, the floating-camera / input-not-locked / "takes too long"

View file

@ -1,5 +1,12 @@
# Retail-faithful teleport — priority residency + fade cover — design (2026-06-22)
> **Presentation superseded 2026-07-15.** The residency and placement design
> remains historical context, but the black-cover interpretation does not.
> The installed DAT-authored portal viewport is now live, and instruction-level
> retail x86 proves the TAS `FADE` states drive only view-plane distance; retail
> draws no alpha-black overlay. Current oracle:
> `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
**Status:** approved design, pre-implementation.
**Branch:** `claude/thirsty-goldberg-51bb9b`. **Baseline:** `dd2eb8b` (+ the uncommitted `tp-probe` diagnostic).
**Supersedes the framing of:** `docs/research/2026-06-21-teleport-foundation-handoff.md` (the `_datLock`-starvation

View file

@ -1,119 +0,0 @@
using System;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Fullscreen black quad at a given alpha for retail's world/portal viewport
/// transitions. It is drawn after the active 3-D viewport but before retained
/// UI, matching <c>gmSmartBoxUI::UseTime</c>: the world or portal scene fades
/// while chat, toolbar, windows, and cursor remain fully visible.
///
/// <para>Draws in NDC, so it needs no view/projection. Self-contained GL state
/// (feedback_render_self_contained_gl_state): sets blend + disables depth, restores the
/// frame-global convention (depth test/write on) on exit.</para>
/// </summary>
public sealed class FadeOverlay : IDisposable
{
private const string VertSrc = @"#version 430 core
layout(location = 0) in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }";
private const string FragSrc = @"#version 430 core
uniform float uAlpha;
out vec4 FragColor;
void main() { FragColor = vec4(0.0, 0.0, 0.0, uAlpha); }";
private readonly GL _gl;
private readonly uint _program;
private readonly uint _vao;
private readonly uint _vbo;
private readonly int _locAlpha;
// Two triangles covering the whole NDC viewport.
private static readonly float[] QuadVerts =
{
-1f, -1f, 1f, -1f, 1f, 1f,
-1f, -1f, 1f, 1f, -1f, 1f,
};
public FadeOverlay(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
uint vs = Compile(ShaderType.VertexShader, VertSrc);
uint fs = Compile(ShaderType.FragmentShader, FragSrc);
_program = _gl.CreateProgram();
_gl.AttachShader(_program, vs);
_gl.AttachShader(_program, fs);
_gl.LinkProgram(_program);
_gl.GetProgram(_program, ProgramPropertyARB.LinkStatus, out int linked);
if (linked == 0)
throw new InvalidOperationException($"FadeOverlay link failed: {_gl.GetProgramInfoLog(_program)}");
_gl.DeleteShader(vs);
_gl.DeleteShader(fs);
_locAlpha = _gl.GetUniformLocation(_program, "uAlpha");
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
unsafe
{
fixed (float* v = QuadVerts)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(QuadVerts.Length * sizeof(float)), v, BufferUsageARB.StaticDraw);
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), (void*)0);
}
_gl.EnableVertexAttribArray(0);
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
}
private uint Compile(ShaderType type, string src)
{
uint s = _gl.CreateShader(type);
_gl.ShaderSource(s, src);
_gl.CompileShader(s);
_gl.GetShader(s, ShaderParameterName.CompileStatus, out int ok);
if (ok == 0)
throw new InvalidOperationException($"FadeOverlay {type} compile failed: {_gl.GetShaderInfoLog(s)}");
return s;
}
/// <summary>
/// Draw the fullscreen black cover at <paramref name="alpha"/> (0 = clear → no-op,
/// 1 = opaque). Call after the active 3-D viewport and before retained UI.
/// </summary>
public void Draw(float alpha)
{
if (alpha <= 0f) return;
alpha = Math.Clamp(alpha, 0f, 1f);
// ---- set state (everything this draw depends on) ----
_gl.Disable(EnableCap.DepthTest);
_gl.DepthMask(false);
_gl.Disable(EnableCap.CullFace);
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
_gl.UseProgram(_program);
_gl.Uniform1(_locAlpha, alpha);
_gl.BindVertexArray(_vao);
_gl.DrawArrays(PrimitiveType.Triangles, 0, 6);
_gl.BindVertexArray(0);
_gl.UseProgram(0);
// ---- restore the frame-global convention ----
_gl.DepthMask(true);
_gl.Enable(EnableCap.DepthTest);
}
public void Dispose()
{
_gl.DeleteProgram(_program);
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
}
}

View file

@ -222,7 +222,6 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
private AcDream.App.Rendering.FadeOverlay? _fadeOverlay;
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
@ -2588,8 +2587,7 @@ public sealed class GameWindow : IDisposable
// T1: invisible portal depth writes (seal/punch) — retail
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
_fadeOverlay = new AcDream.App.Rendering.FadeOverlay(_gl);
_portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.TryCreate(
_portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired(
_gl,
_dats!,
_animLoader!,
@ -2783,6 +2781,9 @@ public sealed class GameWindow : IDisposable
private void ClearInboundEntityState()
{
EndMouseLookAndRestoreCursor();
ResetTeleportTransitState(clearSession: true);
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
// Session-scoped origin readiness. A reconnect's first logical player
// CreateObject must initialize the render/streaming center anew;
@ -6817,118 +6818,34 @@ public sealed class GameWindow : IDisposable
entity.Rotation = rmState.Body.Orientation;
}
// Phase B.3 / G.3a (#133): portal-space arrival detection.
// Only runs for our own player character while in PortalSpace.
if (_playerController is not null
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
&& update.Guid == _playerServerGuid)
// F751 is only a notification gate; the accepted Position may arrive
// before or after it. Canonical physics above always consumes the
// packet first. The presentation coordinator exposes exactly one
// sequence-correlated destination without reordering that state.
if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Apply
&& update.Guid == _playerServerGuid
&& _teleportTransit.OfferDestination(
update.TeleportSequence,
timestamps.TeleportAdvanced,
update,
out var teleportDestination))
{
var oldPos = _playerController.Position;
uint streamingOriginLandblockId =
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
_liveCenterX, _liveCenterY);
// Retail preserves the full Position (objcell_id + Frame) through
// SmartBox::TeleportPlayer @ 0x00453910. Dungeon EnvCells can have
// negative local frame origins, so XYZ cannot identify the source
// landblock (#215). Compare the controller's authoritative current
// cell with the received destination cell instead.
var landblockTransition =
AcDream.App.Streaming.TeleportLandblockTransition.Classify(
_playerController.CellId,
p.LandblockId,
streamingOriginLandblockId);
int oldLbX = (int)((landblockTransition.SourceLandblockId >> 24) & 0xFFu);
int oldLbY = (int)((landblockTransition.SourceLandblockId >> 16) & 0xFFu);
bool differentLandblock = landblockTransition.CrossesLandblock;
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
System.Numerics.Vector3 newWorldPos;
if (differentLandblock)
{
// #145: drop the stale SOURCE center landblock from the physics engine
// BEFORE recentering. The destination loads at world-offset (0,0) (the new
// center), but the prior center was ALSO loaded at offset (0,0) and its
// offset is never re-based — so the two overlap, and the Z-agnostic outdoor
// cell-snap (AdjustPosition / Resolve, iterating _landblocks) resolves the
// player into the STALE source landblock (a dungeon's frame). That happens
// for the arrival snap AND every per-frame resolve until the source finally
// unloads, so outbound movement is sent in the dungeon's frame and the server
// rejects every move as a failed transition (#145 / #138). Removing the stale
// center here makes both resolves fall through to the server-authoritative
// position (Resolve's NO-LANDBLOCK verbatim branch, :605) until the
// destination streams in. Only the offset-(0,0) center can collide with the
// destination-local position, so removing it alone is sufficient.
_physicsEngine.RemoveLandblock(streamingOriginLandblockId);
// Recenter the streaming controller on the new landblock NOW (kick
// off the dungeon load). After recentering, the destination is
// (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
_liveCenterX = lbX;
_liveCenterY = lbY;
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
// #135: pre-collapse on teleport into a sealed dungeon too — same
// race as login. The destination isn't placed until it hydrates, so
// without this NormalTick loads the full neighbor window during the
// arrival hold. The PortalSpace observer branch (OnUpdate) keeps the
// observer pinned to _liveCenterX/Y while held, so the stale frozen
// player position can't drift the observer off the dungeon and re-expand.
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
_streamingController.PreCollapseToDungeon(lbX, lbY);
else
// Outdoor teleport: the render origin (_liveCenter) just moved. Drop ALL
// resident terrain so overlapping blocks on a NEARBY jump re-bake at the
// new origin instead of rendering shifted — the confirmed "terrain in the
// sky" arcs (stale slots offset by exactly deltaLB×192). 2026-06-22.
_streamingController?.ForceReloadWindow();
}
else
{
newWorldPos = worldPos;
}
// Retail "pink-bubble" transit: do NOT snap here. Record the destination and
// PRIORITIZE its landblock in streaming so it applies ahead of the per-frame
// budget (residency in ~hundreds of ms, not 10-14s). The TAS — ticked per frame
// in OnUpdate — holds the player in PortalSpace behind the fade until the
// destination terrain is resident (TeleportWorldReady), then fires Place. While
// held, no movement resolve runs, so the outbound cell frame can't corrupt.
_pendingTeleportRot = rot;
_pendingTeleportPos = newWorldPos;
_pendingTeleportCell = p.LandblockId;
_teleportHoldSeconds = 0f;
_teleportForced = false;
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId =
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
// Eager-apply the destination's IMMEDIATE SURROUNDINGS (the near ring), not
// just the one landblock the player stands on — so they arrive in a loaded,
// collidable world instead of a single landblock in the void.
_streamingController.PriorityRadius = TeleportNearRingRadius;
}
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"AIM", p.LandblockId,
$"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}");
AimTeleportDestination(teleportDestination);
}
}
// Retail teleport transit: the dormant 7-state TAS drives the fade cover, holds the
// Retail teleport transit: the 7-state TAS drives portal/view-plane presentation, holds the
// player in PortalSpace until the destination is resident (TeleportWorldReady), then
// 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 readonly AcDream.App.Streaming.TeleportTransitCoordinator<
AcDream.Core.Net.WorldSession.EntityPositionUpdate> _teleportTransit = new();
private System.Numerics.Vector3 _pendingTeleportPos;
private uint _pendingTeleportCell;
private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout)
private bool _teleportForced; // true when the safety-net timeout force-places
private float _teleportFadeAlpha; // 0 = clear, 1 = full black; consumed by the fade overlay
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
// Loud safety net for a destination that never streams (worker crash / corrupt dat /
@ -6939,13 +6856,133 @@ public sealed class GameWindow : IDisposable
private const float TeleportMaxHoldSeconds = 10f;
// 2026-06-22: how many landblocks of the player's IMMEDIATE SURROUNDINGS to eager-apply
// (and to hold the fade for) on a teleport. radius 1 = the 3×3 around the destination —
// (and to hold portal space for) on a teleport. radius 1 = the 3×3 around the destination —
// the player's own landblock + its 8 neighbours, ~576 m across — enough that they arrive
// standing on loaded ground with wall collision and a non-empty immediate view. The far
// ring (out to the streaming window) still drains at the per-frame budget after the fade
// lifts. Bigger = a more complete arrival but a longer (still hidden) loading fade.
// ring (out to the streaming window) still drains at the per-frame budget after portal
// space exits. Bigger = a more complete arrival but a longer portal-space hold.
private const int TeleportNearRingRadius = 1;
/// <summary>
/// Bind a sequence-correlated teleport Position to presentation and
/// asynchronous streaming. Canonical physics has already accepted this
/// packet; this method only establishes the destination viewport lifetime.
/// </summary>
private void AimTeleportDestination(
AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
var playerController = _playerController
?? throw new InvalidOperationException(
"A teleport destination was accepted before the local player controller existed.");
var p = update.Position;
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
uint streamingOriginLandblockId =
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
_liveCenterX, _liveCenterY);
var origin = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
var worldPos = new System.Numerics.Vector3(
p.PositionX, p.PositionY, p.PositionZ) + origin;
// Retail preserves the full Position (objcell_id + Frame) through
// SmartBox::TeleportPlayer @ 0x00453910. Dungeon EnvCells can have
// negative local frame origins, so XYZ cannot identify the source
// landblock (#215). Player crossing and streaming recentering are
// separate while a prior destination is aimed but not yet placed.
var landblockTransition =
AcDream.App.Streaming.TeleportLandblockTransition.Classify(
playerController.CellId,
p.LandblockId,
streamingOriginLandblockId);
int oldLbX = (int)((landblockTransition.SourceLandblockId >> 24) & 0xFFu);
int oldLbY = (int)((landblockTransition.SourceLandblockId >> 16) & 0xFFu);
bool playerCrossesLandblock = landblockTransition.CrossesLandblock;
bool streamingCenterChanges = landblockTransition.ChangesStreamingCenter;
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, playerController.Position):F1}");
System.Numerics.Vector3 newWorldPos;
if (streamingCenterChanges)
{
// #145: drop the stale STREAMING center landblock from the physics engine
// BEFORE recentering. The destination loads at world-offset (0,0) (the new
// center), but the prior center was ALSO loaded at offset (0,0) and its
// offset is never re-based — so the two overlap, and the Z-agnostic outdoor
// cell-snap resolves the player into the stale center. On a replacement this
// can be the abandoned first destination, not the unplaced player's source.
_physicsEngine.RemoveLandblock(streamingOriginLandblockId);
_liveCenterX = lbX;
_liveCenterY = lbY;
newWorldPos = new System.Numerics.Vector3(
p.PositionX, p.PositionY, p.PositionZ);
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
_streamingController.PreCollapseToDungeon(lbX, lbY);
else
_streamingController?.ForceReloadWindow();
}
else
{
newWorldPos = worldPos;
}
// Do not snap here. The TAS holds portal space until the destination
// near ring is resident, then emits Place exactly once.
_pendingTeleportRot = new System.Numerics.Quaternion(
p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
_pendingTeleportPos = newWorldPos;
_pendingTeleportCell = p.LandblockId;
_teleportHoldSeconds = 0f;
_teleportForced = false;
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId =
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
_streamingController.PriorityRadius = TeleportNearRingRadius;
}
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"AIM", p.LandblockId,
$"seq={update.TeleportSequence} lb={lbX},{lbY} " +
$"indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} " +
$"playerCross={playerCrossesLandblock} centerChange={streamingCenterChanges}");
}
/// <summary>
/// End one logical teleport lifetime. This is the single symmetry seam
/// used by successful completion, replacement by a newer sequence, and
/// session teardown, so presentation state and destination state cannot
/// leak independently.
/// </summary>
private void ResetTeleportTransitState(bool clearSession = false)
{
if (clearSession)
_teleportTransit.ClearSession();
else
_teleportTransit.EndActive();
_pendingTeleportPos = default;
_pendingTeleportCell = 0u;
_pendingTeleportRot = System.Numerics.Quaternion.Identity;
_teleportHoldSeconds = 0f;
_teleportForced = false;
_teleportAnim.Reset();
_teleportViewPlane.Reset();
_portalTunnel?.Exit();
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId = 0u;
_streamingController.PriorityRadius = 0;
}
}
// #145: the LANDBLOCK-relative (cell-local) position used to SEED the player
// body's cell-relative CellPosition. This is the ONE place the streaming center
// (_liveCenter) is allowed to touch the physics frame — at the placement seam,
@ -6967,7 +7004,7 @@ public sealed class GameWindow : IDisposable
/// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell
/// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around
/// the player, <see cref="TeleportNearRingRadius"/>) being registered — not just the
/// single destination landblock — so the fade only lifts onto a loaded, collidable world
/// single destination landblock — so portal space exits onto a loaded, collidable world
/// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no
/// "only one landblock loaded"). The streaming controller priority-applies that same ring,
/// so it flips fast. An impossible claim (indoor cell id outside the dat's NumCells)
@ -7032,35 +7069,79 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}");
// Do NOT flip to InWorld or send LoginComplete here — the player materializes BEHIND
// the fade and stays input-frozen (PortalSpace) until the TAS fades the world back in
// and fires FireLoginComplete (which regains control + acks the server). This is the
// Do NOT flip to InWorld or send LoginComplete here — the player materializes while
// the portal viewport is active and stays input-frozen (PortalSpace) until the TAS
// restores the destination projection and fires FireLoginComplete. This is the
// retail "pop out the other side" sequence.
Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
}
/// <summary>
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). Freeze movement
/// input (PortalSpace) and begin the retail fade transit. The per-frame TAS tick holds
/// the player behind the fade until the destination is resident, then materializes them
/// (Place) and, after the world fades back in, regains control + acks the server
/// input (PortalSpace) and begin retail portal transit. The per-frame TAS tick holds
/// the player behind the portal viewport until the destination is resident, then materializes
/// them (Place) and, after the destination view-plane restores, regains control + acks the server
/// (FireLoginComplete).
/// </summary>
private void OnTeleportStarted(uint sequence)
{
if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence))
ushort teleportSequence = (ushort)sequence;
if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, teleportSequence)
|| !_teleportTransit.CanBegin(teleportSequence))
return;
EndMouseLookAndRestoreCursor();
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
_teleportInProgress = true;
// A fresh sequence is a new logical transit. Discard the prior
// destination and presentation before this sequence can observe them;
// its destination PositionUpdate may arrive on a later network tick.
ResetTeleportTransitState();
_teleportTransit.QueueStart(teleportSequence);
TryActivatePendingTeleportPresentation();
Console.WriteLine($"live: teleport queued (seq={sequence})");
}
/// <summary>
/// Converge a queued F751 notification with the ordinary live-player
/// projection lifecycle. Usually this activates synchronously. If a
/// session transition has not materialized the player projection yet, the
/// notification and any correlated Position remain queued until it does.
/// </summary>
private void TryActivatePendingTeleportPresentation()
{
if (!_teleportTransit.HasPendingStart)
return;
// Retail has no detached fly/orbit gameplay mode. If acdream's live
// developer camera currently owns the view, restore the existing
// player-mode lifecycle before transit so placement still has the
// canonical local physics controller and chase-camera handoff.
var playerController = _playerController;
if (playerController is null)
{
if (!_entitiesByServerGuid.ContainsKey(_playerServerGuid))
return;
_playerMode = true;
if (!EnterPlayerModeNow(loggingTag: "teleport"))
{
_playerMode = false;
return;
}
playerController = _playerController;
}
if (playerController is null)
return;
playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
_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})");
bool hasBufferedDestination = _teleportTransit.Activate(
out var bufferedDestination);
if (hasBufferedDestination)
AimTeleportDestination(bufferedDestination);
Console.WriteLine($"live: teleport presentation started (seq={_teleportTransit.Sequence})");
}
/// <summary>
@ -8881,14 +8962,19 @@ public sealed class GameWindow : IDisposable
// Step 2: routed through the controller; functionally identical.
_liveSessionController?.Tick();
// Usually F751 activates immediately. This no-op convergence check
// covers the session edge where the canonical player exists before
// its normal live projection/controller has materialized.
TryActivatePendingTeleportPresentation();
// Retail teleport transit. Runs AFTER streaming (which priority-applies the
// destination landblock this frame) and the live-session drain, so a destination
// that became resident this frame materializes the same frame. The TAS holds at
// Tunnel until worldReady, fires Place (materialize, hidden), then after the min-
// continue + fades fires FireLoginComplete (regain control + ack). PortalTunnelPresentation
// replaces the world viewport through the tunnel-family states; FadeOverlay transitions
// between the two viewports below the retained UI.
if (_teleportInProgress)
// continue + view-plane transitions fires FireLoginComplete (regain control + ack).
// PortalTunnelPresentation replaces the world viewport through the tunnel-family states;
// retail switches the two 3-D viewports directly, below the retained UI.
if (_teleportTransit.IsActive)
{
bool haveDest = _pendingTeleportCell != 0u;
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
@ -8905,12 +8991,6 @@ 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.
_teleportFadeAlpha = _portalTunnel is null && snap.ShowTunnel
? 1f
: snap.FadeAlpha;
foreach (var e in evts)
{
@ -8937,10 +9017,7 @@ public sealed class GameWindow : IDisposable
// each portal transition.
_liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
_teleportInProgress = false;
_pendingTeleportCell = 0u;
_teleportViewPlane.Reset();
_teleportFadeAlpha = 0f;
ResetTeleportTransitState();
break;
default:
// The retained audio engine does not yet own the
@ -9575,8 +9652,9 @@ public sealed class GameWindow : IDisposable
if (_cameraController is not null)
{
var camera = _cameraController.Active;
var worldProjection = _teleportViewPlane.Apply(camera.Projection);
var activeCamera = _cameraController.Active;
var camera = _teleportViewPlane.ApplyTo(activeCamera);
var worldProjection = camera.Projection;
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
// Extract camera world position from the inverse of the view
@ -10489,7 +10567,7 @@ public sealed class GameWindow : IDisposable
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
// CreatureMode portal-space viewport. This replacement scene and
// its black transition draw BEFORE retained UI, so chat, windows,
// its projection transition draws BEFORE retained UI, so chat, windows,
// cursor, and toolbar remain visible and interactive in transit.
var portalProjection = _teleportViewPlane.Apply(
_cameraController!.Active.Projection);
@ -10497,7 +10575,6 @@ public sealed class GameWindow : IDisposable
_window!.Size.X,
_window.Size.Y,
portalProjection);
_fadeOverlay?.Draw(_teleportFadeAlpha);
// Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the
// viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window
@ -14693,7 +14770,6 @@ public sealed class GameWindow : IDisposable
_wbDrawDispatcher?.Dispose();
_envCellRenderer?.Dispose(); // Phase A8
_portalDepthMask?.Dispose(); // T1
_fadeOverlay?.Dispose();
_clipFrame?.Dispose(); // Phase U.3
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_particleRenderer?.Dispose(); // releases particle mesh refs before WB

View file

@ -103,10 +103,11 @@ public sealed class PortalTunnelPresentation : IDisposable
/// <summary>
/// Resolve retail's enum-mapped portal Setup and animation through the
/// installed DATs. Missing assets fail closed with an actionable message;
/// no substitute tunnel is fabricated.
/// installed DATs. Retail dereferences this scene unconditionally; missing
/// required assets therefore fail startup with an actionable diagnostic.
/// No substitute tunnel is fabricated.
/// </summary>
public static PortalTunnelPresentation? TryCreate(
public static PortalTunnelPresentation CreateRequired(
GL gl,
DatCollection dats,
IAnimationLoader animationLoader,
@ -132,21 +133,18 @@ public sealed class PortalTunnelPresentation : IDisposable
? null
: animationLoader.LoadAnimation(animationDid);
if (setup is null || animation is null)
{
Console.Error.WriteLine(
"[portal-space] retail DAT assets unavailable: "
+ $"setup=0x{setupDid:X8} ({(setup is null ? "missing" : "ok")}), "
+ $"animation=0x{animationDid:X8} ({(animation is null ? "missing" : "ok")})");
return null;
}
EnsureRequiredAssets(
setupDid,
setup is not null,
animationDid,
animation is not null);
return new PortalTunnelPresentation(
gl,
dispatcher,
lightUbo,
meshAdapter,
setup,
setup!,
setupDid,
animationDid,
animationLoader,
@ -155,6 +153,21 @@ public sealed class PortalTunnelPresentation : IDisposable
displayNotice);
}
internal static void EnsureRequiredAssets(
uint setupDid,
bool setupLoaded,
uint animationDid,
bool animationLoaded)
{
if (setupLoaded && animationLoaded)
return;
throw new InvalidOperationException(
"[portal-space] required retail DAT assets unavailable: "
+ $"setup=0x{setupDid:X8} ({(setupLoaded ? "ok" : "missing")}), "
+ $"animation=0x{animationDid:X8} ({(animationLoaded ? "ok" : "missing")})");
}
public bool IsVisible => _visible;
public int CurrentAnimationFrame => _sequence.GetCurrFrameNumber();
public uint SetupDid => _setupDid;
@ -210,7 +223,7 @@ public sealed class PortalTunnelPresentation : IDisposable
/// <summary>
/// Replace the already-rendered world viewport with retail portal space.
/// The caller then draws the fade and retained UI above this pass.
/// The caller then draws retained UI above this pass.
/// </summary>
public void Draw(int width, int height, Matrix4x4 smartBoxProjection)
{

View file

@ -8,14 +8,16 @@ namespace AcDream.App.Rendering;
/// (<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.
/// FOV and adjusts the near plane. At the exit edge retail swaps directly
/// from portal space to the destination world at the transition projection;
/// there is no black-alpha compositor between them.
/// </summary>
public sealed class TeleportViewPlaneController
{
public const float TransitionViewPlaneDistance = 0.001f;
private float _gameViewPlaneDistance = 1f;
private readonly ProjectionOverrideCamera _projectionCamera = new();
public bool Enabled { get; private set; }
public float CurrentViewPlaneDistance { get; private set; } = 1f;
@ -38,20 +40,42 @@ public sealed class TeleportViewPlaneController
}
/// <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.
/// Apply retail's view-plane state machine. Here "fade" is retail's state
/// name for a projection transition: normal to 0.001 on fade-out, and
/// 0.001 back to normal on fade-in. A logout transition retains the
/// captured override during stable Tunnel; <c>EndTeleportAnimation</c>
/// (<c>0x004D65D5</c>) releases it when entering TunnelContinue.
/// </summary>
public void Update(TeleportAnimSnapshot snapshot)
{
Enabled = snapshot.State is TeleportAnimState.WorldFadeOut
or TeleportAnimState.TunnelFadeIn
or TeleportAnimState.TunnelFadeOut
or TeleportAnimState.WorldFadeIn;
switch (snapshot.State)
{
case TeleportAnimState.WorldFadeOut:
case TeleportAnimState.TunnelFadeIn:
case TeleportAnimState.TunnelFadeOut:
case TeleportAnimState.WorldFadeIn:
Enabled = true;
CurrentViewPlaneDistance = Lerp(
_gameViewPlaneDistance,
TransitionViewPlaneDistance,
snapshot.ViewPlaneBlend);
break;
CurrentViewPlaneDistance = Enabled
? Lerp(_gameViewPlaneDistance, TransitionViewPlaneDistance, snapshot.FadeAlpha)
: _gameViewPlaneDistance;
case TeleportAnimState.Tunnel:
// Retail preserves the captured FOV override through the
// logout path's stable tunnel. A normal portal begins in
// Tunnel with no override, so its disabled state is retained.
if (Enabled)
CurrentViewPlaneDistance = _gameViewPlaneDistance;
break;
case TeleportAnimState.TunnelContinue:
case TeleportAnimState.Off:
default:
Enabled = false;
CurrentViewPlaneDistance = _gameViewPlaneDistance;
break;
}
}
public void Reset()
@ -93,6 +117,39 @@ public sealed class TeleportViewPlaneController
return Matrix4x4.CreatePerspectiveFieldOfView(fov, aspect, near, far);
}
/// <summary>
/// Decorate the active camera with the same projection returned by
/// <see cref="Apply(Matrix4x4)"/>. Retail's <c>Render::set_vdst</c> is
/// global to every 3-D draw; callers must pass this returned camera to
/// terrain, meshes, particles, sky, weather, and portal-cell rendering so
/// rasterization and frustum culling cannot use different projections.
/// </summary>
public ICamera ApplyTo(ICamera baseCamera)
{
ArgumentNullException.ThrowIfNull(baseCamera);
_projectionCamera.Update(baseCamera, Apply(baseCamera.Projection));
return _projectionCamera;
}
private static float Lerp(float from, float to, float amount) =>
from + (to - from) * Math.Clamp(amount, 0f, 1f);
private sealed class ProjectionOverrideCamera : ICamera
{
private ICamera _source = null!;
public Matrix4x4 View => _source.View;
public Matrix4x4 Projection { get; private set; } = Matrix4x4.Identity;
public float Aspect
{
get => _source.Aspect;
set => _source.Aspect = value;
}
public void Update(ICamera source, Matrix4x4 projection)
{
_source = source;
Projection = projection;
}
}
}

View file

@ -118,7 +118,7 @@ public sealed class StreamingController
/// so the player arrives to a near-empty world (the "Fort Tethana only one landblock
/// loaded" symptom) and their cell-walk can't root into neighbour cells yet (the
/// transient walk-through-walls). The far ring still drains at the budget. The eager
/// apply runs during the teleport hold (behind the fade), so the GPU spike is hidden.
/// apply runs while the portal viewport replaces the world, so the GPU spike is hidden.
/// </summary>
public int PriorityRadius { get; set; }
@ -377,7 +377,7 @@ public sealed class StreamingController
/// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none
/// survives into the next frame stale, and no async unload can race a re-bake, then null
/// the region so the next <see cref="NormalTick"/> re-bootstraps the WHOLE window fresh at
/// the new origin (the near ring is priority-applied behind the fade; the rest streams in).
/// the new origin (the near ring is priority-applied during portal travel; the rest streams in).
/// A sealed-dungeon destination uses <see cref="PreCollapseToDungeon"/> instead.
/// </summary>
public void ForceReloadWindow()
@ -405,7 +405,7 @@ public sealed class StreamingController
/// (rendering at its old world position as "floating terrain at the horizon"), and rapid
/// hops accumulated them faster than they cleared — a runaway resident count (951 observed
/// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring
/// (<see cref="PriorityRadius"/>, applied behind the fade) likewise bypass the budget so the
/// (<see cref="PriorityRadius"/>, applied during portal travel) likewise bypass the budget so the
/// player materialises in a loaded world.
/// </summary>
private void DrainAndApply()

View file

@ -1,9 +1,11 @@
namespace AcDream.App.Streaming;
/// <summary>
/// Classifies whether a local-player teleport crosses an AC landblock boundary.
/// Cell identity comes from the complete retail <c>Position</c>, never from its
/// frame coordinates: dungeon EnvCells may have valid negative local origins.
/// Classifies both whether the player crosses an AC landblock boundary and
/// whether acdream's asynchronous streaming origin must recenter. These are
/// distinct while a teleport destination is aimed but not yet placed. Cell
/// identity comes from the complete retail <c>Position</c>, never from its frame
/// coordinates: dungeon EnvCells may have valid negative local origins.
/// </summary>
/// <remarks>
/// Retail carries <c>Position::objcell_id</c> through
@ -14,9 +16,12 @@ namespace AcDream.App.Streaming;
/// </remarks>
internal readonly record struct TeleportLandblockTransition(
uint SourceLandblockId,
uint DestinationLandblockId)
uint DestinationLandblockId,
uint StreamingCenterLandblockId)
{
public bool CrossesLandblock => SourceLandblockId != DestinationLandblockId;
public bool ChangesStreamingCenter =>
StreamingCenterLandblockId != DestinationLandblockId;
/// <summary>
/// Build the transition from the player's current cell and the received
@ -34,7 +39,8 @@ internal readonly record struct TeleportLandblockTransition(
return new TeleportLandblockTransition(
sourceLandblockId,
NormalizeLandblockId(destinationCellId));
NormalizeLandblockId(destinationCellId),
NormalizeLandblockId(currentStreamingCenterLandblockId));
}
private static uint NormalizeLandblockId(uint cellOrLandblockId)

View file

@ -0,0 +1,150 @@
namespace AcDream.App.Streaming;
/// <summary>
/// Correlates F751 notifications with accepted Position packets without
/// imposing presentation order on canonical physics. F751 deliberately does
/// not consume retail's TELEPORT_TS, and the matching Position may be observed
/// on either side of the notification.
/// </summary>
internal sealed class TeleportTransitCoordinator<TDestination>
where TDestination : struct
{
private bool _hasLastStart;
private ushort _lastStartSequence;
private readonly Dictionary<ushort, TDestination> _bufferedDestinations = new();
private bool _hasPendingStart;
private ushort _pendingStartSequence;
private bool _destinationAccepted;
public bool IsActive { get; private set; }
public ushort Sequence { get; private set; }
public bool HasPendingStart => _hasPendingStart;
/// <summary>Reject retransmitted or older F751 notifications.</summary>
public bool CanBegin(ushort sequence) =>
!_hasLastStart || IsNewer(_lastStartSequence, sequence);
/// <summary>
/// Record a fresh notification lifetime. Presentation activation is a
/// separate edge because acdream's optional developer camera can
/// temporarily withdraw the normal player projection.
/// </summary>
public bool QueueStart(ushort sequence)
{
if (!CanBegin(sequence))
return false;
PruneDestinationsOlderThan(sequence);
IsActive = false;
Sequence = 0;
_hasPendingStart = true;
_pendingStartSequence = sequence;
_hasLastStart = true;
_lastStartSequence = sequence;
_destinationAccepted = false;
return true;
}
/// <summary>
/// Activate the queued presentation and consume a destination that was
/// accepted before the player projection became available.
/// </summary>
public bool Activate(out TDestination bufferedDestination)
{
bufferedDestination = default;
if (!_hasPendingStart)
return false;
IsActive = true;
Sequence = _pendingStartSequence;
_hasPendingStart = false;
_pendingStartSequence = 0;
_destinationAccepted = false;
if (!_bufferedDestinations.Remove(Sequence, out bufferedDestination))
return false;
_destinationAccepted = true;
return true;
}
/// <summary>
/// Offer an accepted Position packet. A matching active transit consumes
/// its first packet even when TELEPORT_TS was already advanced. Without a
/// matching notification, only a timestamp-advancing packet is buffered;
/// ordinary movement can never become a future portal destination.
/// </summary>
public bool OfferDestination(
ushort sequence,
bool teleportTimestampAdvanced,
TDestination destination,
out TDestination acceptedDestination)
{
acceptedDestination = default;
// An advancing TELEPORT_TS makes every older destination impossible
// to match a future F751. Retire those generations now so a skipped
// notification cannot retain a stale destination through u16 wrap.
if (teleportTimestampAdvanced)
PruneDestinationsOlderThan(sequence);
if (IsActive && sequence == Sequence)
{
if (_destinationAccepted)
return false;
_destinationAccepted = true;
acceptedDestination = destination;
return true;
}
if (_hasPendingStart && sequence == _pendingStartSequence)
{
_bufferedDestinations.TryAdd(sequence, destination);
return false;
}
ushort correlationSequence = _hasPendingStart
? _pendingStartSequence
: Sequence;
bool mayBelongToFutureStart = (!IsActive && !_hasPendingStart)
|| IsNewer(correlationSequence, sequence);
if (!teleportTimestampAdvanced || !mayBelongToFutureStart)
return false;
_bufferedDestinations.TryAdd(sequence, destination);
return false;
}
/// <summary>End presentation while retaining duplicate and reorder history.</summary>
public void EndActive()
{
IsActive = false;
Sequence = 0;
_hasPendingStart = false;
_pendingStartSequence = 0;
_destinationAccepted = false;
}
/// <summary>Clear every correlation value at a network-session boundary.</summary>
public void ClearSession()
{
EndActive();
_hasLastStart = false;
_lastStartSequence = 0;
_bufferedDestinations.Clear();
}
private static bool IsNewer(ushort current, ushort incoming)
=> AcDream.Core.Physics.PhysicsTimestampGate.IsNewer(current, incoming);
private void PruneDestinationsOlderThan(ushort sequence)
{
foreach (ushort bufferedSequence in _bufferedDestinations.Keys.ToArray())
{
if (IsNewer(bufferedSequence, sequence))
_bufferedDestinations.Remove(bufferedSequence);
}
}
}

View file

@ -23,7 +23,7 @@ public enum TeleportEntryKind { Portal, Login, Death, Logout }
public enum TeleportAnimEvent
{
PlayEnterSound, // Begin(): sound_ui_enter_portal
EnterTunnel, // Off/WorldFade* -> Tunnel: world is now hidden
EnterTunnel, // First tunnel-family frame: portal viewport replaces world
Place, // Tunnel -> TunnelContinue: world loaded; place the player
PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal
FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1
@ -32,7 +32,7 @@ public enum TeleportAnimEvent
/// <summary>Immutable per-frame snapshot from the sequencer.</summary>
public readonly record struct TeleportAnimSnapshot(
TeleportAnimState State,
float FadeAlpha, // 0 = clear world, 1 = full black
float ViewPlaneBlend, // 0 = game view distance, 1 = transition distance
bool ShowTunnel, // true during TunnelFadeIn..TunnelFadeOut
bool ShowPleaseWait); // true during TunnelContinue only
@ -82,6 +82,21 @@ public sealed class TeleportAnimSequencer
_enterTunnelPending = _state == TeleportAnimState.Tunnel; // true for Portal/Login/Death
}
/// <summary>
/// Cancel the current transition and discard every pending edge event.
/// Session teardown and a replacement teleport both require a clean
/// lifetime; no event from the abandoned transition may reach the next
/// session or destination.
/// </summary>
public void Reset()
{
_state = TeleportAnimState.Off;
_elapsed = 0f;
_continueElapsed = 0f;
_enterSoundPending = false;
_enterTunnelPending = false;
}
/// <summary>
/// Advance the machine by <paramref name="dt"/> seconds.
/// <paramref name="worldReady"/> = destination collision landblock is resident.
@ -104,12 +119,14 @@ public sealed class TeleportAnimSequencer
{
case TeleportAnimState.WorldFadeOut:
if (_elapsed >= FadeTime)
Advance(TeleportAnimState.TunnelFadeIn, enterTunnel: false);
// UseTime's viewport-visibility block precedes this state
// transition, so retail shows portal space on the next tick.
Advance(TeleportAnimState.TunnelFadeIn, enterTunnel: true);
break;
case TeleportAnimState.TunnelFadeIn:
if (_elapsed >= FadeTime)
Advance(TeleportAnimState.Tunnel, enterTunnel: true);
Advance(TeleportAnimState.Tunnel, enterTunnel: false);
break;
case TeleportAnimState.Tunnel:
@ -171,13 +188,13 @@ public sealed class TeleportAnimSequencer
private TeleportAnimSnapshot BuildSnapshot()
{
float alpha = ComputeFadeAlpha(_state, _elapsed);
float viewBlend = ComputeViewPlaneBlend(_state, _elapsed);
bool showTunnel = _state is TeleportAnimState.TunnelFadeIn
or TeleportAnimState.Tunnel
or TeleportAnimState.TunnelContinue
or TeleportAnimState.TunnelFadeOut;
bool pleaseWait = _state == TeleportAnimState.TunnelContinue;
return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait);
return new TeleportAnimSnapshot(_state, viewBlend, showTunnel, pleaseWait);
}
/// <summary>
@ -193,21 +210,26 @@ public sealed class TeleportAnimSequencer
return RetailAnimationLevels[-negativeIndex];
}
private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed)
/// <summary>
/// Retail's four states named <c>*Fade*</c> do not drive an alpha cover.
/// <c>gmSmartBoxUI::UseTime</c> (0x004D7133-0x004D725F) uses this eased
/// level only to interpolate SmartBox's view-plane distance.
/// </summary>
private static float ComputeViewPlaneBlend(TeleportAnimState state, float elapsed)
{
float t = Math.Clamp(elapsed / FadeTime, 0f, 1f);
float level = GetRetailAnimationLevel(t) / 1024f;
return state switch
{
// Fading TO black (alpha 0→1):
// Normal game view distance -> transition distance.
TeleportAnimState.WorldFadeOut => level,
TeleportAnimState.TunnelFadeIn => 1f - level, // tunnel fades IN: overlay goes clear
// Full black / fully clear inside tunnel states:
TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed
TeleportAnimState.TunnelFadeOut => level,
// Transition distance -> normal game view distance.
TeleportAnimState.TunnelFadeIn => 1f - level,
TeleportAnimState.WorldFadeIn => 1f - level,
// Stable viewport states use the ordinary game projection.
TeleportAnimState.Tunnel => 0f,
TeleportAnimState.TunnelContinue => 0f,
// Fading back out of tunnel:
TeleportAnimState.TunnelFadeOut => level, // tunnel fades out: overlay goes black
TeleportAnimState.WorldFadeIn => 1f - level, // world fades in: overlay clears
_ => 0f,
};
}

View file

@ -53,6 +53,21 @@ public sealed class PortalTunnelAssetTests
Assert.Equal(DatReaderWriter.Enums.AnimationHookType.SoundTweaked, hook.HookType);
}
[Fact]
public void RequiredAssets_MissingSetupOrAnimationFailsWithActionableIds()
{
var error = Assert.Throws<InvalidOperationException>(() =>
PortalTunnelPresentation.EnsureRequiredAssets(
setupDid: 0u,
setupLoaded: false,
animationDid: 0x030005ACu,
animationLoaded: true));
Assert.Contains("required retail DAT assets unavailable", error.Message);
Assert.Contains("setup=0x00000000 (missing)", error.Message);
Assert.Contains("animation=0x030005AC (ok)", error.Message);
}
[Fact]
public void PortalTunnelCamera_RollsAroundForwardAxisWithoutLeavingPassage()
{
@ -96,7 +111,7 @@ public sealed class PortalTunnelAssetTests
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.TunnelFadeOut,
FadeAlpha: 0.5f,
ViewPlaneBlend: 0.5f,
ShowTunnel: true,
ShowPleaseWait: false));
System.Numerics.Matrix4x4 projection = controller.Apply(baseProjection);
@ -126,7 +141,7 @@ public sealed class PortalTunnelAssetTests
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.WorldFadeIn,
FadeAlpha: 1f,
ViewPlaneBlend: 1f,
ShowTunnel: false,
ShowPleaseWait: false));
System.Numerics.Matrix4x4 wideProjection = controller.Apply(baseProjection);
@ -139,7 +154,7 @@ public sealed class PortalTunnelAssetTests
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.Off,
FadeAlpha: 0f,
ViewPlaneBlend: 0f,
ShowTunnel: false,
ShowPleaseWait: false));
@ -147,6 +162,101 @@ public sealed class PortalTunnelAssetTests
Assert.Equal(baseProjection, controller.Apply(baseProjection));
}
[Fact]
public void TeleportViewPlane_ApplyToSuppliesOverrideToEveryCameraConsumer()
{
var source = new PortalTunnelCamera
{
Aspect = 16f / 9f,
FovRadians = MathF.PI / 3f,
Near = 0.1f,
Far = 5000f,
};
var controller = new TeleportViewPlaneController();
controller.Begin(source.Projection);
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.WorldFadeIn,
ViewPlaneBlend: 1f,
ShowTunnel: false,
ShowPleaseWait: false));
ICamera renderCamera = controller.ApplyTo(source);
Assert.Equal(source.View, renderCamera.View);
Assert.Equal(
TeleportViewPlaneController.TransitionViewPlaneDistance,
renderCamera.Projection.M22,
precision: 5);
renderCamera.Aspect = 4f / 3f;
Assert.Equal(4f / 3f, source.Aspect, precision: 5);
}
[Fact]
public void TeleportViewPlane_TunnelPreservesLogoutOverrideUntilTunnelContinue()
{
var capturedProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 3f,
16f / 9f,
0.1f,
5000f);
var changedProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 2f,
16f / 9f,
0.1f,
5000f);
var controller = new TeleportViewPlaneController();
controller.Begin(capturedProjection);
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.WorldFadeOut,
ViewPlaneBlend: 1f,
ShowTunnel: false,
ShowPleaseWait: false));
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.TunnelFadeIn,
ViewPlaneBlend: 0f,
ShowTunnel: true,
ShowPleaseWait: false));
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.Tunnel,
ViewPlaneBlend: 0f,
ShowTunnel: true,
ShowPleaseWait: false));
Assert.True(controller.Enabled);
Assert.Equal(capturedProjection.M22, controller.Apply(changedProjection).M22, precision: 5);
controller.Update(new TeleportAnimSnapshot(
TeleportAnimState.TunnelContinue,
ViewPlaneBlend: 0f,
ShowTunnel: true,
ShowPleaseWait: false));
Assert.False(controller.Enabled);
Assert.Equal(changedProjection, controller.Apply(changedProjection));
}
[Fact]
public void TeleportViewPlane_NormalPortalTunnelDoesNotEnableOverride()
{
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.Tunnel,
ViewPlaneBlend: 0f,
ShowTunnel: true,
ShowPleaseWait: false));
Assert.False(controller.Enabled);
Assert.Equal(baseProjection, controller.Apply(baseProjection));
}
[Theory]
[InlineData(45f)]
[InlineData(60f)]

View file

@ -60,4 +60,18 @@ public sealed class TeleportLandblockTransitionTests
Assert.False(transition.CrossesLandblock);
Assert.Equal(0x8C04FFFFu, transition.SourceLandblockId);
}
[Fact]
public void ReplacementBackToPlayerLandblock_StillRecentersAbandonedStreamingOrigin()
{
var transition = TeleportLandblockTransition.Classify(
sourceCellId: 0x8C0401ADu,
destinationCellId: 0x8C040145u,
currentStreamingCenterLandblockId: 0xA9B4FFFFu);
Assert.False(transition.CrossesLandblock);
Assert.True(transition.ChangesStreamingCenter);
Assert.Equal(0x8C04FFFFu, transition.DestinationLandblockId);
Assert.Equal(0xA9B4FFFFu, transition.StreamingCenterLandblockId);
}
}

View file

@ -0,0 +1,133 @@
using AcDream.App.Streaming;
namespace AcDream.App.Tests.Streaming;
public sealed class TeleportTransitCoordinatorTests
{
[Fact]
public void ReplacementRejectsDelayedPriorDestinationAndAcceptsCurrentAdvance()
{
var transit = new TeleportTransitCoordinator<int>();
Assert.True(transit.QueueStart(10));
Assert.False(transit.Activate(out _));
transit.EndActive();
Assert.True(transit.QueueStart(11));
Assert.False(transit.Activate(out _));
Assert.False(transit.OfferDestination(10, true, 100, out _));
Assert.True(transit.OfferDestination(11, true, 110, out int accepted));
Assert.Equal(110, accepted);
}
[Fact]
public void PositionBeforeF751IsBufferedAndEqualFollowupCannotReplaceIt()
{
var transit = new TeleportTransitCoordinator<int>();
Assert.False(transit.OfferDestination(11, true, 110, out _));
Assert.True(transit.QueueStart(11));
Assert.True(transit.Activate(out int buffered));
Assert.Equal(110, buffered);
Assert.False(transit.OfferDestination(11, false, 111, out _));
}
[Fact]
public void InactiveOrdinaryPositionIsNeverBuffered()
{
var transit = new TeleportTransitCoordinator<int>();
Assert.False(transit.OfferDestination(11, false, 110, out _));
Assert.True(transit.QueueStart(11));
Assert.False(transit.Activate(out _));
}
[Fact]
public void DuplicateStartIsRejectedButSequenceWrapIsFresh()
{
var transit = new TeleportTransitCoordinator<int>();
Assert.True(transit.QueueStart(ushort.MaxValue));
Assert.False(transit.Activate(out _));
transit.EndActive();
Assert.False(transit.CanBegin(ushort.MaxValue));
Assert.False(transit.QueueStart(ushort.MaxValue));
Assert.True(transit.CanBegin(0));
Assert.True(transit.QueueStart(0));
Assert.True(transit.HasPendingStart);
}
[Fact]
public void ExactHalfRangeUsesRetailLowerStampTieBreak()
{
var transit = new TeleportTransitCoordinator<int>();
Assert.True(transit.QueueStart(0x8000));
Assert.False(transit.Activate(out _));
transit.EndActive();
Assert.True(transit.CanBegin(0x0000));
Assert.True(transit.QueueStart(0x0000));
}
[Fact]
public void ClearSessionAllowsSameSequenceInNewSession()
{
var transit = new TeleportTransitCoordinator<int>();
Assert.True(transit.QueueStart(7));
Assert.False(transit.Activate(out _));
transit.ClearSession();
Assert.False(transit.IsActive);
Assert.True(transit.CanBegin(7));
}
[Fact]
public void PendingStartAcceptsEqualPositionUntilPresentationCanActivate()
{
var transit = new TeleportTransitCoordinator<int>();
Assert.True(transit.QueueStart(11));
Assert.False(transit.OfferDestination(11, false, 110, out _));
Assert.True(transit.Activate(out int buffered));
Assert.Equal(110, buffered);
Assert.True(transit.IsActive);
}
[Fact]
public void AdvancingPositionPrunesSkippedDestination()
{
var transit = new TeleportTransitCoordinator<int>();
Assert.False(transit.OfferDestination(11, true, 110, out _));
Assert.False(transit.OfferDestination(12, true, 120, out _));
Assert.True(transit.QueueStart(12));
Assert.True(transit.Activate(out int buffered));
Assert.Equal(120, buffered);
}
[Fact]
public void ObsoleteDestinationCannotSurviveSequenceWrap()
{
var transit = new TeleportTransitCoordinator<int>();
Assert.False(transit.OfferDestination(ushort.MaxValue, true, 999, out _));
Assert.False(transit.OfferDestination(0, true, 0, out _));
Assert.True(transit.QueueStart(0));
Assert.True(transit.Activate(out int wrapped));
Assert.Equal(0, wrapped);
transit.EndActive();
// Advance in legal retail-newer hops until 0xFFFF is current again.
Assert.True(transit.QueueStart(0x7FFF));
Assert.False(transit.Activate(out _));
transit.EndActive();
Assert.True(transit.QueueStart(0xFFFE));
Assert.False(transit.Activate(out _));
transit.EndActive();
Assert.True(transit.QueueStart(ushort.MaxValue));
Assert.False(transit.Activate(out _));
}
}

View file

@ -31,16 +31,32 @@ public sealed class TeleportAnimSequencerTests
}
[Fact]
public void Snapshot_DefaultsAreOff_ClearAlpha()
public void Snapshot_DefaultsAreOff_WithGameViewPlane()
{
var snap = new TeleportAnimSnapshot(
TeleportAnimState.Off, FadeAlpha: 0f, ShowTunnel: false, ShowPleaseWait: false);
TeleportAnimState.Off, ViewPlaneBlend: 0f, ShowTunnel: false, ShowPleaseWait: false);
Assert.Equal(TeleportAnimState.Off, snap.State);
Assert.Equal(0f, snap.FadeAlpha);
Assert.Equal(0f, snap.ViewPlaneBlend);
Assert.False(snap.ShowTunnel);
Assert.False(snap.ShowPleaseWait);
}
[Fact]
public void Reset_CancelsTransitionAndDiscardsPendingEdges()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Reset();
var (snap, events) = seq.Tick(0f, worldReady: true);
Assert.False(seq.IsActive);
Assert.Equal(TeleportAnimState.Off, snap.State);
Assert.Equal(0f, snap.ViewPlaneBlend);
Assert.False(snap.ShowTunnel);
Assert.Empty(events);
}
// --- Task 1.2: Begin(), IsActive, initial state ---
[Theory]
@ -285,20 +301,20 @@ public sealed class TeleportAnimSequencerTests
Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts);
}
// --- Task 1.4: FadeAlpha endpoints and monotonicity ---
// --- Retail view-plane blend endpoints and monotonicity ---
[Fact]
public void FadeAlpha_IsZeroAtStart_OfWorldFadeOut()
public void ViewPlaneBlend_IsZeroAtStart_OfWorldFadeOut()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
var (snap, _) = seq.Tick(dt: 0f, worldReady: false);
// At elapsed=0 in WorldFadeOut: retail animation level 0 => fully visible.
Assert.Equal(0f, snap.FadeAlpha, precision: 4);
// At elapsed=0 the override still equals the ordinary game distance.
Assert.Equal(0f, snap.ViewPlaneBlend, precision: 4);
}
[Fact]
public void FadeAlpha_IsOneAtEnd_OfWorldFadeOut()
public void ViewPlaneBlend_IsOneAtEnd_OfWorldFadeOut()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
@ -309,12 +325,14 @@ public sealed class TeleportAnimSequencerTests
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
var (snap, _) = seq.Tick(0f, worldReady: false);
// The retail animation table should be close to fully opaque here.
Assert.True(snap.FadeAlpha > 0.95f, $"Expected FadeAlpha near 1, got {snap.FadeAlpha}");
// The retail animation table should be close to the transition distance here.
Assert.True(
snap.ViewPlaneBlend > 0.95f,
$"Expected ViewPlaneBlend near 1, got {snap.ViewPlaneBlend}");
}
[Fact]
public void FadeAlpha_IsMonotonicallyIncreasing_DuringWorldFadeOut()
public void ViewPlaneBlend_IsMonotonicallyIncreasing_DuringWorldFadeOut()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
@ -327,23 +345,23 @@ public sealed class TeleportAnimSequencerTests
{
var (snap, _) = seq.Tick(step, worldReady: false);
if (seq.State != TeleportAnimState.WorldFadeOut) break;
Assert.True(snap.FadeAlpha >= prev - 0.001f,
$"Alpha decreased: {prev} -> {snap.FadeAlpha} at step {i}");
prev = snap.FadeAlpha;
Assert.True(snap.ViewPlaneBlend >= prev - 0.001f,
$"View-plane blend decreased: {prev} -> {snap.ViewPlaneBlend} at step {i}");
prev = snap.ViewPlaneBlend;
}
}
[Fact]
public void FadeAlpha_IsZero_DuringTunnelStates()
public void ViewPlaneBlend_IsZero_DuringStableTunnelStates()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
// In Tunnel state, overlay alpha should be 0 (the tunnel viewport is shown; no alpha quad needed)
// Stable tunnel space uses the ordinary game projection.
var (snap, _) = seq.Tick(0.016f, worldReady: false);
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
Assert.Equal(0f, snap.FadeAlpha);
Assert.Equal(0f, snap.ViewPlaneBlend);
}
[Fact]
@ -372,6 +390,30 @@ public sealed class TeleportAnimSequencerTests
Assert.True(snap3.ShowTunnel);
}
[Fact]
public void Logout_EntersPortalViewportOnFirstTunnelFadeInTick()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
// Retail's visibility block runs before the transition block, so the
// WorldFadeOut crossing queues the viewport edge for the next tick.
var (crossing, crossingEvents) = seq.Tick(
TeleportAnimSequencer.FadeTime,
worldReady: false);
Assert.Equal(TeleportAnimState.TunnelFadeIn, crossing.State);
Assert.True(crossing.ShowTunnel);
Assert.DoesNotContain(TeleportAnimEvent.EnterTunnel, crossingEvents);
var (firstFadeInFrame, firstFadeInEvents) = seq.Tick(0f, worldReady: false);
Assert.Equal(TeleportAnimState.TunnelFadeIn, firstFadeInFrame.State);
Assert.Contains(TeleportAnimEvent.EnterTunnel, firstFadeInEvents);
var (_, tunnelEvents) = seq.Tick(TeleportAnimSequencer.FadeTime, worldReady: false);
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
Assert.DoesNotContain(TeleportAnimEvent.EnterTunnel, tunnelEvents);
}
[Fact]
public void ShowPleaseWait_TrueOnlyInTunnelContinue()
{