diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index f35c88e9..475fbaf9 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -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 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 74c0f3c8..60658508 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 4654d416..e5d538da 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, 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. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index dbedf4f2..6f74d445 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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`. 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 1ada51f2..47fd5ef7 100644 --- a/docs/research/2026-07-15-retail-portal-space-pseudocode.md +++ b/docs/research/2026-07-15-retail-portal-space-pseudocode.md @@ -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 diff --git a/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md b/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md index 23784ea3..be15ae0b 100644 --- a/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md +++ b/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md @@ -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**. diff --git a/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md b/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md index b06406d7..24e357a6 100644 --- a/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md +++ b/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md @@ -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 10–14 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. diff --git a/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md b/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md index d646e873..c8299b4f 100644 --- a/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md +++ b/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md @@ -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" diff --git a/docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md b/docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md index 031953d0..552d0136 100644 --- a/docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md +++ b/docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md @@ -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 diff --git a/src/AcDream.App/Rendering/FadeOverlay.cs b/src/AcDream.App/Rendering/FadeOverlay.cs deleted file mode 100644 index 085080e4..00000000 --- a/src/AcDream.App/Rendering/FadeOverlay.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using Silk.NET.OpenGL; - -namespace AcDream.App.Rendering; - -/// -/// 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 gmSmartBoxUI::UseTime: the world or portal scene fades -/// while chat, toolbar, windows, and cursor remain fully visible. -/// -/// 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. -/// -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; - } - - /// - /// Draw the fullscreen black cover at (0 = clear → no-op, - /// 1 = opaque). Call after the active 3-D viewport and before retained UI. - /// - 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); - } -} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d334bf52..38c227a0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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; + /// + /// 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. + /// + 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}"); + } + + /// + /// 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. + /// + 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, ) 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}"); } /// /// 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). /// 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})"); + } + + /// + /// 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. + /// + 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})"); } /// @@ -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 diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs index 73aaaa29..4c5bbd88 100644 --- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs +++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs @@ -103,10 +103,11 @@ public sealed class PortalTunnelPresentation : IDisposable /// /// 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. /// - 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 /// /// 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. /// public void Draw(int width, int height, Matrix4x4 smartBoxProjection) { diff --git a/src/AcDream.App/Rendering/TeleportViewPlaneController.cs b/src/AcDream.App/Rendering/TeleportViewPlaneController.cs index 2accef56..01c50711 100644 --- a/src/AcDream.App/Rendering/TeleportViewPlaneController.cs +++ b/src/AcDream.App/Rendering/TeleportViewPlaneController.cs @@ -8,14 +8,16 @@ namespace AcDream.App.Rendering; /// (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. +/// 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. /// 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 } /// - /// 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; EndTeleportAnimation + /// (0x004D65D5) releases it when entering TunnelContinue. /// 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); } + /// + /// Decorate the active camera with the same projection returned by + /// . Retail's Render::set_vdst 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. + /// + 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; + } + } } diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index d39b3d9f..969c9a41 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -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. /// 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 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 instead. /// 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 - /// (, applied behind the fade) likewise bypass the budget so the + /// (, applied during portal travel) likewise bypass the budget so the /// player materialises in a loaded world. /// private void DrainAndApply() diff --git a/src/AcDream.App/Streaming/TeleportLandblockTransition.cs b/src/AcDream.App/Streaming/TeleportLandblockTransition.cs index c982d24f..f8acdb80 100644 --- a/src/AcDream.App/Streaming/TeleportLandblockTransition.cs +++ b/src/AcDream.App/Streaming/TeleportLandblockTransition.cs @@ -1,9 +1,11 @@ namespace AcDream.App.Streaming; /// -/// Classifies whether a local-player teleport crosses an AC landblock boundary. -/// Cell identity comes from the complete retail Position, 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 Position, never from its frame +/// coordinates: dungeon EnvCells may have valid negative local origins. /// /// /// Retail carries Position::objcell_id through @@ -14,9 +16,12 @@ namespace AcDream.App.Streaming; /// internal readonly record struct TeleportLandblockTransition( uint SourceLandblockId, - uint DestinationLandblockId) + uint DestinationLandblockId, + uint StreamingCenterLandblockId) { public bool CrossesLandblock => SourceLandblockId != DestinationLandblockId; + public bool ChangesStreamingCenter => + StreamingCenterLandblockId != DestinationLandblockId; /// /// 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) diff --git a/src/AcDream.App/Streaming/TeleportTransitCoordinator.cs b/src/AcDream.App/Streaming/TeleportTransitCoordinator.cs new file mode 100644 index 00000000..7987ebe8 --- /dev/null +++ b/src/AcDream.App/Streaming/TeleportTransitCoordinator.cs @@ -0,0 +1,150 @@ +namespace AcDream.App.Streaming; + +/// +/// 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. +/// +internal sealed class TeleportTransitCoordinator + where TDestination : struct +{ + private bool _hasLastStart; + private ushort _lastStartSequence; + private readonly Dictionary _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; + + /// Reject retransmitted or older F751 notifications. + public bool CanBegin(ushort sequence) => + !_hasLastStart || IsNewer(_lastStartSequence, sequence); + + /// + /// Record a fresh notification lifetime. Presentation activation is a + /// separate edge because acdream's optional developer camera can + /// temporarily withdraw the normal player projection. + /// + 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; + } + + /// + /// Activate the queued presentation and consume a destination that was + /// accepted before the player projection became available. + /// + 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; + } + + /// + /// 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. + /// + 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; + } + + /// End presentation while retaining duplicate and reorder history. + public void EndActive() + { + IsActive = false; + Sequence = 0; + _hasPendingStart = false; + _pendingStartSequence = 0; + _destinationAccepted = false; + } + + /// Clear every correlation value at a network-session boundary. + 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); + } + } +} diff --git a/src/AcDream.Core/World/TeleportAnimSequencer.cs b/src/AcDream.Core/World/TeleportAnimSequencer.cs index 3042a07d..510c27d5 100644 --- a/src/AcDream.Core/World/TeleportAnimSequencer.cs +++ b/src/AcDream.Core/World/TeleportAnimSequencer.cs @@ -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 /// Immutable per-frame snapshot from the sequencer. 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 } + /// + /// 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. + /// + public void Reset() + { + _state = TeleportAnimState.Off; + _elapsed = 0f; + _continueElapsed = 0f; + _enterSoundPending = false; + _enterTunnelPending = false; + } + /// /// Advance the machine by seconds. /// = 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); } /// @@ -193,21 +210,26 @@ public sealed class TeleportAnimSequencer return RetailAnimationLevels[-negativeIndex]; } - private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed) + /// + /// Retail's four states named *Fade* do not drive an alpha cover. + /// gmSmartBoxUI::UseTime (0x004D7133-0x004D725F) uses this eased + /// level only to interpolate SmartBox's view-plane distance. + /// + 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, }; } diff --git a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs index f58702ab..e567e81e 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs @@ -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(() => + 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)] diff --git a/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs b/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs index 027d4665..1cadf774 100644 --- a/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs +++ b/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs @@ -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); + } } diff --git a/tests/AcDream.App.Tests/Streaming/TeleportTransitCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/TeleportTransitCoordinatorTests.cs new file mode 100644 index 00000000..d5eea9f9 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/TeleportTransitCoordinatorTests.cs @@ -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(); + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); + + 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(); + + 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 _)); + } +} diff --git a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs index 59d8f110..2a6e1a88 100644 --- a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs +++ b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs @@ -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() {