fix(world): remove non-retail portal exit fade
This commit is contained in:
parent
842bd89c16
commit
dded9e6b17
21 changed files with 919 additions and 347 deletions
|
|
@ -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
|
`TeleportAnimSequencer` is the pure Core owner of retail's seven-state
|
||||||
teleport lifecycle, exact 1/2/5-second thresholds, frame-aligned exit window,
|
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
|
`PortalTunnelPresentation` owns the synthetic DAT Setup (`0x02000306`), its
|
||||||
40-frame/s animation (`0x030005AC`), `CSequence`, SmartBox-FOV camera, distant light, mesh
|
40-frame/s animation (`0x030005AC`), `CSequence`, SmartBox-FOV camera, distant light, mesh
|
||||||
references, and animation-hook delivery. It renders through the existing
|
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
|
`SmartBox::SetOverrideFovDistance` / `Render::set_vdst`. It captures the active
|
||||||
game projection at teleport begin, then applies the same table-eased
|
game projection at teleport begin, then applies the same table-eased
|
||||||
view-plane distance and near plane to the portal and world viewports through
|
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
|
`+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
|
away from it. Destination placement also calls the retail chase camera's
|
||||||
`set_viewer(player, reset_sought=1)` equivalent; the normal damped/swept camera
|
`set_viewer(player, reset_sought=1)` equivalent; the normal damped/swept camera
|
||||||
path then re-extends from the arriving player.
|
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
|
The destination residency gate supplies retail's `EndTeleportAnimation` edge
|
||||||
once asynchronous streaming is ready. Player placement remains authoritative
|
once asynchronous streaming is ready. Player placement remains authoritative
|
||||||
and separate from presentation; arrival resets camera viewer state but does
|
and separate from presentation; arrival resets camera viewer state but does
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| # | 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-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-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-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) |
|
| 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-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-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)
|
## 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.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.
|
- **✓ 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.**
|
- **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.
|
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
client-enum-resolved DAT Setup `0x02000306` with animation `0x030005AC` at
|
||||||
40 frames/s in a dedicated CreatureMode-equivalent viewport, using retail's
|
40 frames/s in a dedicated CreatureMode-equivalent viewport, using retail's
|
||||||
camera, distant light, random eased forward-axis roll, frame-aligned exit
|
camera, distant light, random eased forward-axis roll, frame-aligned exit
|
||||||
window, exact 100-sample fade curve, view-plane/FOV warp, and destination
|
window, exact 100-sample view-plane curve, projection warp, and destination
|
||||||
viewer reset. The viewport and fades compose below retained UI, so
|
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
|
chat, panels, toolbar, cursor, and input remain live throughout travel. See
|
||||||
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
|
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,10 @@ show portal viewport
|
||||||
hide world SmartBox viewport
|
hide world SmartBox viewport
|
||||||
```
|
```
|
||||||
|
|
||||||
At the end of `TunnelFadeOut`, retail restores the world view distance, hides
|
At the end of `TunnelFadeOut`, retail commits the current transition view
|
||||||
the portal viewport, shows the world viewport, clears the teleport object's
|
distance, hides the portal viewport, shows the world viewport, clears the
|
||||||
sequence animations, plays `Sound_UI_ExitPortal`, and enters `WorldFadeIn`.
|
teleport object's sequence animations, plays `Sound_UI_ExitPortal`, and enters
|
||||||
|
`WorldFadeIn`. That following state restores the ordinary game view distance.
|
||||||
|
|
||||||
## Camera motion
|
## Camera motion
|
||||||
|
|
||||||
|
|
@ -185,31 +186,35 @@ WorldFadeIn:
|
||||||
-> Off
|
-> 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
|
the end of the 120-frame animation. The five-second ceiling is retail's escape
|
||||||
for a missed window.
|
for a missed window.
|
||||||
|
|
||||||
World/tunnel fades use `GetAnimLevel(elapsed / FadeTime)`. The fade belongs
|
The four states whose retail enum names contain `FADE` use
|
||||||
between the 3-D viewport and the retained UI. It must never cover or disable
|
`GetAnimLevel(elapsed / FadeTime)`. Instruction-level x86 inspection of
|
||||||
the retained UI.
|
`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
|
## 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
|
captures the current SmartBox view-plane distance; for a perspective
|
||||||
projection this is `cot(verticalFov / 2)`. The transition endpoint is the
|
projection this is `cot(verticalFov / 2)`. The transition endpoint is the
|
||||||
named constant `TRANSITION_VIEW_PLANE_DISTANCE = 0.001` at `0x007BD260`.
|
named constant `TRANSITION_VIEW_PLANE_DISTANCE = 0.001` at `0x007BD260`.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
fadeLevel = GetAnimLevel(elapsed / FadeTime) / 1024
|
viewPlaneLevel = GetAnimLevel(elapsed / FadeTime) / 1024
|
||||||
|
|
||||||
WorldFadeOut or TunnelFadeOut:
|
WorldFadeOut or TunnelFadeOut:
|
||||||
currentDistance = gameDistance
|
currentDistance = gameDistance
|
||||||
+ (0.001 - gameDistance) * fadeLevel
|
+ (0.001 - gameDistance) * viewPlaneLevel
|
||||||
|
|
||||||
TunnelFadeIn or WorldFadeIn:
|
TunnelFadeIn or WorldFadeIn:
|
||||||
currentDistance = 0.001
|
currentDistance = 0.001
|
||||||
+ (gameDistance - 0.001) * fadeLevel
|
+ (gameDistance - 0.001) * viewPlaneLevel
|
||||||
|
|
||||||
SmartBox.SetOverrideFovDistance(true, currentDistance)
|
SmartBox.SetOverrideFovDistance(true, currentDistance)
|
||||||
```
|
```
|
||||||
|
|
@ -221,10 +226,22 @@ verticalFov = 2 * atan(1 / currentDistance)
|
||||||
znear = max(0.1, currentDistance * 0.25)
|
znear = max(0.1, currentDistance * 0.25)
|
||||||
```
|
```
|
||||||
|
|
||||||
At `0.001`, the view is nearly 180 degrees wide. Black covers the singular
|
At `0.001`, the view is nearly 180 degrees wide. Retail does not cover that
|
||||||
endpoint; as `WorldFadeIn` clears, the FOV eases back to the captured game
|
endpoint with black: it switches directly from the tunnel viewport to the
|
||||||
projection. This is retail's characteristic destination-world warp, separate
|
destination world at this projection, then `WorldFadeIn` expands the world
|
||||||
from both the alpha fade and chase-camera movement.
|
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
|
## 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 retail teleport hook does not clear the character's animation sequence.
|
||||||
The arrival presentation is instead hidden by the portal scene and its final
|
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.
|
must not add an arrival-only animation reset.
|
||||||
|
|
||||||
## acdream integration translation
|
## 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
|
consumes the portal object's current frame for retail exit timing and uses
|
||||||
the exact table-driven easing curve.
|
the exact table-driven easing curve.
|
||||||
- During tunnel states the portal scene replaces world pixels before retained
|
- 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.
|
`RetailUiRuntime.Tick` continue unchanged.
|
||||||
- The projection owner captures the active camera's M22 view-plane distance at
|
- 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
|
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
|
- Destination placement resets the retail chase camera's published and sought
|
||||||
positions to the player before the normal update path resumes.
|
positions to the player before the normal update path resumes.
|
||||||
- Destination residency remains acdream's asynchronous adaptation. It supplies
|
- Destination residency remains acdream's asynchronous adaptation. It supplies
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
# Retail Teleport Flow Implementation Plan
|
# 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.
|
> **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**.
|
**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**.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
# Retail Teleport — Priority Residency + Fade Cover — Implementation Plan
|
# 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.
|
> **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.
|
**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.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
# Design — Retail teleport flow (the unified `TeleportAnimState` controller)
|
# 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
|
**Date:** 2026-06-21
|
||||||
**Feature:** Work item B from `docs/research/2026-06-21-teleport-issues-handoff.md` — the keystone of the
|
**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"
|
teleport-issues cluster (#138, #145 residual, the floating-camera / input-not-locked / "takes too long"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
# Retail-faithful teleport — priority residency + fade cover — design (2026-06-22)
|
# 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.
|
**Status:** approved design, pre-implementation.
|
||||||
**Branch:** `claude/thirsty-goldberg-51bb9b`. **Baseline:** `dd2eb8b` (+ the uncommitted `tp-probe` diagnostic).
|
**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
|
**Supersedes the framing of:** `docs/research/2026-06-21-teleport-foundation-handoff.md` (the `_datLock`-starvation
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
using System;
|
|
||||||
using Silk.NET.OpenGL;
|
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fullscreen black quad at a given alpha for retail's world/portal viewport
|
|
||||||
/// transitions. It is drawn after the active 3-D viewport but before retained
|
|
||||||
/// UI, matching <c>gmSmartBoxUI::UseTime</c>: the world or portal scene fades
|
|
||||||
/// while chat, toolbar, windows, and cursor remain fully visible.
|
|
||||||
///
|
|
||||||
/// <para>Draws in NDC, so it needs no view/projection. Self-contained GL state
|
|
||||||
/// (feedback_render_self_contained_gl_state): sets blend + disables depth, restores the
|
|
||||||
/// frame-global convention (depth test/write on) on exit.</para>
|
|
||||||
/// </summary>
|
|
||||||
public sealed class FadeOverlay : IDisposable
|
|
||||||
{
|
|
||||||
private const string VertSrc = @"#version 430 core
|
|
||||||
layout(location = 0) in vec2 aPos;
|
|
||||||
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }";
|
|
||||||
|
|
||||||
private const string FragSrc = @"#version 430 core
|
|
||||||
uniform float uAlpha;
|
|
||||||
out vec4 FragColor;
|
|
||||||
void main() { FragColor = vec4(0.0, 0.0, 0.0, uAlpha); }";
|
|
||||||
|
|
||||||
private readonly GL _gl;
|
|
||||||
private readonly uint _program;
|
|
||||||
private readonly uint _vao;
|
|
||||||
private readonly uint _vbo;
|
|
||||||
private readonly int _locAlpha;
|
|
||||||
|
|
||||||
// Two triangles covering the whole NDC viewport.
|
|
||||||
private static readonly float[] QuadVerts =
|
|
||||||
{
|
|
||||||
-1f, -1f, 1f, -1f, 1f, 1f,
|
|
||||||
-1f, -1f, 1f, 1f, -1f, 1f,
|
|
||||||
};
|
|
||||||
|
|
||||||
public FadeOverlay(GL gl)
|
|
||||||
{
|
|
||||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
|
||||||
|
|
||||||
uint vs = Compile(ShaderType.VertexShader, VertSrc);
|
|
||||||
uint fs = Compile(ShaderType.FragmentShader, FragSrc);
|
|
||||||
_program = _gl.CreateProgram();
|
|
||||||
_gl.AttachShader(_program, vs);
|
|
||||||
_gl.AttachShader(_program, fs);
|
|
||||||
_gl.LinkProgram(_program);
|
|
||||||
_gl.GetProgram(_program, ProgramPropertyARB.LinkStatus, out int linked);
|
|
||||||
if (linked == 0)
|
|
||||||
throw new InvalidOperationException($"FadeOverlay link failed: {_gl.GetProgramInfoLog(_program)}");
|
|
||||||
_gl.DeleteShader(vs);
|
|
||||||
_gl.DeleteShader(fs);
|
|
||||||
|
|
||||||
_locAlpha = _gl.GetUniformLocation(_program, "uAlpha");
|
|
||||||
|
|
||||||
_vao = _gl.GenVertexArray();
|
|
||||||
_vbo = _gl.GenBuffer();
|
|
||||||
_gl.BindVertexArray(_vao);
|
|
||||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
|
|
||||||
unsafe
|
|
||||||
{
|
|
||||||
fixed (float* v = QuadVerts)
|
|
||||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
|
||||||
(nuint)(QuadVerts.Length * sizeof(float)), v, BufferUsageARB.StaticDraw);
|
|
||||||
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), (void*)0);
|
|
||||||
}
|
|
||||||
_gl.EnableVertexAttribArray(0);
|
|
||||||
_gl.BindVertexArray(0);
|
|
||||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private uint Compile(ShaderType type, string src)
|
|
||||||
{
|
|
||||||
uint s = _gl.CreateShader(type);
|
|
||||||
_gl.ShaderSource(s, src);
|
|
||||||
_gl.CompileShader(s);
|
|
||||||
_gl.GetShader(s, ShaderParameterName.CompileStatus, out int ok);
|
|
||||||
if (ok == 0)
|
|
||||||
throw new InvalidOperationException($"FadeOverlay {type} compile failed: {_gl.GetShaderInfoLog(s)}");
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Draw the fullscreen black cover at <paramref name="alpha"/> (0 = clear → no-op,
|
|
||||||
/// 1 = opaque). Call after the active 3-D viewport and before retained UI.
|
|
||||||
/// </summary>
|
|
||||||
public void Draw(float alpha)
|
|
||||||
{
|
|
||||||
if (alpha <= 0f) return;
|
|
||||||
alpha = Math.Clamp(alpha, 0f, 1f);
|
|
||||||
|
|
||||||
// ---- set state (everything this draw depends on) ----
|
|
||||||
_gl.Disable(EnableCap.DepthTest);
|
|
||||||
_gl.DepthMask(false);
|
|
||||||
_gl.Disable(EnableCap.CullFace);
|
|
||||||
_gl.Enable(EnableCap.Blend);
|
|
||||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
|
||||||
|
|
||||||
_gl.UseProgram(_program);
|
|
||||||
_gl.Uniform1(_locAlpha, alpha);
|
|
||||||
_gl.BindVertexArray(_vao);
|
|
||||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, 6);
|
|
||||||
_gl.BindVertexArray(0);
|
|
||||||
_gl.UseProgram(0);
|
|
||||||
|
|
||||||
// ---- restore the frame-global convention ----
|
|
||||||
_gl.DepthMask(true);
|
|
||||||
_gl.Enable(EnableCap.DepthTest);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_gl.DeleteProgram(_program);
|
|
||||||
_gl.DeleteVertexArray(_vao);
|
|
||||||
_gl.DeleteBuffer(_vbo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -222,7 +222,6 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
|
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
|
||||||
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
|
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
|
||||||
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
|
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
|
||||||
private AcDream.App.Rendering.FadeOverlay? _fadeOverlay;
|
|
||||||
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
|
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
|
||||||
|
|
||||||
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
|
// 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
|
// T1: invisible portal depth writes (seal/punch) — retail
|
||||||
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
|
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
|
||||||
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
|
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
|
||||||
_fadeOverlay = new AcDream.App.Rendering.FadeOverlay(_gl);
|
_portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired(
|
||||||
_portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.TryCreate(
|
|
||||||
_gl,
|
_gl,
|
||||||
_dats!,
|
_dats!,
|
||||||
_animLoader!,
|
_animLoader!,
|
||||||
|
|
@ -2783,6 +2781,9 @@ public sealed class GameWindow : IDisposable
|
||||||
private void ClearInboundEntityState()
|
private void ClearInboundEntityState()
|
||||||
{
|
{
|
||||||
EndMouseLookAndRestoreCursor();
|
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
|
// Session-scoped origin readiness. A reconnect's first logical player
|
||||||
// CreateObject must initialize the render/streaming center anew;
|
// CreateObject must initialize the render/streaming center anew;
|
||||||
|
|
@ -6817,118 +6818,34 @@ public sealed class GameWindow : IDisposable
|
||||||
entity.Rotation = rmState.Body.Orientation;
|
entity.Rotation = rmState.Body.Orientation;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase B.3 / G.3a (#133): portal-space arrival detection.
|
// F751 is only a notification gate; the accepted Position may arrive
|
||||||
// Only runs for our own player character while in PortalSpace.
|
// before or after it. Canonical physics above always consumes the
|
||||||
if (_playerController is not null
|
// packet first. The presentation coordinator exposes exactly one
|
||||||
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
|
// sequence-correlated destination without reordering that state.
|
||||||
&& update.Guid == _playerServerGuid)
|
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;
|
AimTeleportDestination(teleportDestination);
|
||||||
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}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// player in PortalSpace until the destination is resident (TeleportWorldReady), then
|
||||||
// fires Place (materialize) and FireLoginComplete (regain control + ack the server).
|
// fires Place (materialize) and FireLoginComplete (regain control + ack the server).
|
||||||
// Replaces the old TeleportArrivalController hold/place machine.
|
// Replaces the old TeleportArrivalController hold/place machine.
|
||||||
private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new();
|
private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new();
|
||||||
private readonly TeleportViewPlaneController _teleportViewPlane = 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 System.Numerics.Vector3 _pendingTeleportPos;
|
||||||
private uint _pendingTeleportCell;
|
private uint _pendingTeleportCell;
|
||||||
private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout)
|
private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout)
|
||||||
private bool _teleportForced; // true when the safety-net timeout force-places
|
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;
|
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
|
||||||
|
|
||||||
// Loud safety net for a destination that never streams (worker crash / corrupt dat /
|
// 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;
|
private const float TeleportMaxHoldSeconds = 10f;
|
||||||
|
|
||||||
// 2026-06-22: how many landblocks of the player's IMMEDIATE SURROUNDINGS to eager-apply
|
// 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
|
// 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
|
// 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
|
// ring (out to the streaming window) still drains at the per-frame budget after portal
|
||||||
// lifts. Bigger = a more complete arrival but a longer (still hidden) loading fade.
|
// space exits. Bigger = a more complete arrival but a longer portal-space hold.
|
||||||
private const int TeleportNearRingRadius = 1;
|
private const int TeleportNearRingRadius = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bind a sequence-correlated teleport Position to presentation and
|
||||||
|
/// asynchronous streaming. Canonical physics has already accepted this
|
||||||
|
/// packet; this method only establishes the destination viewport lifetime.
|
||||||
|
/// </summary>
|
||||||
|
private void AimTeleportDestination(
|
||||||
|
AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
|
||||||
|
{
|
||||||
|
var playerController = _playerController
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"A teleport destination was accepted before the local player controller existed.");
|
||||||
|
var p = update.Position;
|
||||||
|
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
|
||||||
|
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
|
||||||
|
uint streamingOriginLandblockId =
|
||||||
|
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
|
||||||
|
_liveCenterX, _liveCenterY);
|
||||||
|
var origin = new System.Numerics.Vector3(
|
||||||
|
(lbX - _liveCenterX) * 192f,
|
||||||
|
(lbY - _liveCenterY) * 192f,
|
||||||
|
0f);
|
||||||
|
var worldPos = new System.Numerics.Vector3(
|
||||||
|
p.PositionX, p.PositionY, p.PositionZ) + origin;
|
||||||
|
|
||||||
|
// Retail preserves the full Position (objcell_id + Frame) through
|
||||||
|
// SmartBox::TeleportPlayer @ 0x00453910. Dungeon EnvCells can have
|
||||||
|
// negative local frame origins, so XYZ cannot identify the source
|
||||||
|
// landblock (#215). Player crossing and streaming recentering are
|
||||||
|
// separate while a prior destination is aimed but not yet placed.
|
||||||
|
var landblockTransition =
|
||||||
|
AcDream.App.Streaming.TeleportLandblockTransition.Classify(
|
||||||
|
playerController.CellId,
|
||||||
|
p.LandblockId,
|
||||||
|
streamingOriginLandblockId);
|
||||||
|
int oldLbX = (int)((landblockTransition.SourceLandblockId >> 24) & 0xFFu);
|
||||||
|
int oldLbY = (int)((landblockTransition.SourceLandblockId >> 16) & 0xFFu);
|
||||||
|
bool playerCrossesLandblock = landblockTransition.CrossesLandblock;
|
||||||
|
bool streamingCenterChanges = landblockTransition.ChangesStreamingCenter;
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
|
||||||
|
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, playerController.Position):F1}");
|
||||||
|
|
||||||
|
System.Numerics.Vector3 newWorldPos;
|
||||||
|
if (streamingCenterChanges)
|
||||||
|
{
|
||||||
|
// #145: drop the stale STREAMING center landblock from the physics engine
|
||||||
|
// BEFORE recentering. The destination loads at world-offset (0,0) (the new
|
||||||
|
// center), but the prior center was ALSO loaded at offset (0,0) and its
|
||||||
|
// offset is never re-based — so the two overlap, and the Z-agnostic outdoor
|
||||||
|
// cell-snap resolves the player into the stale center. On a replacement this
|
||||||
|
// can be the abandoned first destination, not the unplaced player's source.
|
||||||
|
_physicsEngine.RemoveLandblock(streamingOriginLandblockId);
|
||||||
|
|
||||||
|
_liveCenterX = lbX;
|
||||||
|
_liveCenterY = lbY;
|
||||||
|
newWorldPos = new System.Numerics.Vector3(
|
||||||
|
p.PositionX, p.PositionY, p.PositionZ);
|
||||||
|
|
||||||
|
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
|
||||||
|
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
||||||
|
else
|
||||||
|
_streamingController?.ForceReloadWindow();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
newWorldPos = worldPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not snap here. The TAS holds portal space until the destination
|
||||||
|
// near ring is resident, then emits Place exactly once.
|
||||||
|
_pendingTeleportRot = new System.Numerics.Quaternion(
|
||||||
|
p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||||
|
_pendingTeleportPos = newWorldPos;
|
||||||
|
_pendingTeleportCell = p.LandblockId;
|
||||||
|
_teleportHoldSeconds = 0f;
|
||||||
|
_teleportForced = false;
|
||||||
|
if (_streamingController is not null)
|
||||||
|
{
|
||||||
|
_streamingController.PriorityLandblockId =
|
||||||
|
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
|
||||||
|
_streamingController.PriorityRadius = TeleportNearRingRadius;
|
||||||
|
}
|
||||||
|
|
||||||
|
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||||||
|
"AIM", p.LandblockId,
|
||||||
|
$"seq={update.TeleportSequence} lb={lbX},{lbY} " +
|
||||||
|
$"indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} " +
|
||||||
|
$"playerCross={playerCrossesLandblock} centerChange={streamingCenterChanges}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End one logical teleport lifetime. This is the single symmetry seam
|
||||||
|
/// used by successful completion, replacement by a newer sequence, and
|
||||||
|
/// session teardown, so presentation state and destination state cannot
|
||||||
|
/// leak independently.
|
||||||
|
/// </summary>
|
||||||
|
private void ResetTeleportTransitState(bool clearSession = false)
|
||||||
|
{
|
||||||
|
if (clearSession)
|
||||||
|
_teleportTransit.ClearSession();
|
||||||
|
else
|
||||||
|
_teleportTransit.EndActive();
|
||||||
|
_pendingTeleportPos = default;
|
||||||
|
_pendingTeleportCell = 0u;
|
||||||
|
_pendingTeleportRot = System.Numerics.Quaternion.Identity;
|
||||||
|
_teleportHoldSeconds = 0f;
|
||||||
|
_teleportForced = false;
|
||||||
|
|
||||||
|
_teleportAnim.Reset();
|
||||||
|
_teleportViewPlane.Reset();
|
||||||
|
_portalTunnel?.Exit();
|
||||||
|
|
||||||
|
if (_streamingController is not null)
|
||||||
|
{
|
||||||
|
_streamingController.PriorityLandblockId = 0u;
|
||||||
|
_streamingController.PriorityRadius = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// #145: the LANDBLOCK-relative (cell-local) position used to SEED the player
|
// #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
|
// 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,
|
// (_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
|
/// 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
|
/// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around
|
||||||
/// the player, <see cref="TeleportNearRingRadius"/>) being registered — not just the
|
/// the player, <see cref="TeleportNearRingRadius"/>) being registered — not just the
|
||||||
/// single destination landblock — so the fade only lifts onto a loaded, collidable world
|
/// single destination landblock — so portal space exits onto a loaded, collidable world
|
||||||
/// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no
|
/// (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,
|
/// "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)
|
/// 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(
|
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||||||
"PLACED", resolved.CellId, $"forced={forced}");
|
"PLACED", resolved.CellId, $"forced={forced}");
|
||||||
// Do NOT flip to InWorld or send LoginComplete here — the player materializes BEHIND
|
// Do NOT flip to InWorld or send LoginComplete here — the player materializes while
|
||||||
// the fade and stays input-frozen (PortalSpace) until the TAS fades the world back in
|
// the portal viewport is active and stays input-frozen (PortalSpace) until the TAS
|
||||||
// and fires FireLoginComplete (which regains control + acks the server). This is the
|
// restores the destination projection and fires FireLoginComplete. This is the
|
||||||
// retail "pop out the other side" sequence.
|
// retail "pop out the other side" sequence.
|
||||||
Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
|
Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). Freeze movement
|
/// 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
|
/// input (PortalSpace) and begin retail portal transit. The per-frame TAS tick holds
|
||||||
/// the player behind the fade until the destination is resident, then materializes them
|
/// the player behind the portal viewport until the destination is resident, then materializes
|
||||||
/// (Place) and, after the world fades back in, regains control + acks the server
|
/// them (Place) and, after the destination view-plane restores, regains control + acks the server
|
||||||
/// (FireLoginComplete).
|
/// (FireLoginComplete).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void OnTeleportStarted(uint sequence)
|
private void OnTeleportStarted(uint sequence)
|
||||||
{
|
{
|
||||||
if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence))
|
ushort teleportSequence = (ushort)sequence;
|
||||||
|
if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, teleportSequence)
|
||||||
|
|| !_teleportTransit.CanBegin(teleportSequence))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
EndMouseLookAndRestoreCursor();
|
EndMouseLookAndRestoreCursor();
|
||||||
if (_playerController is not null)
|
// A fresh sequence is a new logical transit. Discard the prior
|
||||||
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
|
// destination and presentation before this sequence can observe them;
|
||||||
_teleportInProgress = true;
|
// its destination PositionUpdate may arrive on a later network tick.
|
||||||
|
ResetTeleportTransitState();
|
||||||
|
_teleportTransit.QueueStart(teleportSequence);
|
||||||
|
TryActivatePendingTeleportPresentation();
|
||||||
|
Console.WriteLine($"live: teleport queued (seq={sequence})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converge a queued F751 notification with the ordinary live-player
|
||||||
|
/// projection lifecycle. Usually this activates synchronously. If a
|
||||||
|
/// session transition has not materialized the player projection yet, the
|
||||||
|
/// notification and any correlated Position remain queued until it does.
|
||||||
|
/// </summary>
|
||||||
|
private void TryActivatePendingTeleportPresentation()
|
||||||
|
{
|
||||||
|
if (!_teleportTransit.HasPendingStart)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Retail has no detached fly/orbit gameplay mode. If acdream's live
|
||||||
|
// developer camera currently owns the view, restore the existing
|
||||||
|
// player-mode lifecycle before transit so placement still has the
|
||||||
|
// canonical local physics controller and chase-camera handoff.
|
||||||
|
var playerController = _playerController;
|
||||||
|
if (playerController is null)
|
||||||
|
{
|
||||||
|
if (!_entitiesByServerGuid.ContainsKey(_playerServerGuid))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_playerMode = true;
|
||||||
|
if (!EnterPlayerModeNow(loggingTag: "teleport"))
|
||||||
|
{
|
||||||
|
_playerMode = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
playerController = _playerController;
|
||||||
|
}
|
||||||
|
if (playerController is null)
|
||||||
|
return;
|
||||||
|
playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
|
||||||
_teleportHoldSeconds = 0f;
|
_teleportHoldSeconds = 0f;
|
||||||
_teleportForced = false;
|
_teleportForced = false;
|
||||||
_teleportViewPlane.Begin(
|
_teleportViewPlane.Begin(
|
||||||
_cameraController?.Active.Projection ?? System.Numerics.Matrix4x4.Identity);
|
_cameraController?.Active.Projection ?? System.Numerics.Matrix4x4.Identity);
|
||||||
_teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal);
|
_teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal);
|
||||||
Console.WriteLine($"live: teleport started (seq={sequence})");
|
bool hasBufferedDestination = _teleportTransit.Activate(
|
||||||
|
out var bufferedDestination);
|
||||||
|
if (hasBufferedDestination)
|
||||||
|
AimTeleportDestination(bufferedDestination);
|
||||||
|
Console.WriteLine($"live: teleport presentation started (seq={_teleportTransit.Sequence})");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -8881,14 +8962,19 @@ public sealed class GameWindow : IDisposable
|
||||||
// Step 2: routed through the controller; functionally identical.
|
// Step 2: routed through the controller; functionally identical.
|
||||||
_liveSessionController?.Tick();
|
_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
|
// Retail teleport transit. Runs AFTER streaming (which priority-applies the
|
||||||
// destination landblock this frame) and the live-session drain, so a destination
|
// 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
|
// that became resident this frame materializes the same frame. The TAS holds at
|
||||||
// Tunnel until worldReady, fires Place (materialize, hidden), then after the min-
|
// Tunnel until worldReady, fires Place (materialize, hidden), then after the min-
|
||||||
// continue + fades fires FireLoginComplete (regain control + ack). PortalTunnelPresentation
|
// continue + view-plane transitions fires FireLoginComplete (regain control + ack).
|
||||||
// replaces the world viewport through the tunnel-family states; FadeOverlay transitions
|
// PortalTunnelPresentation replaces the world viewport through the tunnel-family states;
|
||||||
// between the two viewports below the retained UI.
|
// retail switches the two 3-D viewports directly, below the retained UI.
|
||||||
if (_teleportInProgress)
|
if (_teleportTransit.IsActive)
|
||||||
{
|
{
|
||||||
bool haveDest = _pendingTeleportCell != 0u;
|
bool haveDest = _pendingTeleportCell != 0u;
|
||||||
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
|
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
|
||||||
|
|
@ -8905,12 +8991,6 @@ public sealed class GameWindow : IDisposable
|
||||||
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
|
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
|
||||||
var (snap, evts) = _teleportAnim.Tick((float)dt, ready, tunnelFrame);
|
var (snap, evts) = _teleportAnim.Tick((float)dt, ready, tunnelFrame);
|
||||||
_teleportViewPlane.Update(snap);
|
_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)
|
foreach (var e in evts)
|
||||||
{
|
{
|
||||||
|
|
@ -8937,10 +9017,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// each portal transition.
|
// each portal transition.
|
||||||
_liveSession?.SendGameAction(
|
_liveSession?.SendGameAction(
|
||||||
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
|
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
|
||||||
_teleportInProgress = false;
|
ResetTeleportTransitState();
|
||||||
_pendingTeleportCell = 0u;
|
|
||||||
_teleportViewPlane.Reset();
|
|
||||||
_teleportFadeAlpha = 0f;
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// The retained audio engine does not yet own the
|
// The retained audio engine does not yet own the
|
||||||
|
|
@ -9575,8 +9652,9 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
if (_cameraController is not null)
|
if (_cameraController is not null)
|
||||||
{
|
{
|
||||||
var camera = _cameraController.Active;
|
var activeCamera = _cameraController.Active;
|
||||||
var worldProjection = _teleportViewPlane.Apply(camera.Projection);
|
var camera = _teleportViewPlane.ApplyTo(activeCamera);
|
||||||
|
var worldProjection = camera.Projection;
|
||||||
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
|
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
|
||||||
|
|
||||||
// Extract camera world position from the inverse of the view
|
// 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
|
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
|
||||||
// CreatureMode portal-space viewport. This replacement scene and
|
// 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.
|
// cursor, and toolbar remain visible and interactive in transit.
|
||||||
var portalProjection = _teleportViewPlane.Apply(
|
var portalProjection = _teleportViewPlane.Apply(
|
||||||
_cameraController!.Active.Projection);
|
_cameraController!.Active.Projection);
|
||||||
|
|
@ -10497,7 +10575,6 @@ public sealed class GameWindow : IDisposable
|
||||||
_window!.Size.X,
|
_window!.Size.X,
|
||||||
_window.Size.Y,
|
_window.Size.Y,
|
||||||
portalProjection);
|
portalProjection);
|
||||||
_fadeOverlay?.Draw(_teleportFadeAlpha);
|
|
||||||
|
|
||||||
// Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the
|
// 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
|
// 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();
|
_wbDrawDispatcher?.Dispose();
|
||||||
_envCellRenderer?.Dispose(); // Phase A8
|
_envCellRenderer?.Dispose(); // Phase A8
|
||||||
_portalDepthMask?.Dispose(); // T1
|
_portalDepthMask?.Dispose(); // T1
|
||||||
_fadeOverlay?.Dispose();
|
|
||||||
_clipFrame?.Dispose(); // Phase U.3
|
_clipFrame?.Dispose(); // Phase U.3
|
||||||
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
|
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
|
||||||
_particleRenderer?.Dispose(); // releases particle mesh refs before WB
|
_particleRenderer?.Dispose(); // releases particle mesh refs before WB
|
||||||
|
|
|
||||||
|
|
@ -103,10 +103,11 @@ public sealed class PortalTunnelPresentation : IDisposable
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolve retail's enum-mapped portal Setup and animation through the
|
/// Resolve retail's enum-mapped portal Setup and animation through the
|
||||||
/// installed DATs. Missing assets fail closed with an actionable message;
|
/// installed DATs. Retail dereferences this scene unconditionally; missing
|
||||||
/// no substitute tunnel is fabricated.
|
/// required assets therefore fail startup with an actionable diagnostic.
|
||||||
|
/// No substitute tunnel is fabricated.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static PortalTunnelPresentation? TryCreate(
|
public static PortalTunnelPresentation CreateRequired(
|
||||||
GL gl,
|
GL gl,
|
||||||
DatCollection dats,
|
DatCollection dats,
|
||||||
IAnimationLoader animationLoader,
|
IAnimationLoader animationLoader,
|
||||||
|
|
@ -132,21 +133,18 @@ public sealed class PortalTunnelPresentation : IDisposable
|
||||||
? null
|
? null
|
||||||
: animationLoader.LoadAnimation(animationDid);
|
: animationLoader.LoadAnimation(animationDid);
|
||||||
|
|
||||||
if (setup is null || animation is null)
|
EnsureRequiredAssets(
|
||||||
{
|
setupDid,
|
||||||
Console.Error.WriteLine(
|
setup is not null,
|
||||||
"[portal-space] retail DAT assets unavailable: "
|
animationDid,
|
||||||
+ $"setup=0x{setupDid:X8} ({(setup is null ? "missing" : "ok")}), "
|
animation is not null);
|
||||||
+ $"animation=0x{animationDid:X8} ({(animation is null ? "missing" : "ok")})");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PortalTunnelPresentation(
|
return new PortalTunnelPresentation(
|
||||||
gl,
|
gl,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
lightUbo,
|
lightUbo,
|
||||||
meshAdapter,
|
meshAdapter,
|
||||||
setup,
|
setup!,
|
||||||
setupDid,
|
setupDid,
|
||||||
animationDid,
|
animationDid,
|
||||||
animationLoader,
|
animationLoader,
|
||||||
|
|
@ -155,6 +153,21 @@ public sealed class PortalTunnelPresentation : IDisposable
|
||||||
displayNotice);
|
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 bool IsVisible => _visible;
|
||||||
public int CurrentAnimationFrame => _sequence.GetCurrFrameNumber();
|
public int CurrentAnimationFrame => _sequence.GetCurrFrameNumber();
|
||||||
public uint SetupDid => _setupDid;
|
public uint SetupDid => _setupDid;
|
||||||
|
|
@ -210,7 +223,7 @@ public sealed class PortalTunnelPresentation : IDisposable
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Replace the already-rendered world viewport with retail portal space.
|
/// Replace the already-rendered world viewport with retail portal space.
|
||||||
/// The caller then draws the fade and retained UI above this pass.
|
/// The caller then draws retained UI above this pass.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Draw(int width, int height, Matrix4x4 smartBoxProjection)
|
public void Draw(int width, int height, Matrix4x4 smartBoxProjection)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,16 @@ namespace AcDream.App.Rendering;
|
||||||
/// (<c>0x004D6E30</c>) eases SmartBox's view-plane distance between the
|
/// (<c>0x004D6E30</c>) eases SmartBox's view-plane distance between the
|
||||||
/// active game's value and <c>TRANSITION_VIEW_PLANE_DISTANCE = 0.001</c>.
|
/// active game's value and <c>TRANSITION_VIEW_PLANE_DISTANCE = 0.001</c>.
|
||||||
/// <c>Render::set_vdst</c> (<c>0x0054B240</c>) converts that distance back to
|
/// <c>Render::set_vdst</c> (<c>0x0054B240</c>) converts that distance back to
|
||||||
/// FOV and adjusts the near plane. This is the wide projection warp visible
|
/// FOV and adjusts the near plane. At the exit edge retail swaps directly
|
||||||
/// while the destination world emerges from black.
|
/// from portal space to the destination world at the transition projection;
|
||||||
|
/// there is no black-alpha compositor between them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TeleportViewPlaneController
|
public sealed class TeleportViewPlaneController
|
||||||
{
|
{
|
||||||
public const float TransitionViewPlaneDistance = 0.001f;
|
public const float TransitionViewPlaneDistance = 0.001f;
|
||||||
|
|
||||||
private float _gameViewPlaneDistance = 1f;
|
private float _gameViewPlaneDistance = 1f;
|
||||||
|
private readonly ProjectionOverrideCamera _projectionCamera = new();
|
||||||
|
|
||||||
public bool Enabled { get; private set; }
|
public bool Enabled { get; private set; }
|
||||||
public float CurrentViewPlaneDistance { get; private set; } = 1f;
|
public float CurrentViewPlaneDistance { get; private set; } = 1f;
|
||||||
|
|
@ -38,20 +40,42 @@ public sealed class TeleportViewPlaneController
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Apply the four retail fade-state branches. Their projection blend is
|
/// Apply retail's view-plane state machine. Here "fade" is retail's state
|
||||||
/// numerically the same table-driven level as the black fade: normal to
|
/// name for a projection transition: normal to 0.001 on fade-out, and
|
||||||
/// 0.001 on fade-out, and 0.001 back to normal on fade-in.
|
/// 0.001 back to normal on fade-in. A logout transition retains the
|
||||||
|
/// captured override during stable Tunnel; <c>EndTeleportAnimation</c>
|
||||||
|
/// (<c>0x004D65D5</c>) releases it when entering TunnelContinue.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Update(TeleportAnimSnapshot snapshot)
|
public void Update(TeleportAnimSnapshot snapshot)
|
||||||
{
|
{
|
||||||
Enabled = snapshot.State is TeleportAnimState.WorldFadeOut
|
switch (snapshot.State)
|
||||||
or TeleportAnimState.TunnelFadeIn
|
{
|
||||||
or TeleportAnimState.TunnelFadeOut
|
case TeleportAnimState.WorldFadeOut:
|
||||||
or TeleportAnimState.WorldFadeIn;
|
case TeleportAnimState.TunnelFadeIn:
|
||||||
|
case TeleportAnimState.TunnelFadeOut:
|
||||||
|
case TeleportAnimState.WorldFadeIn:
|
||||||
|
Enabled = true;
|
||||||
|
CurrentViewPlaneDistance = Lerp(
|
||||||
|
_gameViewPlaneDistance,
|
||||||
|
TransitionViewPlaneDistance,
|
||||||
|
snapshot.ViewPlaneBlend);
|
||||||
|
break;
|
||||||
|
|
||||||
CurrentViewPlaneDistance = Enabled
|
case TeleportAnimState.Tunnel:
|
||||||
? Lerp(_gameViewPlaneDistance, TransitionViewPlaneDistance, snapshot.FadeAlpha)
|
// Retail preserves the captured FOV override through the
|
||||||
: _gameViewPlaneDistance;
|
// 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()
|
public void Reset()
|
||||||
|
|
@ -93,6 +117,39 @@ public sealed class TeleportViewPlaneController
|
||||||
return Matrix4x4.CreatePerspectiveFieldOfView(fov, aspect, near, far);
|
return Matrix4x4.CreatePerspectiveFieldOfView(fov, aspect, near, far);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decorate the active camera with the same projection returned by
|
||||||
|
/// <see cref="Apply(Matrix4x4)"/>. Retail's <c>Render::set_vdst</c> is
|
||||||
|
/// global to every 3-D draw; callers must pass this returned camera to
|
||||||
|
/// terrain, meshes, particles, sky, weather, and portal-cell rendering so
|
||||||
|
/// rasterization and frustum culling cannot use different projections.
|
||||||
|
/// </summary>
|
||||||
|
public ICamera ApplyTo(ICamera baseCamera)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(baseCamera);
|
||||||
|
_projectionCamera.Update(baseCamera, Apply(baseCamera.Projection));
|
||||||
|
return _projectionCamera;
|
||||||
|
}
|
||||||
|
|
||||||
private static float Lerp(float from, float to, float amount) =>
|
private static float Lerp(float from, float to, float amount) =>
|
||||||
from + (to - from) * Math.Clamp(amount, 0f, 1f);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ public sealed class StreamingController
|
||||||
/// so the player arrives to a near-empty world (the "Fort Tethana only one landblock
|
/// 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
|
/// 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
|
/// transient walk-through-walls). The far ring still drains at the budget. The eager
|
||||||
/// apply runs during the teleport hold (behind the fade), so the GPU spike is hidden.
|
/// apply runs while the portal viewport replaces the world, so the GPU spike is hidden.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int PriorityRadius { get; set; }
|
public int PriorityRadius { get; set; }
|
||||||
|
|
||||||
|
|
@ -377,7 +377,7 @@ public sealed class StreamingController
|
||||||
/// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none
|
/// 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
|
/// survives into the next frame stale, and no async unload can race a re-bake, then null
|
||||||
/// the region so the next <see cref="NormalTick"/> re-bootstraps the WHOLE window fresh at
|
/// the region so the next <see cref="NormalTick"/> re-bootstraps the WHOLE window fresh at
|
||||||
/// the new origin (the near ring is priority-applied behind the fade; the rest streams in).
|
/// the new origin (the near ring is priority-applied during portal travel; the rest streams in).
|
||||||
/// A sealed-dungeon destination uses <see cref="PreCollapseToDungeon"/> instead.
|
/// A sealed-dungeon destination uses <see cref="PreCollapseToDungeon"/> instead.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ForceReloadWindow()
|
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
|
/// (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
|
/// 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
|
/// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring
|
||||||
/// (<see cref="PriorityRadius"/>, applied behind the fade) likewise bypass the budget so the
|
/// (<see cref="PriorityRadius"/>, applied during portal travel) likewise bypass the budget so the
|
||||||
/// player materialises in a loaded world.
|
/// player materialises in a loaded world.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void DrainAndApply()
|
private void DrainAndApply()
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
namespace AcDream.App.Streaming;
|
namespace AcDream.App.Streaming;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Classifies whether a local-player teleport crosses an AC landblock boundary.
|
/// Classifies both whether the player crosses an AC landblock boundary and
|
||||||
/// Cell identity comes from the complete retail <c>Position</c>, never from its
|
/// whether acdream's asynchronous streaming origin must recenter. These are
|
||||||
/// frame coordinates: dungeon EnvCells may have valid negative local origins.
|
/// distinct while a teleport destination is aimed but not yet placed. Cell
|
||||||
|
/// identity comes from the complete retail <c>Position</c>, never from its frame
|
||||||
|
/// coordinates: dungeon EnvCells may have valid negative local origins.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Retail carries <c>Position::objcell_id</c> through
|
/// Retail carries <c>Position::objcell_id</c> through
|
||||||
|
|
@ -14,9 +16,12 @@ namespace AcDream.App.Streaming;
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal readonly record struct TeleportLandblockTransition(
|
internal readonly record struct TeleportLandblockTransition(
|
||||||
uint SourceLandblockId,
|
uint SourceLandblockId,
|
||||||
uint DestinationLandblockId)
|
uint DestinationLandblockId,
|
||||||
|
uint StreamingCenterLandblockId)
|
||||||
{
|
{
|
||||||
public bool CrossesLandblock => SourceLandblockId != DestinationLandblockId;
|
public bool CrossesLandblock => SourceLandblockId != DestinationLandblockId;
|
||||||
|
public bool ChangesStreamingCenter =>
|
||||||
|
StreamingCenterLandblockId != DestinationLandblockId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build the transition from the player's current cell and the received
|
/// Build the transition from the player's current cell and the received
|
||||||
|
|
@ -34,7 +39,8 @@ internal readonly record struct TeleportLandblockTransition(
|
||||||
|
|
||||||
return new TeleportLandblockTransition(
|
return new TeleportLandblockTransition(
|
||||||
sourceLandblockId,
|
sourceLandblockId,
|
||||||
NormalizeLandblockId(destinationCellId));
|
NormalizeLandblockId(destinationCellId),
|
||||||
|
NormalizeLandblockId(currentStreamingCenterLandblockId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static uint NormalizeLandblockId(uint cellOrLandblockId)
|
private static uint NormalizeLandblockId(uint cellOrLandblockId)
|
||||||
|
|
|
||||||
150
src/AcDream.App/Streaming/TeleportTransitCoordinator.cs
Normal file
150
src/AcDream.App/Streaming/TeleportTransitCoordinator.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
namespace AcDream.App.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Correlates F751 notifications with accepted Position packets without
|
||||||
|
/// imposing presentation order on canonical physics. F751 deliberately does
|
||||||
|
/// not consume retail's TELEPORT_TS, and the matching Position may be observed
|
||||||
|
/// on either side of the notification.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class TeleportTransitCoordinator<TDestination>
|
||||||
|
where TDestination : struct
|
||||||
|
{
|
||||||
|
private bool _hasLastStart;
|
||||||
|
private ushort _lastStartSequence;
|
||||||
|
private readonly Dictionary<ushort, TDestination> _bufferedDestinations = new();
|
||||||
|
private bool _hasPendingStart;
|
||||||
|
private ushort _pendingStartSequence;
|
||||||
|
private bool _destinationAccepted;
|
||||||
|
|
||||||
|
public bool IsActive { get; private set; }
|
||||||
|
public ushort Sequence { get; private set; }
|
||||||
|
public bool HasPendingStart => _hasPendingStart;
|
||||||
|
|
||||||
|
/// <summary>Reject retransmitted or older F751 notifications.</summary>
|
||||||
|
public bool CanBegin(ushort sequence) =>
|
||||||
|
!_hasLastStart || IsNewer(_lastStartSequence, sequence);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Record a fresh notification lifetime. Presentation activation is a
|
||||||
|
/// separate edge because acdream's optional developer camera can
|
||||||
|
/// temporarily withdraw the normal player projection.
|
||||||
|
/// </summary>
|
||||||
|
public bool QueueStart(ushort sequence)
|
||||||
|
{
|
||||||
|
if (!CanBegin(sequence))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
PruneDestinationsOlderThan(sequence);
|
||||||
|
IsActive = false;
|
||||||
|
Sequence = 0;
|
||||||
|
_hasPendingStart = true;
|
||||||
|
_pendingStartSequence = sequence;
|
||||||
|
_hasLastStart = true;
|
||||||
|
_lastStartSequence = sequence;
|
||||||
|
_destinationAccepted = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Activate the queued presentation and consume a destination that was
|
||||||
|
/// accepted before the player projection became available.
|
||||||
|
/// </summary>
|
||||||
|
public bool Activate(out TDestination bufferedDestination)
|
||||||
|
{
|
||||||
|
bufferedDestination = default;
|
||||||
|
if (!_hasPendingStart)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
IsActive = true;
|
||||||
|
Sequence = _pendingStartSequence;
|
||||||
|
_hasPendingStart = false;
|
||||||
|
_pendingStartSequence = 0;
|
||||||
|
_destinationAccepted = false;
|
||||||
|
|
||||||
|
if (!_bufferedDestinations.Remove(Sequence, out bufferedDestination))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_destinationAccepted = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offer an accepted Position packet. A matching active transit consumes
|
||||||
|
/// its first packet even when TELEPORT_TS was already advanced. Without a
|
||||||
|
/// matching notification, only a timestamp-advancing packet is buffered;
|
||||||
|
/// ordinary movement can never become a future portal destination.
|
||||||
|
/// </summary>
|
||||||
|
public bool OfferDestination(
|
||||||
|
ushort sequence,
|
||||||
|
bool teleportTimestampAdvanced,
|
||||||
|
TDestination destination,
|
||||||
|
out TDestination acceptedDestination)
|
||||||
|
{
|
||||||
|
acceptedDestination = default;
|
||||||
|
|
||||||
|
// An advancing TELEPORT_TS makes every older destination impossible
|
||||||
|
// to match a future F751. Retire those generations now so a skipped
|
||||||
|
// notification cannot retain a stale destination through u16 wrap.
|
||||||
|
if (teleportTimestampAdvanced)
|
||||||
|
PruneDestinationsOlderThan(sequence);
|
||||||
|
|
||||||
|
if (IsActive && sequence == Sequence)
|
||||||
|
{
|
||||||
|
if (_destinationAccepted)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_destinationAccepted = true;
|
||||||
|
acceptedDestination = destination;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hasPendingStart && sequence == _pendingStartSequence)
|
||||||
|
{
|
||||||
|
_bufferedDestinations.TryAdd(sequence, destination);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ushort correlationSequence = _hasPendingStart
|
||||||
|
? _pendingStartSequence
|
||||||
|
: Sequence;
|
||||||
|
bool mayBelongToFutureStart = (!IsActive && !_hasPendingStart)
|
||||||
|
|| IsNewer(correlationSequence, sequence);
|
||||||
|
if (!teleportTimestampAdvanced || !mayBelongToFutureStart)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_bufferedDestinations.TryAdd(sequence, destination);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>End presentation while retaining duplicate and reorder history.</summary>
|
||||||
|
public void EndActive()
|
||||||
|
{
|
||||||
|
IsActive = false;
|
||||||
|
Sequence = 0;
|
||||||
|
_hasPendingStart = false;
|
||||||
|
_pendingStartSequence = 0;
|
||||||
|
_destinationAccepted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clear every correlation value at a network-session boundary.</summary>
|
||||||
|
public void ClearSession()
|
||||||
|
{
|
||||||
|
EndActive();
|
||||||
|
_hasLastStart = false;
|
||||||
|
_lastStartSequence = 0;
|
||||||
|
_bufferedDestinations.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNewer(ushort current, ushort incoming)
|
||||||
|
=> AcDream.Core.Physics.PhysicsTimestampGate.IsNewer(current, incoming);
|
||||||
|
|
||||||
|
private void PruneDestinationsOlderThan(ushort sequence)
|
||||||
|
{
|
||||||
|
foreach (ushort bufferedSequence in _bufferedDestinations.Keys.ToArray())
|
||||||
|
{
|
||||||
|
if (IsNewer(bufferedSequence, sequence))
|
||||||
|
_bufferedDestinations.Remove(bufferedSequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ public enum TeleportEntryKind { Portal, Login, Death, Logout }
|
||||||
public enum TeleportAnimEvent
|
public enum TeleportAnimEvent
|
||||||
{
|
{
|
||||||
PlayEnterSound, // Begin(): sound_ui_enter_portal
|
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
|
Place, // Tunnel -> TunnelContinue: world loaded; place the player
|
||||||
PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal
|
PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal
|
||||||
FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1
|
FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1
|
||||||
|
|
@ -32,7 +32,7 @@ public enum TeleportAnimEvent
|
||||||
/// <summary>Immutable per-frame snapshot from the sequencer.</summary>
|
/// <summary>Immutable per-frame snapshot from the sequencer.</summary>
|
||||||
public readonly record struct TeleportAnimSnapshot(
|
public readonly record struct TeleportAnimSnapshot(
|
||||||
TeleportAnimState State,
|
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 ShowTunnel, // true during TunnelFadeIn..TunnelFadeOut
|
||||||
bool ShowPleaseWait); // true during TunnelContinue only
|
bool ShowPleaseWait); // true during TunnelContinue only
|
||||||
|
|
||||||
|
|
@ -82,6 +82,21 @@ public sealed class TeleportAnimSequencer
|
||||||
_enterTunnelPending = _state == TeleportAnimState.Tunnel; // true for Portal/Login/Death
|
_enterTunnelPending = _state == TeleportAnimState.Tunnel; // true for Portal/Login/Death
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancel the current transition and discard every pending edge event.
|
||||||
|
/// Session teardown and a replacement teleport both require a clean
|
||||||
|
/// lifetime; no event from the abandoned transition may reach the next
|
||||||
|
/// session or destination.
|
||||||
|
/// </summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_state = TeleportAnimState.Off;
|
||||||
|
_elapsed = 0f;
|
||||||
|
_continueElapsed = 0f;
|
||||||
|
_enterSoundPending = false;
|
||||||
|
_enterTunnelPending = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Advance the machine by <paramref name="dt"/> seconds.
|
/// Advance the machine by <paramref name="dt"/> seconds.
|
||||||
/// <paramref name="worldReady"/> = destination collision landblock is resident.
|
/// <paramref name="worldReady"/> = destination collision landblock is resident.
|
||||||
|
|
@ -104,12 +119,14 @@ public sealed class TeleportAnimSequencer
|
||||||
{
|
{
|
||||||
case TeleportAnimState.WorldFadeOut:
|
case TeleportAnimState.WorldFadeOut:
|
||||||
if (_elapsed >= FadeTime)
|
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;
|
break;
|
||||||
|
|
||||||
case TeleportAnimState.TunnelFadeIn:
|
case TeleportAnimState.TunnelFadeIn:
|
||||||
if (_elapsed >= FadeTime)
|
if (_elapsed >= FadeTime)
|
||||||
Advance(TeleportAnimState.Tunnel, enterTunnel: true);
|
Advance(TeleportAnimState.Tunnel, enterTunnel: false);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TeleportAnimState.Tunnel:
|
case TeleportAnimState.Tunnel:
|
||||||
|
|
@ -171,13 +188,13 @@ public sealed class TeleportAnimSequencer
|
||||||
|
|
||||||
private TeleportAnimSnapshot BuildSnapshot()
|
private TeleportAnimSnapshot BuildSnapshot()
|
||||||
{
|
{
|
||||||
float alpha = ComputeFadeAlpha(_state, _elapsed);
|
float viewBlend = ComputeViewPlaneBlend(_state, _elapsed);
|
||||||
bool showTunnel = _state is TeleportAnimState.TunnelFadeIn
|
bool showTunnel = _state is TeleportAnimState.TunnelFadeIn
|
||||||
or TeleportAnimState.Tunnel
|
or TeleportAnimState.Tunnel
|
||||||
or TeleportAnimState.TunnelContinue
|
or TeleportAnimState.TunnelContinue
|
||||||
or TeleportAnimState.TunnelFadeOut;
|
or TeleportAnimState.TunnelFadeOut;
|
||||||
bool pleaseWait = _state == TeleportAnimState.TunnelContinue;
|
bool pleaseWait = _state == TeleportAnimState.TunnelContinue;
|
||||||
return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait);
|
return new TeleportAnimSnapshot(_state, viewBlend, showTunnel, pleaseWait);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -193,21 +210,26 @@ public sealed class TeleportAnimSequencer
|
||||||
return RetailAnimationLevels[-negativeIndex];
|
return RetailAnimationLevels[-negativeIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed)
|
/// <summary>
|
||||||
|
/// Retail's four states named <c>*Fade*</c> do not drive an alpha cover.
|
||||||
|
/// <c>gmSmartBoxUI::UseTime</c> (0x004D7133-0x004D725F) uses this eased
|
||||||
|
/// level only to interpolate SmartBox's view-plane distance.
|
||||||
|
/// </summary>
|
||||||
|
private static float ComputeViewPlaneBlend(TeleportAnimState state, float elapsed)
|
||||||
{
|
{
|
||||||
float t = Math.Clamp(elapsed / FadeTime, 0f, 1f);
|
float t = Math.Clamp(elapsed / FadeTime, 0f, 1f);
|
||||||
float level = GetRetailAnimationLevel(t) / 1024f;
|
float level = GetRetailAnimationLevel(t) / 1024f;
|
||||||
return state switch
|
return state switch
|
||||||
{
|
{
|
||||||
// Fading TO black (alpha 0→1):
|
// Normal game view distance -> transition distance.
|
||||||
TeleportAnimState.WorldFadeOut => level,
|
TeleportAnimState.WorldFadeOut => level,
|
||||||
TeleportAnimState.TunnelFadeIn => 1f - level, // tunnel fades IN: overlay goes clear
|
TeleportAnimState.TunnelFadeOut => level,
|
||||||
// Full black / fully clear inside tunnel states:
|
// Transition distance -> normal game view distance.
|
||||||
TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed
|
TeleportAnimState.TunnelFadeIn => 1f - level,
|
||||||
|
TeleportAnimState.WorldFadeIn => 1f - level,
|
||||||
|
// Stable viewport states use the ordinary game projection.
|
||||||
|
TeleportAnimState.Tunnel => 0f,
|
||||||
TeleportAnimState.TunnelContinue => 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,
|
_ => 0f,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,21 @@ public sealed class PortalTunnelAssetTests
|
||||||
Assert.Equal(DatReaderWriter.Enums.AnimationHookType.SoundTweaked, hook.HookType);
|
Assert.Equal(DatReaderWriter.Enums.AnimationHookType.SoundTweaked, hook.HookType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RequiredAssets_MissingSetupOrAnimationFailsWithActionableIds()
|
||||||
|
{
|
||||||
|
var error = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
PortalTunnelPresentation.EnsureRequiredAssets(
|
||||||
|
setupDid: 0u,
|
||||||
|
setupLoaded: false,
|
||||||
|
animationDid: 0x030005ACu,
|
||||||
|
animationLoaded: true));
|
||||||
|
|
||||||
|
Assert.Contains("required retail DAT assets unavailable", error.Message);
|
||||||
|
Assert.Contains("setup=0x00000000 (missing)", error.Message);
|
||||||
|
Assert.Contains("animation=0x030005AC (ok)", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PortalTunnelCamera_RollsAroundForwardAxisWithoutLeavingPassage()
|
public void PortalTunnelCamera_RollsAroundForwardAxisWithoutLeavingPassage()
|
||||||
{
|
{
|
||||||
|
|
@ -96,7 +111,7 @@ public sealed class PortalTunnelAssetTests
|
||||||
|
|
||||||
controller.Update(new TeleportAnimSnapshot(
|
controller.Update(new TeleportAnimSnapshot(
|
||||||
TeleportAnimState.TunnelFadeOut,
|
TeleportAnimState.TunnelFadeOut,
|
||||||
FadeAlpha: 0.5f,
|
ViewPlaneBlend: 0.5f,
|
||||||
ShowTunnel: true,
|
ShowTunnel: true,
|
||||||
ShowPleaseWait: false));
|
ShowPleaseWait: false));
|
||||||
System.Numerics.Matrix4x4 projection = controller.Apply(baseProjection);
|
System.Numerics.Matrix4x4 projection = controller.Apply(baseProjection);
|
||||||
|
|
@ -126,7 +141,7 @@ public sealed class PortalTunnelAssetTests
|
||||||
|
|
||||||
controller.Update(new TeleportAnimSnapshot(
|
controller.Update(new TeleportAnimSnapshot(
|
||||||
TeleportAnimState.WorldFadeIn,
|
TeleportAnimState.WorldFadeIn,
|
||||||
FadeAlpha: 1f,
|
ViewPlaneBlend: 1f,
|
||||||
ShowTunnel: false,
|
ShowTunnel: false,
|
||||||
ShowPleaseWait: false));
|
ShowPleaseWait: false));
|
||||||
System.Numerics.Matrix4x4 wideProjection = controller.Apply(baseProjection);
|
System.Numerics.Matrix4x4 wideProjection = controller.Apply(baseProjection);
|
||||||
|
|
@ -139,7 +154,7 @@ public sealed class PortalTunnelAssetTests
|
||||||
|
|
||||||
controller.Update(new TeleportAnimSnapshot(
|
controller.Update(new TeleportAnimSnapshot(
|
||||||
TeleportAnimState.Off,
|
TeleportAnimState.Off,
|
||||||
FadeAlpha: 0f,
|
ViewPlaneBlend: 0f,
|
||||||
ShowTunnel: false,
|
ShowTunnel: false,
|
||||||
ShowPleaseWait: false));
|
ShowPleaseWait: false));
|
||||||
|
|
||||||
|
|
@ -147,6 +162,101 @@ public sealed class PortalTunnelAssetTests
|
||||||
Assert.Equal(baseProjection, controller.Apply(baseProjection));
|
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]
|
[Theory]
|
||||||
[InlineData(45f)]
|
[InlineData(45f)]
|
||||||
[InlineData(60f)]
|
[InlineData(60f)]
|
||||||
|
|
|
||||||
|
|
@ -60,4 +60,18 @@ public sealed class TeleportLandblockTransitionTests
|
||||||
Assert.False(transition.CrossesLandblock);
|
Assert.False(transition.CrossesLandblock);
|
||||||
Assert.Equal(0x8C04FFFFu, transition.SourceLandblockId);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Streaming;
|
||||||
|
|
||||||
|
public sealed class TeleportTransitCoordinatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ReplacementRejectsDelayedPriorDestinationAndAcceptsCurrentAdvance()
|
||||||
|
{
|
||||||
|
var transit = new TeleportTransitCoordinator<int>();
|
||||||
|
Assert.True(transit.QueueStart(10));
|
||||||
|
Assert.False(transit.Activate(out _));
|
||||||
|
transit.EndActive();
|
||||||
|
Assert.True(transit.QueueStart(11));
|
||||||
|
Assert.False(transit.Activate(out _));
|
||||||
|
|
||||||
|
Assert.False(transit.OfferDestination(10, true, 100, out _));
|
||||||
|
Assert.True(transit.OfferDestination(11, true, 110, out int accepted));
|
||||||
|
Assert.Equal(110, accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PositionBeforeF751IsBufferedAndEqualFollowupCannotReplaceIt()
|
||||||
|
{
|
||||||
|
var transit = new TeleportTransitCoordinator<int>();
|
||||||
|
|
||||||
|
Assert.False(transit.OfferDestination(11, true, 110, out _));
|
||||||
|
Assert.True(transit.QueueStart(11));
|
||||||
|
Assert.True(transit.Activate(out int buffered));
|
||||||
|
Assert.Equal(110, buffered);
|
||||||
|
Assert.False(transit.OfferDestination(11, false, 111, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InactiveOrdinaryPositionIsNeverBuffered()
|
||||||
|
{
|
||||||
|
var transit = new TeleportTransitCoordinator<int>();
|
||||||
|
|
||||||
|
Assert.False(transit.OfferDestination(11, false, 110, out _));
|
||||||
|
Assert.True(transit.QueueStart(11));
|
||||||
|
Assert.False(transit.Activate(out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateStartIsRejectedButSequenceWrapIsFresh()
|
||||||
|
{
|
||||||
|
var transit = new TeleportTransitCoordinator<int>();
|
||||||
|
Assert.True(transit.QueueStart(ushort.MaxValue));
|
||||||
|
Assert.False(transit.Activate(out _));
|
||||||
|
transit.EndActive();
|
||||||
|
|
||||||
|
Assert.False(transit.CanBegin(ushort.MaxValue));
|
||||||
|
Assert.False(transit.QueueStart(ushort.MaxValue));
|
||||||
|
Assert.True(transit.CanBegin(0));
|
||||||
|
Assert.True(transit.QueueStart(0));
|
||||||
|
Assert.True(transit.HasPendingStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExactHalfRangeUsesRetailLowerStampTieBreak()
|
||||||
|
{
|
||||||
|
var transit = new TeleportTransitCoordinator<int>();
|
||||||
|
Assert.True(transit.QueueStart(0x8000));
|
||||||
|
Assert.False(transit.Activate(out _));
|
||||||
|
transit.EndActive();
|
||||||
|
|
||||||
|
Assert.True(transit.CanBegin(0x0000));
|
||||||
|
Assert.True(transit.QueueStart(0x0000));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearSessionAllowsSameSequenceInNewSession()
|
||||||
|
{
|
||||||
|
var transit = new TeleportTransitCoordinator<int>();
|
||||||
|
Assert.True(transit.QueueStart(7));
|
||||||
|
Assert.False(transit.Activate(out _));
|
||||||
|
|
||||||
|
transit.ClearSession();
|
||||||
|
|
||||||
|
Assert.False(transit.IsActive);
|
||||||
|
Assert.True(transit.CanBegin(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PendingStartAcceptsEqualPositionUntilPresentationCanActivate()
|
||||||
|
{
|
||||||
|
var transit = new TeleportTransitCoordinator<int>();
|
||||||
|
Assert.True(transit.QueueStart(11));
|
||||||
|
|
||||||
|
Assert.False(transit.OfferDestination(11, false, 110, out _));
|
||||||
|
Assert.True(transit.Activate(out int buffered));
|
||||||
|
|
||||||
|
Assert.Equal(110, buffered);
|
||||||
|
Assert.True(transit.IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvancingPositionPrunesSkippedDestination()
|
||||||
|
{
|
||||||
|
var transit = new TeleportTransitCoordinator<int>();
|
||||||
|
|
||||||
|
Assert.False(transit.OfferDestination(11, true, 110, out _));
|
||||||
|
Assert.False(transit.OfferDestination(12, true, 120, out _));
|
||||||
|
Assert.True(transit.QueueStart(12));
|
||||||
|
Assert.True(transit.Activate(out int buffered));
|
||||||
|
|
||||||
|
Assert.Equal(120, buffered);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObsoleteDestinationCannotSurviveSequenceWrap()
|
||||||
|
{
|
||||||
|
var transit = new TeleportTransitCoordinator<int>();
|
||||||
|
|
||||||
|
Assert.False(transit.OfferDestination(ushort.MaxValue, true, 999, out _));
|
||||||
|
Assert.False(transit.OfferDestination(0, true, 0, out _));
|
||||||
|
Assert.True(transit.QueueStart(0));
|
||||||
|
Assert.True(transit.Activate(out int wrapped));
|
||||||
|
Assert.Equal(0, wrapped);
|
||||||
|
transit.EndActive();
|
||||||
|
|
||||||
|
// Advance in legal retail-newer hops until 0xFFFF is current again.
|
||||||
|
Assert.True(transit.QueueStart(0x7FFF));
|
||||||
|
Assert.False(transit.Activate(out _));
|
||||||
|
transit.EndActive();
|
||||||
|
Assert.True(transit.QueueStart(0xFFFE));
|
||||||
|
Assert.False(transit.Activate(out _));
|
||||||
|
transit.EndActive();
|
||||||
|
Assert.True(transit.QueueStart(ushort.MaxValue));
|
||||||
|
|
||||||
|
Assert.False(transit.Activate(out _));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,16 +31,32 @@ public sealed class TeleportAnimSequencerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Snapshot_DefaultsAreOff_ClearAlpha()
|
public void Snapshot_DefaultsAreOff_WithGameViewPlane()
|
||||||
{
|
{
|
||||||
var snap = new TeleportAnimSnapshot(
|
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(TeleportAnimState.Off, snap.State);
|
||||||
Assert.Equal(0f, snap.FadeAlpha);
|
Assert.Equal(0f, snap.ViewPlaneBlend);
|
||||||
Assert.False(snap.ShowTunnel);
|
Assert.False(snap.ShowTunnel);
|
||||||
Assert.False(snap.ShowPleaseWait);
|
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 ---
|
// --- Task 1.2: Begin(), IsActive, initial state ---
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|
@ -285,20 +301,20 @@ public sealed class TeleportAnimSequencerTests
|
||||||
Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts);
|
Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Task 1.4: FadeAlpha endpoints and monotonicity ---
|
// --- Retail view-plane blend endpoints and monotonicity ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FadeAlpha_IsZeroAtStart_OfWorldFadeOut()
|
public void ViewPlaneBlend_IsZeroAtStart_OfWorldFadeOut()
|
||||||
{
|
{
|
||||||
var seq = new TeleportAnimSequencer();
|
var seq = new TeleportAnimSequencer();
|
||||||
seq.Begin(TeleportEntryKind.Logout);
|
seq.Begin(TeleportEntryKind.Logout);
|
||||||
var (snap, _) = seq.Tick(dt: 0f, worldReady: false);
|
var (snap, _) = seq.Tick(dt: 0f, worldReady: false);
|
||||||
// At elapsed=0 in WorldFadeOut: retail animation level 0 => fully visible.
|
// At elapsed=0 the override still equals the ordinary game distance.
|
||||||
Assert.Equal(0f, snap.FadeAlpha, precision: 4);
|
Assert.Equal(0f, snap.ViewPlaneBlend, precision: 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FadeAlpha_IsOneAtEnd_OfWorldFadeOut()
|
public void ViewPlaneBlend_IsOneAtEnd_OfWorldFadeOut()
|
||||||
{
|
{
|
||||||
var seq = new TeleportAnimSequencer();
|
var seq = new TeleportAnimSequencer();
|
||||||
seq.Begin(TeleportEntryKind.Logout);
|
seq.Begin(TeleportEntryKind.Logout);
|
||||||
|
|
@ -309,12 +325,14 @@ public sealed class TeleportAnimSequencerTests
|
||||||
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
|
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
|
||||||
|
|
||||||
var (snap, _) = seq.Tick(0f, worldReady: false);
|
var (snap, _) = seq.Tick(0f, worldReady: false);
|
||||||
// The retail animation table should be close to fully opaque here.
|
// The retail animation table should be close to the transition distance here.
|
||||||
Assert.True(snap.FadeAlpha > 0.95f, $"Expected FadeAlpha near 1, got {snap.FadeAlpha}");
|
Assert.True(
|
||||||
|
snap.ViewPlaneBlend > 0.95f,
|
||||||
|
$"Expected ViewPlaneBlend near 1, got {snap.ViewPlaneBlend}");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FadeAlpha_IsMonotonicallyIncreasing_DuringWorldFadeOut()
|
public void ViewPlaneBlend_IsMonotonicallyIncreasing_DuringWorldFadeOut()
|
||||||
{
|
{
|
||||||
var seq = new TeleportAnimSequencer();
|
var seq = new TeleportAnimSequencer();
|
||||||
seq.Begin(TeleportEntryKind.Logout);
|
seq.Begin(TeleportEntryKind.Logout);
|
||||||
|
|
@ -327,23 +345,23 @@ public sealed class TeleportAnimSequencerTests
|
||||||
{
|
{
|
||||||
var (snap, _) = seq.Tick(step, worldReady: false);
|
var (snap, _) = seq.Tick(step, worldReady: false);
|
||||||
if (seq.State != TeleportAnimState.WorldFadeOut) break;
|
if (seq.State != TeleportAnimState.WorldFadeOut) break;
|
||||||
Assert.True(snap.FadeAlpha >= prev - 0.001f,
|
Assert.True(snap.ViewPlaneBlend >= prev - 0.001f,
|
||||||
$"Alpha decreased: {prev} -> {snap.FadeAlpha} at step {i}");
|
$"View-plane blend decreased: {prev} -> {snap.ViewPlaneBlend} at step {i}");
|
||||||
prev = snap.FadeAlpha;
|
prev = snap.ViewPlaneBlend;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FadeAlpha_IsZero_DuringTunnelStates()
|
public void ViewPlaneBlend_IsZero_DuringStableTunnelStates()
|
||||||
{
|
{
|
||||||
var seq = new TeleportAnimSequencer();
|
var seq = new TeleportAnimSequencer();
|
||||||
seq.Begin(TeleportEntryKind.Portal);
|
seq.Begin(TeleportEntryKind.Portal);
|
||||||
seq.Tick(0f, worldReady: false);
|
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);
|
var (snap, _) = seq.Tick(0.016f, worldReady: false);
|
||||||
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
||||||
Assert.Equal(0f, snap.FadeAlpha);
|
Assert.Equal(0f, snap.ViewPlaneBlend);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -372,6 +390,30 @@ public sealed class TeleportAnimSequencerTests
|
||||||
Assert.True(snap3.ShowTunnel);
|
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]
|
[Fact]
|
||||||
public void ShowPleaseWait_TrueOnlyInTunnelContinue()
|
public void ShowPleaseWait_TrueOnlyInTunnelContinue()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue