feat(world): port retail portal-space viewport
Replace the opaque teleport cover with the DAT-authored CreatureMode tunnel, exact retail camera/light/animation timing and easing, and animation-hook audio routing. Compose portal and fade passes below retained UI so gameplay windows and input remain active through F751 travel. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
0eab7497c1
commit
eab23cbdd1
14 changed files with 983 additions and 45 deletions
|
|
@ -595,6 +595,25 @@ same-landblock teleport keeps the already-published render and physics unit.
|
||||||
This is the asynchronous adapter for retail's direct full-`Position` placement
|
This is the asynchronous adapter for retail's direct full-`Position` placement
|
||||||
(`SmartBox::TeleportPlayer @ 0x00453910`).
|
(`SmartBox::TeleportPlayer @ 0x00453910`).
|
||||||
|
|
||||||
|
### Portal-space presentation boundary
|
||||||
|
|
||||||
|
`TeleportAnimSequencer` is the pure Core owner of retail's seven-state
|
||||||
|
teleport lifecycle, exact 1/2/5-second thresholds, frame-aligned exit window,
|
||||||
|
and 100-sample `UIGlobals::GetAnimLevel` fade curve. App-layer
|
||||||
|
`PortalTunnelPresentation` owns the synthetic DAT Setup (`0x02000306`), its
|
||||||
|
40-frame/s animation (`0x030005AC`), `CSequence`, SmartBox-FOV camera, distant light, mesh
|
||||||
|
references, and animation-hook delivery. It renders through the existing
|
||||||
|
modern `WbDrawDispatcher` as a replacement 3-D viewport, after world rendering
|
||||||
|
and before retained UI. The shared dispatcher is reset to no world-cell clips
|
||||||
|
and no world point lights for this pass. Chat, gameplay windows, toolbar,
|
||||||
|
cursor, and input continue normally above it. The synthetic object never
|
||||||
|
enters `LiveEntityRuntime`, collision, picking, radar, or server GUID state.
|
||||||
|
|
||||||
|
The destination residency gate supplies retail's `EndTeleportAnimation` edge
|
||||||
|
once asynchronous streaming is ready. Player placement remains authoritative
|
||||||
|
and separate from presentation; arrival does not reset the character animation
|
||||||
|
sequence. See `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
|
||||||
|
|
||||||
## Roadmap Model
|
## Roadmap Model
|
||||||
|
|
||||||
The old R1-R8 architecture sequence was a useful early refactor sketch, but it
|
The old R1-R8 architecture sequence was a useful early refactor sketch, but it
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Adaptation (AD) — 38 rows (AD-43 added 2026-07-14 — malformed zero-time PhysicsScript recursion fails diagnostically instead of hanging the update thread)
|
## 2. Adaptation (AD) — 37 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
|
||||||
|
|
||||||
| # | 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 |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
|
@ -90,7 +90,6 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
|
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
|
||||||
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
|
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
|
||||||
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
|
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
|
||||||
| AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) |
|
|
||||||
| AD-32 | Movement, Position, State, Vector, Pickup, Delete, and ObjDesc packets for a FUTURE object incarnation are dropped until its CreateObject arrives; retail queues each blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it after construction. F754/F755 and ParentEvent are no longer part of this divergence: `EntityEffectController` preserves one mixed effect FIFO per server GUID until the canonical local owner is ready, while runtime-owned `ParentAttachmentState` retains parent relations by generation. Older-incarnation state packets drop in both clients. | `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` (`TryAcceptInstance`); `src/AcDream.App/World/InboundPhysicsStateController.cs`; `src/AcDream.App/World/LiveEntityRuntime.cs`; effect exception `src/AcDream.App/Rendering/Vfx/EntityEffectController.cs`; Parent exception `src/AcDream.App/World/ParentAttachmentState.cs` | acdream has no general per-object mixed queue for non-effect state packets yet. Dropping those state families is generation-safe: a future packet cannot mutate the old body or advance its stamps, and the newer CreateObject wholesale-seeds all nine channels. The canonical runtime remains the seam for widening the queue later. | The first queued non-effect state can be absent until a later authoritative update; effect and Parent packets retain retail order and ownership | `SmartBox::HandleSetState` 0x004533E0; `HandleVectorUpdate` 0x00453480; `HandleParentEvent` 0x004535D0; `HandlePickupEvent` 0x00453530; `HandleDeleteObject` 0x00451EA0; `HandleObjDescEvent` 0x00453340; `UnpackPositionEvent` 0x004542C0; F754/F755 dispatch and pending replay pc:357214-357239 |
|
| AD-32 | Movement, Position, State, Vector, Pickup, Delete, and ObjDesc packets for a FUTURE object incarnation are dropped until its CreateObject arrives; retail queues each blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it after construction. F754/F755 and ParentEvent are no longer part of this divergence: `EntityEffectController` preserves one mixed effect FIFO per server GUID until the canonical local owner is ready, while runtime-owned `ParentAttachmentState` retains parent relations by generation. Older-incarnation state packets drop in both clients. | `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` (`TryAcceptInstance`); `src/AcDream.App/World/InboundPhysicsStateController.cs`; `src/AcDream.App/World/LiveEntityRuntime.cs`; effect exception `src/AcDream.App/Rendering/Vfx/EntityEffectController.cs`; Parent exception `src/AcDream.App/World/ParentAttachmentState.cs` | acdream has no general per-object mixed queue for non-effect state packets yet. Dropping those state families is generation-safe: a future packet cannot mutate the old body or advance its stamps, and the newer CreateObject wholesale-seeds all nine channels. The canonical runtime remains the seam for widening the queue later. | The first queued non-effect state can be absent until a later authoritative update; effect and Parent packets retain retail order and ownership | `SmartBox::HandleSetState` 0x004533E0; `HandleVectorUpdate` 0x00453480; `HandleParentEvent` 0x004535D0; `HandlePickupEvent` 0x00453530; `HandleDeleteObject` 0x00451EA0; `HandleObjDescEvent` 0x00453340; `UnpackPositionEvent` 0x004542C0; F754/F755 dispatch and pending replay pc:357214-357239 |
|
||||||
| AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) |
|
| AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) |
|
||||||
| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the −4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a |
|
| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the −4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a |
|
||||||
|
|
@ -172,7 +171,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
||||||
| ~~AP-66~~ | **RETIRED 2026-07-13 — authored paperdoll empty-slot presentation.** The earlier “no silhouettes” conclusion inspected the ItemList elements' own media but missed `UIElement_ItemList::InternalCreateItem`, which clones a distinct `UIElement_UIItem` catalog prototype for each location. All 21 supported jewelry, weapon, ammo, shield, clothing, cloak, trinket, and armor lists now resolve their exact `ItemSlot_Empty` surface from live DAT; `PostInit` confirms non-armor lists remain visible while the nine armor lists toggle with Slots. | `src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs`; `ItemListCellTemplate.cs`; `PaperdollController.cs` | — | — | `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`; `PostInit @ 0x004A5360`; `UIElement_ItemList::InternalCreateItem @ 0x004E3570`; `LayoutDesc 0x21000037` |
|
| ~~AP-66~~ | **RETIRED 2026-07-13 — authored paperdoll empty-slot presentation.** The earlier “no silhouettes” conclusion inspected the ItemList elements' own media but missed `UIElement_ItemList::InternalCreateItem`, which clones a distinct `UIElement_UIItem` catalog prototype for each location. All 21 supported jewelry, weapon, ammo, shield, clothing, cloak, trinket, and armor lists now resolve their exact `ItemSlot_Empty` surface from live DAT; `PostInit` confirms non-armor lists remain visible while the nine armor lists toggle with Slots. | `src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs`; `ItemListCellTemplate.cs`; `PaperdollController.cs` | — | — | `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`; `PostInit @ 0x004A5360`; `UIElement_ItemList::InternalCreateItem @ 0x004E3570`; `LayoutDesc 0x21000037` |
|
||||||
| AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` |
|
| AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` |
|
||||||
| AP-69 | On a landblock unload/reload, acdream retains the accepted live record and the same `WorldEntity`. Full unload moves non-persistent live projections to that landblock's pending bucket; Near→Far demotion drops only dat-static layers and leaves live projections resident. Neither path replays logical registration or reconstructs from a stale CreateObject. ACE will NOT re-broadcast objects whose guid remains in its per-player `KnownObjects` set, matching retail's retained object-table model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained record is pruned ONLY by an explicit server `DeleteObject` (0xF747) or session teardown. (#138) | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs`; `GameWindow.RehydrateServerEntitiesForLandblock`; `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Stable logical identity is retail-faithful and prevents duplicated meshes, scripts, emitters, or stale-spawn teleports while remaining independent of an ACE re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) can reappear at its last-known position, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | `CPhysicsObj::change_cell` 0x00513390; `CPhysicsObj::leave_world` 0x005155A0; ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS` |
|
| AP-69 | On a landblock unload/reload, acdream retains the accepted live record and the same `WorldEntity`. Full unload moves non-persistent live projections to that landblock's pending bucket; Near→Far demotion drops only dat-static layers and leaves live projections resident. Neither path replays logical registration or reconstructs from a stale CreateObject. ACE will NOT re-broadcast objects whose guid remains in its per-player `KnownObjects` set, matching retail's retained object-table model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained record is pruned ONLY by an explicit server `DeleteObject` (0xF747) or session teardown. (#138) | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs`; `GameWindow.RehydrateServerEntitiesForLandblock`; `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Stable logical identity is retail-faithful and prevents duplicated meshes, scripts, emitters, or stale-spawn teleports while remaining independent of an ACE re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) can reappear at its last-known position, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | `CPhysicsObj::change_cell` 0x00513390; `CPhysicsObj::leave_world` 0x005155A0; ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS` |
|
||||||
| AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) |
|
|
||||||
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 |
|
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 |
|
||||||
| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 |
|
| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 |
|
||||||
| AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now visible; only unmapped wording deviates, and those lines retain the raw code. Retire by porting the String-table lookup (#202). | An unmapped WeenieError shows a generic line instead of retail's exact sentence | retail String-table error lookup; ACE `WeenieError.cs` values |
|
| AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now visible; only unmapped wording deviates, and those lines retain the raw code. Retire by porting the String-table lookup (#202). | An unmapped WeenieError shows a generic line instead of retail's exact sentence | retail String-table error lookup; ACE `WeenieError.cs` values |
|
||||||
|
|
@ -215,6 +213,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` |
|
||||||
|
|
||||||
## 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)
|
||||||
|
|
||||||
|
|
@ -239,7 +238,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
||||||
| TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
|
| TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
|
||||||
| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
|
| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
|
||||||
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
|
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
|
||||||
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
|
| TS-28 | **NARROWED 2026-07-15** — F751 teleports now resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login still sends LoginComplete directly from the PlayerCreate (0xF746) handler and does not enter the portal-space presentation. | `src/AcDream.Core.Net/WorldSession.cs` (PlayerCreate branch); `src/AcDream.App/Rendering/GameWindow.cs` (F751 `FireLoginComplete`) | The live-session bootstrap currently needs the acknowledgement to unlock the initial authoritative object/property stream; moving initial login behind the App presentation requires an explicit session→presentation readiness contract rather than withholding it inside Core.Net | Initial login can expose server updates earlier than retail and skips the wormhole presentation; recalls/portals now have retail ordering | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` |
|
||||||
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
|
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
|
||||||
| TS-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
|
| TS-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
|
||||||
| TS-31 | **NARROWED 2026-07-13** — `/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
|
| TS-31 | **NARROWED 2026-07-13** — `/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,18 @@ loader when preloading emitter meshes. This is not a second DAT reader:
|
||||||
`CreateBlockingParticleHook::Execute` `0x00526EF0` and
|
`CreateBlockingParticleHook::Execute` `0x00526EF0` and
|
||||||
`ParticleManager::CreateBlockingParticleEmitter` `0x0051B8A0`.
|
`ParticleManager::CreateBlockingParticleEmitter` `0x0051B8A0`.
|
||||||
|
|
||||||
|
**Retail portal-space viewport adapter (2026-07-15).**
|
||||||
|
`src/AcDream.App/Rendering/PortalTunnelPresentation.cs` uses the extracted
|
||||||
|
Setup/GfxObj mesh pipeline and mandatory `WbDrawDispatcher` for retail's
|
||||||
|
synthetic CreatureMode tunnel object. The adapter does not duplicate mesh or
|
||||||
|
DAT decoding: it resolves the two client-enum assets through `DatCollection`,
|
||||||
|
uses the shared `RetailAnimationLoader`, registers Setup part refs through
|
||||||
|
`WbMeshAdapter`, and submits the animated `SetupMesh` through the existing
|
||||||
|
dispatcher. It clears world clip routing and point lights for the private scene
|
||||||
|
before installing retail's distant light. The lifecycle, camera, animation,
|
||||||
|
and draw ordering are acdream-owned ports of `gmSmartBoxUI`; WorldBuilder never
|
||||||
|
implemented this UI viewport.
|
||||||
|
|
||||||
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling
|
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling
|
||||||
algorithm, **check this inventory first**. If we already extracted it (🟢
|
algorithm, **check this inventory first**. If we already extracted it (🟢
|
||||||
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has
|
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has
|
||||||
|
|
|
||||||
|
|
@ -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 `LoginComplete` re-send, "pink bubble" loading state. 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`.
|
- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/fades; visual gate pending 2026-07-15). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,15 @@ recorded end-to-end dungeon round-trip user gate, plus the far-town
|
||||||
teleport-OUT arrival cascade residual (**#145-residual, REOPENED** —
|
teleport-OUT arrival cascade residual (**#145-residual, REOPENED** —
|
||||||
capture-harness-first).
|
capture-harness-first).
|
||||||
|
|
||||||
|
**2026-07-15 portal presentation completion (visual gate pending).** The
|
||||||
|
temporary black tunnel cover is retired. F751 transit now renders retail's
|
||||||
|
client-enum-resolved DAT Setup `0x02000306` with animation `0x030005AC` at
|
||||||
|
40 frames/s in a dedicated CreatureMode-equivalent viewport, using retail's
|
||||||
|
camera, distant light, random eased yaw, frame-aligned exit window, and exact
|
||||||
|
100-sample fade curve. The viewport and fades compose below retained UI, so
|
||||||
|
chat, panels, toolbar, cursor, and input remain live throughout travel. See
|
||||||
|
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### (historical M1.5 working notes below)
|
#### (historical M1.5 working notes below)
|
||||||
|
|
|
||||||
243
docs/research/2026-07-15-retail-portal-space-pseudocode.md
Normal file
243
docs/research/2026-07-15-retail-portal-space-pseudocode.md
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
# Retail portal-space presentation pseudocode
|
||||||
|
|
||||||
|
Date: 2026-07-15
|
||||||
|
|
||||||
|
Scope: the client-side 3-D scene and timing shown while a player is between
|
||||||
|
world positions. This is presentation only. The server remains authoritative
|
||||||
|
for teleport start, destination position, world readiness, and completion.
|
||||||
|
|
||||||
|
## Retail oracle
|
||||||
|
|
||||||
|
Named Sept-2013 retail symbols:
|
||||||
|
|
||||||
|
- `gmSmartBoxUI::BeginTeleportAnimation` `0x004D6300`
|
||||||
|
- `gmSmartBoxUI::EndTeleportAnimation` `0x004D65A0`
|
||||||
|
- `gmSmartBoxUI::PostInit` `0x004D6B60`
|
||||||
|
- `gmSmartBoxUI::UseTime` `0x004D6E30`
|
||||||
|
- `CreatureMode::CreatureMode` `0x00454390`
|
||||||
|
- `CreatureMode::SetCameraDirection_Degrees` `0x00453760`
|
||||||
|
- `CreatureMode::AddObject` `0x004556D0`
|
||||||
|
- `UIGlobals::Init` `0x004EE470`
|
||||||
|
- `UIGlobals::GetAnimLevel` `0x004EE540`
|
||||||
|
- `CPhysicsObj::set_sequence_animation` `0x0050F6F0`
|
||||||
|
- `SmartBox::TeleportPlayer` `0x00453910`
|
||||||
|
- `SmartBox::PlayerPositionUpdated` `0x00453870`
|
||||||
|
- `CPhysicsObj::teleport_hook` `0x00514ED0`
|
||||||
|
- `CommandInterpreter::PlayerTeleported` `0x006B32B0`
|
||||||
|
|
||||||
|
Constants were checked against static x86 disassembly. The Binary Ninja
|
||||||
|
pseudocode loses several x87 operands in `UseTime`; disassembly is authoritative
|
||||||
|
for those comparisons.
|
||||||
|
|
||||||
|
## Asset construction (`gmSmartBoxUI::PostInit`)
|
||||||
|
|
||||||
|
```text
|
||||||
|
portalSetupDid = ResolveClientEnum(enumValue=0x10000001, category=7)
|
||||||
|
teleportObj = CPhysicsObj.makeObject(portalSetupDid, noWeenie, makeParts)
|
||||||
|
|
||||||
|
portalViewport = child 0x10000436 as UIElement_Viewport
|
||||||
|
portalViewport.CreatureMode.AddObject(teleportObj)
|
||||||
|
portalViewport.CreatureMode.AddLight(DISTANT_LIGHT, intensity=2.0)
|
||||||
|
portalViewport.CreatureMode.SetLightDirection(0, (0.3, -1.9, 0.65))
|
||||||
|
portalViewport.CreatureMode.SetCameraPosition((0.24, -2.7, 0.88))
|
||||||
|
portalViewport.CreatureMode.UseSmartboxFOV()
|
||||||
|
```
|
||||||
|
|
||||||
|
`CreatureMode` starts with ambient light `(0.3, 0.3, 0.3)` and a 45-degree
|
||||||
|
default FOV, but `UseSmartboxFOV` makes this viewport inherit the active world
|
||||||
|
view's current FOV on every draw. Its identity camera frame looks along AC
|
||||||
|
`+Y`, with `+Z` up.
|
||||||
|
The portal viewport is a replacement for the world viewport. It is not a
|
||||||
|
full-screen UI overlay: normal retained UI continues to update and draw above
|
||||||
|
it.
|
||||||
|
|
||||||
|
## Begin/end and scene visibility
|
||||||
|
|
||||||
|
```text
|
||||||
|
BeginTeleportAnimation(requestedState):
|
||||||
|
if currentState == Off:
|
||||||
|
gameViewDistance = SmartBox.GetOverrideFovDistance()
|
||||||
|
currentViewDistance = gameViewDistance
|
||||||
|
clear rotation-segment state
|
||||||
|
currentState = requestedState
|
||||||
|
transitionStart = now
|
||||||
|
play centered UI sound Sound_UI_EnterPortal
|
||||||
|
|
||||||
|
EndTeleportAnimation():
|
||||||
|
if currentState != Off:
|
||||||
|
currentState = TunnelContinue
|
||||||
|
transitionStart = now
|
||||||
|
SmartBox.SetOverrideFovDistance(disabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
Portal/login/death transit begins directly in `Tunnel`. Logout begins in
|
||||||
|
`WorldFadeOut`. When a tunnel-family state first becomes visible:
|
||||||
|
|
||||||
|
```text
|
||||||
|
disable vivid target indicator
|
||||||
|
portalAnimDid = ResolveClientEnum(enumValue=0x10000002, category=7)
|
||||||
|
teleportObj.set_sequence_animation(
|
||||||
|
animation=portalAnimDid,
|
||||||
|
clearExisting=true,
|
||||||
|
lowFrame=1,
|
||||||
|
highFrame=end,
|
||||||
|
fps=40)
|
||||||
|
reset portal camera position to (0.24, -2.7, 0.88)
|
||||||
|
show portal viewport
|
||||||
|
hide world SmartBox viewport
|
||||||
|
```
|
||||||
|
|
||||||
|
At the end of `TunnelFadeOut`, retail restores the world view distance, hides
|
||||||
|
the portal viewport, shows the world viewport, clears the teleport object's
|
||||||
|
sequence animations, plays `Sound_UI_ExitPortal`, and enters `WorldFadeIn`.
|
||||||
|
|
||||||
|
## Camera motion
|
||||||
|
|
||||||
|
The camera rotates around AC's vertical `+Z` axis. Each segment is independent:
|
||||||
|
|
||||||
|
```text
|
||||||
|
if now >= rotationStart + rotationDuration:
|
||||||
|
currentAngle = previousEndAngle
|
||||||
|
rotationStart = now
|
||||||
|
rotationDuration = RandomUniform(0.6, 1.8) seconds
|
||||||
|
startAngle = currentAngle
|
||||||
|
endAngle = RandomUniform(0, 360) degrees
|
||||||
|
display "In Portal Space - Please Wait..."
|
||||||
|
else:
|
||||||
|
t = (now - rotationStart) / rotationDuration
|
||||||
|
level = UIGlobals.GetAnimLevel(t) / 1024
|
||||||
|
currentAngle = startAngle + (endAngle - startAngle) * level
|
||||||
|
|
||||||
|
CreatureMode.SetCameraDirection_Degrees((0, currentAngle, 0))
|
||||||
|
```
|
||||||
|
|
||||||
|
`UIGlobals.GetAnimLevel` is a 100-entry cumulative sine table, not a
|
||||||
|
polynomial smoothstep:
|
||||||
|
|
||||||
|
```text
|
||||||
|
for i in 0..99:
|
||||||
|
sample[i] = truncate(sin(i * pi / 99) * 1024)
|
||||||
|
total = sum(sample)
|
||||||
|
running = 0
|
||||||
|
for i in 0..99:
|
||||||
|
running += sample[i]
|
||||||
|
level[i] = (running << 10) / total // integer division, 0..1024
|
||||||
|
|
||||||
|
GetAnimLevel(t):
|
||||||
|
t = clamp(t, 0, 1)
|
||||||
|
index = floor(t * 99)
|
||||||
|
return level[index]
|
||||||
|
```
|
||||||
|
|
||||||
|
## State timing (`gmSmartBoxUI::UseTime`)
|
||||||
|
|
||||||
|
Golden data constants:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FadeTime = 1.0 seconds (0x007BD278)
|
||||||
|
MinContinue = 2.0 seconds (0x007BD268)
|
||||||
|
MaxContinue = 5.0 seconds (0x007BD270)
|
||||||
|
PortalFPS = 40 frames/s (0x007BD280)
|
||||||
|
EndFrame = 120
|
||||||
|
ExitWindowLow = FadeTime + 0.1 = 1.1 seconds
|
||||||
|
ExitWindowHigh = FadeTime + 0.3 = 1.3 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
WorldFadeOut:
|
||||||
|
after 1 second -> TunnelFadeIn
|
||||||
|
|
||||||
|
TunnelFadeIn:
|
||||||
|
after 1 second -> Tunnel
|
||||||
|
|
||||||
|
Tunnel:
|
||||||
|
hold until SmartBox teleport-in-progress clears
|
||||||
|
EndTeleportAnimation -> TunnelContinue
|
||||||
|
|
||||||
|
TunnelContinue:
|
||||||
|
elapsed = now - transitionStart
|
||||||
|
if elapsed >= 2 seconds:
|
||||||
|
remaining = unsigned(120 - teleportObj.currentFrame) / 40
|
||||||
|
if elapsed >= 5 seconds
|
||||||
|
or 1.1 < remaining < 1.3 seconds:
|
||||||
|
-> TunnelFadeOut
|
||||||
|
|
||||||
|
TunnelFadeOut:
|
||||||
|
after 1 second:
|
||||||
|
hide/clear portal scene
|
||||||
|
show world
|
||||||
|
play exit sound
|
||||||
|
-> WorldFadeIn
|
||||||
|
|
||||||
|
WorldFadeIn:
|
||||||
|
after 1 second:
|
||||||
|
send LoginComplete
|
||||||
|
clear teleport-in-progress
|
||||||
|
-> Off
|
||||||
|
```
|
||||||
|
|
||||||
|
The narrow remaining-time window makes the one-second tunnel fade finish at
|
||||||
|
the end of the 120-frame animation. The five-second ceiling is retail's escape
|
||||||
|
for a missed window.
|
||||||
|
|
||||||
|
World/tunnel fades use `GetAnimLevel(elapsed / FadeTime)`. The fade belongs
|
||||||
|
between the 3-D viewport and the retained UI. It must never cover or disable
|
||||||
|
the retained UI.
|
||||||
|
|
||||||
|
## Player placement boundary
|
||||||
|
|
||||||
|
```text
|
||||||
|
SmartBox.TeleportPlayer(position):
|
||||||
|
player.SetPositionSimple(position, slide=true)
|
||||||
|
PlayerPositionUpdated(isTeleport=true)
|
||||||
|
|
||||||
|
PlayerPositionUpdated(isTeleport=true):
|
||||||
|
clear position-update/teleport bookkeeping
|
||||||
|
player.teleport_hook()
|
||||||
|
commandInterpreter.PlayerTeleported()
|
||||||
|
move world viewer to player
|
||||||
|
update CellManager position
|
||||||
|
|
||||||
|
player.teleport_hook():
|
||||||
|
MovementManager.CancelMoveTo(error=0x3c)
|
||||||
|
PositionManager.UnStick()
|
||||||
|
PositionManager.StopInterpolating()
|
||||||
|
PositionManager.UnConstrain()
|
||||||
|
TargetManager.ClearTarget()
|
||||||
|
TargetManager.NotifyVoyeurOfEvent(Teleported)
|
||||||
|
report_collision_end(force=true)
|
||||||
|
|
||||||
|
PlayerTeleported():
|
||||||
|
SetAutoRun(false, send=true)
|
||||||
|
SendMovementEvent()
|
||||||
|
```
|
||||||
|
|
||||||
|
The retail teleport hook does not clear the character's animation sequence.
|
||||||
|
The arrival presentation is instead hidden by the portal scene and its final
|
||||||
|
world fade while the normal motion/update stream settles. Therefore acdream
|
||||||
|
must not add an arrival-only animation reset.
|
||||||
|
|
||||||
|
## acdream integration translation
|
||||||
|
|
||||||
|
- A dedicated App-layer portal presentation owns the synthetic portal object,
|
||||||
|
animation sequence, camera, scene light, resource references, and draw pass.
|
||||||
|
- The existing pure teleport state machine remains the lifecycle owner, but it
|
||||||
|
consumes the portal object's current frame for retail exit timing and uses
|
||||||
|
the exact table-driven easing curve.
|
||||||
|
- During tunnel states the portal scene replaces world pixels before retained
|
||||||
|
UI draws. The black fade also draws before retained UI. Input dispatch and
|
||||||
|
`RetailUiRuntime.Tick` continue unchanged.
|
||||||
|
- Destination residency remains acdream's asynchronous adaptation. It supplies
|
||||||
|
the same `EndTeleportAnimation` edge retail receives when its blocking cell
|
||||||
|
load completes; it does not alter presentation ordering.
|
||||||
|
- The portal object is synthetic and never enters `LiveEntityRuntime`, world
|
||||||
|
picking, collision, radar, or server GUID ownership.
|
||||||
|
|
||||||
|
## Cross-reference notes
|
||||||
|
|
||||||
|
- `references/WorldBuilder` supplies the extracted setup/GfxObj mesh and modern
|
||||||
|
draw pipeline used by the synthetic scene; it has no teleport lifecycle.
|
||||||
|
- Holtburger's network study (`docs/research/2026-05-10-holtburger-network-stack-study.md`)
|
||||||
|
corroborates the LoginComplete wire acknowledgement, but intentionally has no
|
||||||
|
retail UI/CreatureMode implementation. The named retail client is therefore
|
||||||
|
the presentation oracle.
|
||||||
|
|
@ -4,11 +4,10 @@ using Silk.NET.OpenGL;
|
||||||
namespace AcDream.App.Rendering;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fullscreen black quad at a given alpha — the teleport fade cover (retail-teleport
|
/// Fullscreen black quad at a given alpha for retail's world/portal viewport
|
||||||
/// spec §C, 2026-06-22). Drawn LAST in the frame (over the world + UI) so it covers
|
/// transitions. It is drawn after the active 3-D viewport but before retained
|
||||||
/// everything; the <see cref="AcDream.Core.World.TeleportAnimSequencer"/> drives the alpha
|
/// UI, matching <c>gmSmartBoxUI::UseTime</c>: the world or portal scene fades
|
||||||
/// (opaque-black through the transit, ramping the world back in on WorldFadeIn). The
|
/// while chat, toolbar, windows, and cursor remain fully visible.
|
||||||
/// authentic 3D portal-swirl is deferred — this black cover replaces it for now.
|
|
||||||
///
|
///
|
||||||
/// <para>Draws in NDC, so it needs no view/projection. Self-contained GL state
|
/// <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
|
/// (feedback_render_self_contained_gl_state): sets blend + disables depth, restores the
|
||||||
|
|
@ -85,8 +84,7 @@ void main() { FragColor = vec4(0.0, 0.0, 0.0, uAlpha); }";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Draw the fullscreen black cover at <paramref name="alpha"/> (0 = clear → no-op,
|
/// Draw the fullscreen black cover at <paramref name="alpha"/> (0 = clear → no-op,
|
||||||
/// 1 = opaque). Must be called LAST in the frame, after the world and UI, so it covers
|
/// 1 = opaque). Call after the active 3-D viewport and before retained UI.
|
||||||
/// everything on screen.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Draw(float alpha)
|
public void Draw(float alpha)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,8 @@ public sealed class GameWindow : IDisposable
|
||||||
// each frame on an indoor root (null on the outdoor root).
|
// each frame on an indoor root (null on the outdoor root).
|
||||||
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.FadeOverlay? _fadeOverlay; // teleport fade cover (spec C)
|
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,6 +2589,14 @@ public sealed class GameWindow : IDisposable
|
||||||
// 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);
|
_fadeOverlay = new AcDream.App.Rendering.FadeOverlay(_gl);
|
||||||
|
_portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.TryCreate(
|
||||||
|
_gl,
|
||||||
|
_dats!,
|
||||||
|
_animLoader!,
|
||||||
|
_hookRouter,
|
||||||
|
_wbDrawDispatcher,
|
||||||
|
_sceneLightingUbo!,
|
||||||
|
_wbMeshAdapter!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
||||||
|
|
@ -8872,9 +8881,9 @@ public sealed class GameWindow : IDisposable
|
||||||
// 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). The fade overlay
|
// continue + fades fires FireLoginComplete (regain control + ack). PortalTunnelPresentation
|
||||||
// is opaque black during the tunnel states (we render a fade, not the 3D swirl) and
|
// replaces the world viewport through the tunnel-family states; FadeOverlay transitions
|
||||||
// ramps the world back in on WorldFadeIn.
|
// between the two viewports below the retained UI.
|
||||||
if (_teleportInProgress)
|
if (_teleportInProgress)
|
||||||
{
|
{
|
||||||
bool haveDest = _pendingTeleportCell != 0u;
|
bool haveDest = _pendingTeleportCell != 0u;
|
||||||
|
|
@ -8889,8 +8898,14 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var (snap, evts) = _teleportAnim.Tick((float)dt, ready);
|
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
|
||||||
_teleportFadeAlpha = snap.ShowTunnel ? 1f : snap.FadeAlpha;
|
var (snap, evts) = _teleportAnim.Tick((float)dt, ready, tunnelFrame);
|
||||||
|
// 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)
|
||||||
{
|
{
|
||||||
|
|
@ -8904,6 +8919,12 @@ public sealed class GameWindow : IDisposable
|
||||||
_streamingController.PriorityRadius = 0;
|
_streamingController.PriorityRadius = 0;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case AcDream.Core.World.TeleportAnimEvent.EnterTunnel:
|
||||||
|
_portalTunnel?.Enter();
|
||||||
|
break;
|
||||||
|
case AcDream.Core.World.TeleportAnimEvent.PlayExitSound:
|
||||||
|
_portalTunnel?.Exit();
|
||||||
|
break;
|
||||||
case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete:
|
case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete:
|
||||||
if (_playerController is not null)
|
if (_playerController is not null)
|
||||||
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
|
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
|
||||||
|
|
@ -8916,10 +8937,14 @@ public sealed class GameWindow : IDisposable
|
||||||
_teleportFadeAlpha = 0f;
|
_teleportFadeAlpha = 0f;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// PlayEnterSound / EnterTunnel / PlayExitSound — audio polish deferred.
|
// The retained audio engine does not yet own the
|
||||||
|
// ClientUISystem sound-table enum path. Enter/exit
|
||||||
|
// visual edges are handled above.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_portalTunnel?.Tick((float)dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase K.1a — tick the input dispatcher so Hold-type bindings
|
// Phase K.1a — tick the input dispatcher so Hold-type bindings
|
||||||
|
|
@ -10455,6 +10480,16 @@ public sealed class GameWindow : IDisposable
|
||||||
SkipWorldGeometry: ;
|
SkipWorldGeometry: ;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
|
||||||
|
// CreatureMode portal-space viewport. This replacement scene and
|
||||||
|
// its black transition draw BEFORE retained UI, so chat, windows,
|
||||||
|
// cursor, and toolbar remain visible and interactive in transit.
|
||||||
|
_portalTunnel?.Draw(
|
||||||
|
_window!.Size.X,
|
||||||
|
_window.Size.Y,
|
||||||
|
_cameraController!.Active.Projection);
|
||||||
|
_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
|
||||||
// is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle).
|
// is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle).
|
||||||
|
|
@ -10481,11 +10516,6 @@ public sealed class GameWindow : IDisposable
|
||||||
_retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
|
_retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teleport fade cover (retail-teleport spec C). Drawn AFTER the world + retail UI so
|
|
||||||
// it covers them during a transit; the ImGui devtools below composite on top so they
|
|
||||||
// stay visible for debugging. Alpha is 0 outside a teleport → Draw is a no-op.
|
|
||||||
_fadeOverlay?.Draw(_teleportFadeAlpha);
|
|
||||||
|
|
||||||
// Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws
|
// Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws
|
||||||
// so ImGui composites on top. ImGuiController save/restores the
|
// so ImGui composites on top. ImGuiController save/restores the
|
||||||
// GL state it touches (blend, scissor, VAO, shader, texture); any
|
// GL state it touches (blend, scissor, VAO, shader, texture); any
|
||||||
|
|
@ -14649,6 +14679,8 @@ public sealed class GameWindow : IDisposable
|
||||||
_effectPoses.Clear();
|
_effectPoses.Clear();
|
||||||
}
|
}
|
||||||
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
||||||
|
_portalTunnel?.Dispose();
|
||||||
|
_portalTunnel = null;
|
||||||
_wbDrawDispatcher?.Dispose();
|
_wbDrawDispatcher?.Dispose();
|
||||||
_envCellRenderer?.Dispose(); // Phase A8
|
_envCellRenderer?.Dispose(); // Phase A8
|
||||||
_portalDepthMask?.Dispose(); // T1
|
_portalDepthMask?.Dispose(); // T1
|
||||||
|
|
|
||||||
53
src/AcDream.App/Rendering/PortalTunnelCamera.cs
Normal file
53
src/AcDream.App/Rendering/PortalTunnelCamera.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail portal-space camera from <c>gmSmartBoxUI::PostInit</c>
|
||||||
|
/// (<c>0x004D6D8A</c>) and <c>CreatureMode::SetCameraDirection_Degrees</c>
|
||||||
|
/// (<c>0x00453760</c>). Identity looks along AC +Y with +Z up; the animated
|
||||||
|
/// angle rotates that view around +Z.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PortalTunnelCamera : ICamera
|
||||||
|
{
|
||||||
|
public static readonly Vector3 RetailEye = new(0.24f, -2.7f, 0.88f);
|
||||||
|
|
||||||
|
public float DirectionDegrees { get; set; }
|
||||||
|
public float FovRadians { get; set; } = MathF.PI / 4f;
|
||||||
|
public float Near { get; set; } = 0.1f;
|
||||||
|
public float Far { get; set; } = 50f;
|
||||||
|
public float Aspect { get; set; } = 1f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>CreatureMode::UseSmartboxFOV</c> reads the active SmartBox
|
||||||
|
/// projection each draw. Recover its vertical field of view from that
|
||||||
|
/// perspective matrix while retaining this private scene's near/far planes.
|
||||||
|
/// </summary>
|
||||||
|
public void UseSmartBoxFov(Matrix4x4 smartBoxProjection)
|
||||||
|
{
|
||||||
|
float verticalScale = smartBoxProjection.M22;
|
||||||
|
if (!float.IsFinite(verticalScale) || verticalScale <= 0f)
|
||||||
|
return;
|
||||||
|
|
||||||
|
float fov = 2f * MathF.Atan(1f / verticalScale);
|
||||||
|
if (float.IsFinite(fov) && fov > 0f && fov < MathF.PI)
|
||||||
|
FovRadians = fov;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Matrix4x4 View
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
float radians = DirectionDegrees * (MathF.PI / 180f);
|
||||||
|
Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, radians);
|
||||||
|
Vector3 forward = Vector3.Transform(Vector3.UnitY, rotation);
|
||||||
|
return Matrix4x4.CreateLookAt(RetailEye, RetailEye + forward, Vector3.UnitZ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Matrix4x4 Projection => Matrix4x4.CreatePerspectiveFieldOfView(
|
||||||
|
FovRadians,
|
||||||
|
Aspect <= 0f ? 1f : Aspect,
|
||||||
|
Near,
|
||||||
|
Far);
|
||||||
|
}
|
||||||
363
src/AcDream.App/Rendering/PortalTunnelPresentation.cs
Normal file
363
src/AcDream.App/Rendering/PortalTunnelPresentation.cs
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.Content.Vfx;
|
||||||
|
using AcDream.Core.Lighting;
|
||||||
|
using AcDream.Core.Meshing;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The retail portal-space CreatureMode scene owned by
|
||||||
|
/// <c>gmSmartBoxUI</c> (<c>PostInit 0x004D6B60</c>,
|
||||||
|
/// <c>UseTime 0x004D6E30</c>). It is a synthetic DAT Setup animated at
|
||||||
|
/// 40 frames/second and drawn as a replacement 3-D viewport beneath the
|
||||||
|
/// retained gameplay UI.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PortalTunnelPresentation : IDisposable
|
||||||
|
{
|
||||||
|
public const uint SetupClientEnum = 0x10000001u;
|
||||||
|
public const uint AnimationClientEnum = 0x10000002u;
|
||||||
|
public const uint ClientEnumCategory = 7u;
|
||||||
|
|
||||||
|
private const uint SyntheticEntityId = 0xFFFF_FF01u;
|
||||||
|
private const uint SyntheticLandblockId = 0u;
|
||||||
|
private const float RotationDurationMin = 0.6f;
|
||||||
|
private const float RotationDurationMax = 1.8f;
|
||||||
|
|
||||||
|
private static readonly HashSet<uint> AnimatedIds = new() { SyntheticEntityId };
|
||||||
|
|
||||||
|
private readonly GL _gl;
|
||||||
|
private readonly WbDrawDispatcher _dispatcher;
|
||||||
|
private readonly SceneLightingUboBinding _lightUbo;
|
||||||
|
private readonly IWbMeshAdapter _meshAdapter;
|
||||||
|
private readonly Setup _setup;
|
||||||
|
private readonly uint _setupDid;
|
||||||
|
private readonly uint _animationDid;
|
||||||
|
private readonly CSequence _sequence;
|
||||||
|
private readonly PortalAnimationHookQueue _animationHooks;
|
||||||
|
private readonly WorldEntity _entity;
|
||||||
|
private readonly PortalTunnelCamera _camera = new();
|
||||||
|
private readonly Random _random;
|
||||||
|
private readonly Action<string>? _displayNotice;
|
||||||
|
private readonly uint[] _registeredGfxObjects;
|
||||||
|
|
||||||
|
private bool _visible;
|
||||||
|
private double _rotationElapsed;
|
||||||
|
private double _rotationDuration;
|
||||||
|
private float _rotationStartAngle;
|
||||||
|
private float _rotationEndAngle;
|
||||||
|
private float _rotationCurrentAngle;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
private PortalTunnelPresentation(
|
||||||
|
GL gl,
|
||||||
|
WbDrawDispatcher dispatcher,
|
||||||
|
SceneLightingUboBinding lightUbo,
|
||||||
|
IWbMeshAdapter meshAdapter,
|
||||||
|
Setup setup,
|
||||||
|
uint setupDid,
|
||||||
|
uint animationDid,
|
||||||
|
IAnimationLoader animationLoader,
|
||||||
|
IAnimationHookSink hookSink,
|
||||||
|
Random random,
|
||||||
|
Action<string>? displayNotice)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_lightUbo = lightUbo;
|
||||||
|
_meshAdapter = meshAdapter;
|
||||||
|
_setup = setup;
|
||||||
|
_setupDid = setupDid;
|
||||||
|
_animationDid = animationDid;
|
||||||
|
_sequence = new CSequence(animationLoader);
|
||||||
|
_animationHooks = new PortalAnimationHookQueue(hookSink, SyntheticEntityId);
|
||||||
|
_sequence.HookObj = _animationHooks;
|
||||||
|
_random = random;
|
||||||
|
_displayNotice = displayNotice;
|
||||||
|
|
||||||
|
_entity = new WorldEntity
|
||||||
|
{
|
||||||
|
Id = SyntheticEntityId,
|
||||||
|
SourceGfxObjOrSetupId = setupDid,
|
||||||
|
Position = Vector3.Zero,
|
||||||
|
Rotation = Quaternion.Identity,
|
||||||
|
MeshRefs = SetupMesh.Flatten(setup),
|
||||||
|
};
|
||||||
|
|
||||||
|
_registeredGfxObjects = setup.Parts
|
||||||
|
.Select(part => (uint)part)
|
||||||
|
.Where(id => id != 0u)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
foreach (uint gfxObjId in _registeredGfxObjects)
|
||||||
|
_meshAdapter.IncrementRefCount(gfxObjId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve retail's enum-mapped portal Setup and animation through the
|
||||||
|
/// installed DATs. Missing assets fail closed with an actionable message;
|
||||||
|
/// no substitute tunnel is fabricated.
|
||||||
|
/// </summary>
|
||||||
|
public static PortalTunnelPresentation? TryCreate(
|
||||||
|
GL gl,
|
||||||
|
DatCollection dats,
|
||||||
|
IAnimationLoader animationLoader,
|
||||||
|
IAnimationHookSink hookSink,
|
||||||
|
WbDrawDispatcher dispatcher,
|
||||||
|
SceneLightingUboBinding lightUbo,
|
||||||
|
IWbMeshAdapter meshAdapter,
|
||||||
|
Action<string>? displayNotice = null,
|
||||||
|
Random? random = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(gl);
|
||||||
|
ArgumentNullException.ThrowIfNull(dats);
|
||||||
|
ArgumentNullException.ThrowIfNull(animationLoader);
|
||||||
|
ArgumentNullException.ThrowIfNull(hookSink);
|
||||||
|
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||||
|
ArgumentNullException.ThrowIfNull(lightUbo);
|
||||||
|
ArgumentNullException.ThrowIfNull(meshAdapter);
|
||||||
|
|
||||||
|
uint setupDid = RetailDataIdResolver.Resolve(dats, SetupClientEnum, ClientEnumCategory);
|
||||||
|
uint animationDid = RetailDataIdResolver.Resolve(dats, AnimationClientEnum, ClientEnumCategory);
|
||||||
|
Setup? setup = setupDid == 0u ? null : dats.Get<Setup>(setupDid);
|
||||||
|
Animation? animation = animationDid == 0u
|
||||||
|
? null
|
||||||
|
: animationLoader.LoadAnimation(animationDid);
|
||||||
|
|
||||||
|
if (setup is null || animation is null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"[portal-space] retail DAT assets unavailable: "
|
||||||
|
+ $"setup=0x{setupDid:X8} ({(setup is null ? "missing" : "ok")}), "
|
||||||
|
+ $"animation=0x{animationDid:X8} ({(animation is null ? "missing" : "ok")})");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PortalTunnelPresentation(
|
||||||
|
gl,
|
||||||
|
dispatcher,
|
||||||
|
lightUbo,
|
||||||
|
meshAdapter,
|
||||||
|
setup,
|
||||||
|
setupDid,
|
||||||
|
animationDid,
|
||||||
|
animationLoader,
|
||||||
|
hookSink,
|
||||||
|
random ?? Random.Shared,
|
||||||
|
displayNotice);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsVisible => _visible;
|
||||||
|
public int CurrentAnimationFrame => _sequence.GetCurrFrameNumber();
|
||||||
|
public uint SetupDid => _setupDid;
|
||||||
|
public uint AnimationDid => _animationDid;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>set_sequence_animation(anim, clear=1, low=1, fps=40)</c>
|
||||||
|
/// on the edge where portal space becomes visible.
|
||||||
|
/// </summary>
|
||||||
|
public void Enter()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
_animationHooks.Clear();
|
||||||
|
_sequence.ClearAnimations();
|
||||||
|
_sequence.AppendAnimation(new AnimData
|
||||||
|
{
|
||||||
|
AnimId = (QualifiedDataId<Animation>)_animationDid,
|
||||||
|
LowFrame = 1,
|
||||||
|
HighFrame = -1,
|
||||||
|
Framerate = TeleportAnimSequencer.TunnelFramesPerSecond,
|
||||||
|
});
|
||||||
|
|
||||||
|
_rotationElapsed = 0.0;
|
||||||
|
_rotationDuration = 0.0;
|
||||||
|
_rotationStartAngle = 0f;
|
||||||
|
_rotationEndAngle = 0f;
|
||||||
|
_rotationCurrentAngle = 0f;
|
||||||
|
_camera.DirectionDegrees = 0f;
|
||||||
|
_visible = true;
|
||||||
|
RebuildPose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hide the CreatureMode viewport and clear its sequence.</summary>
|
||||||
|
public void Exit()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_visible = false;
|
||||||
|
_animationHooks.Clear();
|
||||||
|
_sequence.ClearAnimations();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Tick(float dt)
|
||||||
|
{
|
||||||
|
if (!_visible || dt < 0f)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_sequence.Update(dt, frame: null);
|
||||||
|
RebuildPose();
|
||||||
|
_animationHooks.Drain(Vector3.Zero);
|
||||||
|
TickRotation(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replace the already-rendered world viewport with retail portal space.
|
||||||
|
/// The caller then draws the fade and retained UI above this pass.
|
||||||
|
/// </summary>
|
||||||
|
public void Draw(int width, int height, Matrix4x4 smartBoxProjection)
|
||||||
|
{
|
||||||
|
if (!_visible || width <= 0 || height <= 0 || _entity.MeshRefs.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_camera.Aspect = width / (float)height;
|
||||||
|
_camera.UseSmartBoxFov(smartBoxProjection);
|
||||||
|
using var scope = new GLStateScope(_gl);
|
||||||
|
|
||||||
|
_gl.Viewport(0, 0, (uint)width, (uint)height);
|
||||||
|
_gl.Disable(EnableCap.ScissorTest);
|
||||||
|
_gl.ClearColor(0f, 0f, 0f, 1f);
|
||||||
|
_gl.ClearDepth(1.0);
|
||||||
|
_gl.DepthMask(true);
|
||||||
|
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||||
|
|
||||||
|
_gl.Enable(EnableCap.DepthTest);
|
||||||
|
_gl.DepthFunc(DepthFunction.Less);
|
||||||
|
_gl.Enable(EnableCap.CullFace);
|
||||||
|
_gl.CullFace(TriangleFace.Back);
|
||||||
|
_gl.FrontFace(FrontFaceDirection.Ccw);
|
||||||
|
_gl.Disable(EnableCap.Blend);
|
||||||
|
|
||||||
|
UploadRetailLight();
|
||||||
|
// The dispatcher is shared with the world pass. Portal space is its
|
||||||
|
// own CreatureMode scene: it has no world-cell clip routing and no
|
||||||
|
// world point lights, only the distant light installed above.
|
||||||
|
_dispatcher.ClearClipRouting();
|
||||||
|
_dispatcher.SetSceneLights(null);
|
||||||
|
|
||||||
|
var entries = new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>, IReadOnlyDictionary<uint, WorldEntity>?)[]
|
||||||
|
{
|
||||||
|
(SyntheticLandblockId,
|
||||||
|
new Vector3(-16f, -16f, -16f),
|
||||||
|
new Vector3(16f, 16f, 16f),
|
||||||
|
new WorldEntity[] { _entity },
|
||||||
|
null),
|
||||||
|
};
|
||||||
|
|
||||||
|
_dispatcher.Draw(
|
||||||
|
_camera,
|
||||||
|
entries,
|
||||||
|
frustum: null,
|
||||||
|
neverCullLandblockId: SyntheticLandblockId,
|
||||||
|
visibleCellIds: null,
|
||||||
|
animatedEntityIds: AnimatedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TickRotation(float dt)
|
||||||
|
{
|
||||||
|
_rotationElapsed += dt;
|
||||||
|
if (_rotationElapsed >= _rotationDuration)
|
||||||
|
{
|
||||||
|
_rotationCurrentAngle = _rotationEndAngle;
|
||||||
|
_rotationElapsed = 0.0;
|
||||||
|
_rotationDuration = NextDouble(RotationDurationMin, RotationDurationMax);
|
||||||
|
_rotationStartAngle = _rotationCurrentAngle;
|
||||||
|
_rotationEndAngle = (float)NextDouble(0.0, 360.0);
|
||||||
|
_displayNotice?.Invoke("In Portal Space - Please Wait...");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
float t = _rotationDuration <= 0.0
|
||||||
|
? 1f
|
||||||
|
: (float)(_rotationElapsed / _rotationDuration);
|
||||||
|
float level = TeleportAnimSequencer.GetRetailAnimationLevel(t) / 1024f;
|
||||||
|
_rotationCurrentAngle = _rotationStartAngle
|
||||||
|
+ ((_rotationEndAngle - _rotationStartAngle) * level);
|
||||||
|
}
|
||||||
|
|
||||||
|
_camera.DirectionDegrees = _rotationCurrentAngle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double NextDouble(double min, double max) => min + (_random.NextDouble() * (max - min));
|
||||||
|
|
||||||
|
private void RebuildPose()
|
||||||
|
{
|
||||||
|
AnimationFrame? frame = _sequence.GetCurrAnimframe();
|
||||||
|
_entity.MeshRefs = SetupMesh.Flatten(_setup, frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UploadRetailLight()
|
||||||
|
{
|
||||||
|
Vector3 direction = Vector3.Normalize(new Vector3(0.3f, -1.9f, 0.65f));
|
||||||
|
_lightUbo.Upload(new SceneLightingUbo
|
||||||
|
{
|
||||||
|
Light0 = new UboLight
|
||||||
|
{
|
||||||
|
PosAndKind = Vector4.Zero,
|
||||||
|
DirAndRange = new Vector4(direction, 1e9f),
|
||||||
|
ColorAndIntensity = new Vector4(1f, 1f, 1f, 2f),
|
||||||
|
ConeAngleEtc = Vector4.Zero,
|
||||||
|
},
|
||||||
|
CellAmbient = new Vector4(0.3f, 0.3f, 0.3f, 1f),
|
||||||
|
FogParams = new Vector4(1e9f, 1e9f, 0f, 0f),
|
||||||
|
FogColor = Vector4.Zero,
|
||||||
|
CameraAndTime = new Vector4(PortalTunnelCamera.RetailEye, 0f),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail stages animation hooks while advancing the sequence, then drains
|
||||||
|
/// them after the object's current pose has been committed. Portal space
|
||||||
|
/// has no live-world record, so it owns this small equivalent queue and
|
||||||
|
/// forwards its DAT-authored hooks through the shared hook router.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class PortalAnimationHookQueue : IAnimHookQueue
|
||||||
|
{
|
||||||
|
private readonly IAnimationHookSink _sink;
|
||||||
|
private readonly uint _ownerId;
|
||||||
|
private readonly List<AnimationHook> _pending = new();
|
||||||
|
|
||||||
|
public PortalAnimationHookQueue(IAnimationHookSink sink, uint ownerId)
|
||||||
|
{
|
||||||
|
_sink = sink;
|
||||||
|
_ownerId = ownerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddAnimHook(AnimationHook hook) => _pending.Add(hook);
|
||||||
|
|
||||||
|
public void AddAnimDoneHook()
|
||||||
|
{
|
||||||
|
// The tunnel animation is retail's cyclic tail, so AnimDone is
|
||||||
|
// not observable during the portal-space presentation.
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Drain(Vector3 worldPosition)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < _pending.Count; i++)
|
||||||
|
_sink.OnHook(_ownerId, worldPosition, _pending[i]);
|
||||||
|
_pending.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear() => _pending.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
Exit();
|
||||||
|
_animationHooks.Clear();
|
||||||
|
foreach (uint gfxObjId in _registeredGfxObjects)
|
||||||
|
_meshAdapter.DecrementRefCount(gfxObjId);
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,11 +26,11 @@ namespace AcDream.Core.Net.Messages;
|
||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Should be sent immediately after <c>CharacterEnterWorld</c> (0xF657)
|
/// Retail clients send it once the portal-space transition animation finishes.
|
||||||
/// completes the in-world transition. Retail clients send it once the
|
/// acdream's F751 teleport path now does the same through
|
||||||
/// portal-space transition animation finishes; we send it as soon as we
|
/// <c>TeleportAnimSequencer.FireLoginComplete</c>. Initial session bootstrap
|
||||||
/// flag the session <c>InWorld</c> because acdream doesn't have a portal-
|
/// still sends from the PlayerCreate handler; that remaining ordering gap is
|
||||||
/// space animation yet.
|
/// tracked as TS-28 in the divergence register.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class GameActionLoginComplete
|
public static class GameActionLoginComplete
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,14 @@ public sealed class TeleportAnimSequencer
|
||||||
public const float FadeTime = 1.0f;
|
public const float FadeTime = 1.0f;
|
||||||
public const float MinContinue = 2.0f;
|
public const float MinContinue = 2.0f;
|
||||||
public const float MaxContinue = 5.0f;
|
public const float MaxContinue = 5.0f;
|
||||||
|
public const float TunnelFramesPerSecond = 40.0f;
|
||||||
|
public const int TunnelEndFrame = 120;
|
||||||
|
public const float ExitWindowLow = FadeTime + 0.1f;
|
||||||
|
public const float ExitWindowHigh = FadeTime + 0.3f;
|
||||||
|
|
||||||
|
// UIGlobals::Init @ 0x004EE470. Retail integrates 100 integer sine
|
||||||
|
// samples into a 0..1024 easing table.
|
||||||
|
private static readonly short[] RetailAnimationLevels = BuildRetailAnimationLevels();
|
||||||
|
|
||||||
private TeleportAnimState _state = TeleportAnimState.Off;
|
private TeleportAnimState _state = TeleportAnimState.Off;
|
||||||
private float _elapsed = 0f; // time in current state
|
private float _elapsed = 0f; // time in current state
|
||||||
|
|
@ -80,7 +88,7 @@ public sealed class TeleportAnimSequencer
|
||||||
/// Returns the current snapshot + edge-triggered events fired THIS tick.
|
/// Returns the current snapshot + edge-triggered events fired THIS tick.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
|
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
|
||||||
Tick(float dt, bool worldReady)
|
Tick(float dt, bool worldReady, int tunnelAnimationFrame = 72)
|
||||||
{
|
{
|
||||||
var evts = new List<TeleportAnimEvent>();
|
var evts = new List<TeleportAnimEvent>();
|
||||||
|
|
||||||
|
|
@ -116,10 +124,15 @@ public sealed class TeleportAnimSequencer
|
||||||
|
|
||||||
case TeleportAnimState.TunnelContinue:
|
case TeleportAnimState.TunnelContinue:
|
||||||
_continueElapsed += dt;
|
_continueElapsed += dt;
|
||||||
// worldReady stays true once the landblock loads, so this advances at the 2s
|
// gmSmartBoxUI::UseTime @ 0x004D7264: after the two-second
|
||||||
// minimum in the normal case; gating min-advance on worldReady keeps the
|
// minimum, start the one-second fade only while the portal
|
||||||
// MaxContinue safety net authoritative if readiness ever regresses mid-continue.
|
// animation has 1.1..1.3 seconds left. Retail performs the
|
||||||
bool minMet = worldReady && _continueElapsed >= MinContinue;
|
// frame subtraction as unsigned arithmetic.
|
||||||
|
uint remainingFrames = unchecked((uint)TunnelEndFrame - (uint)tunnelAnimationFrame);
|
||||||
|
float remainingSeconds = remainingFrames / TunnelFramesPerSecond;
|
||||||
|
bool minMet = _continueElapsed >= MinContinue
|
||||||
|
&& remainingSeconds > ExitWindowLow
|
||||||
|
&& remainingSeconds < ExitWindowHigh;
|
||||||
bool maxForce = _continueElapsed >= MaxContinue;
|
bool maxForce = _continueElapsed >= MaxContinue;
|
||||||
if (minMet || maxForce)
|
if (minMet || maxForce)
|
||||||
Advance(TeleportAnimState.TunnelFadeOut, enterTunnel: false);
|
Advance(TeleportAnimState.TunnelFadeOut, enterTunnel: false);
|
||||||
|
|
@ -167,27 +180,60 @@ public sealed class TeleportAnimSequencer
|
||||||
return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait);
|
return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float Smoothstep(float t)
|
/// <summary>
|
||||||
|
/// Retail <c>UIGlobals::GetAnimLevel</c> (<c>0x004EE540</c>), returning
|
||||||
|
/// an integer level in the inclusive range 0..1024.
|
||||||
|
/// </summary>
|
||||||
|
public static short GetRetailAnimationLevel(float t)
|
||||||
{
|
{
|
||||||
t = Math.Clamp(t, 0f, 1f);
|
t = Math.Clamp(t, 0f, 1f);
|
||||||
return t * t * (3f - 2f * t);
|
// x87 _ftol2 truncates -99*t toward zero, then retail subtracts the
|
||||||
|
// resulting negative word offset from the table base.
|
||||||
|
int negativeIndex = (int)Math.Truncate(-99.0 * t);
|
||||||
|
return RetailAnimationLevels[-negativeIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed)
|
private static float ComputeFadeAlpha(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;
|
||||||
return state switch
|
return state switch
|
||||||
{
|
{
|
||||||
// Fading TO black (alpha 0→1):
|
// Fading TO black (alpha 0→1):
|
||||||
TeleportAnimState.WorldFadeOut => Smoothstep(t),
|
TeleportAnimState.WorldFadeOut => level,
|
||||||
TeleportAnimState.TunnelFadeIn => 1f - Smoothstep(t), // tunnel fades IN: overlay goes clear
|
TeleportAnimState.TunnelFadeIn => 1f - level, // tunnel fades IN: overlay goes clear
|
||||||
// Full black / fully clear inside tunnel states:
|
// Full black / fully clear inside tunnel states:
|
||||||
TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed
|
TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed
|
||||||
TeleportAnimState.TunnelContinue => 0f,
|
TeleportAnimState.TunnelContinue => 0f,
|
||||||
// Fading back out of tunnel:
|
// Fading back out of tunnel:
|
||||||
TeleportAnimState.TunnelFadeOut => Smoothstep(t), // tunnel fades out: overlay goes black
|
TeleportAnimState.TunnelFadeOut => level, // tunnel fades out: overlay goes black
|
||||||
TeleportAnimState.WorldFadeIn => 1f - Smoothstep(t), // world fades in: overlay clears
|
TeleportAnimState.WorldFadeIn => 1f - level, // world fades in: overlay clears
|
||||||
_ => 0f,
|
_ => 0f,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static short[] BuildRetailAnimationLevels()
|
||||||
|
{
|
||||||
|
const double retailPi = 3.1415920000000002d;
|
||||||
|
const double reciprocal99 = 0.010101010101010102d;
|
||||||
|
const double scale = 1024.0d;
|
||||||
|
|
||||||
|
var samples = new short[100];
|
||||||
|
int total = 0;
|
||||||
|
for (int i = 0; i < samples.Length; i++)
|
||||||
|
{
|
||||||
|
short sample = (short)Math.Truncate(Math.Sin(i * retailPi * reciprocal99) * scale);
|
||||||
|
samples[i] = sample;
|
||||||
|
total += sample;
|
||||||
|
}
|
||||||
|
|
||||||
|
int running = 0;
|
||||||
|
for (int i = 0; i < samples.Length; i++)
|
||||||
|
{
|
||||||
|
running += samples[i];
|
||||||
|
samples[i] = (short)((running << 10) / total);
|
||||||
|
}
|
||||||
|
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
116
tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
Normal file
116
tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.Content.Vfx;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit.Sdk;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class PortalTunnelAssetTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void InstalledDat_ResolvesRetailPortalSetupAndAnimation()
|
||||||
|
{
|
||||||
|
string? datDir = ResolveDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
throw SkipException.ForSkip(
|
||||||
|
"Installed client_portal.dat is required for the portal-space asset gate.");
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
uint setupDid = RetailDataIdResolver.Resolve(
|
||||||
|
dats,
|
||||||
|
PortalTunnelPresentation.SetupClientEnum,
|
||||||
|
PortalTunnelPresentation.ClientEnumCategory);
|
||||||
|
uint animationDid = RetailDataIdResolver.Resolve(
|
||||||
|
dats,
|
||||||
|
PortalTunnelPresentation.AnimationClientEnum,
|
||||||
|
PortalTunnelPresentation.ClientEnumCategory);
|
||||||
|
|
||||||
|
Assert.NotEqual(0u, setupDid);
|
||||||
|
Assert.NotEqual(0u, animationDid);
|
||||||
|
Assert.Equal(0x02000306u, setupDid);
|
||||||
|
Assert.Equal(0x030005ACu, animationDid);
|
||||||
|
Setup setup = Assert.IsType<Setup>(dats.Get<Setup>(setupDid));
|
||||||
|
Animation animation = Assert.IsType<Animation>(
|
||||||
|
new RetailAnimationLoader(dats).LoadAnimation(animationDid));
|
||||||
|
|
||||||
|
Assert.NotEmpty(setup.Parts);
|
||||||
|
Assert.True(
|
||||||
|
animation.PartFrames.Count >= TeleportAnimSequencer.TunnelEndFrame,
|
||||||
|
$"Retail tunnel timing samples frame {TeleportAnimSequencer.TunnelEndFrame}, "
|
||||||
|
+ $"but animation 0x{animationDid:X8} has only {animation.PartFrames.Count} frames.");
|
||||||
|
Assert.All(
|
||||||
|
setup.Parts,
|
||||||
|
part => Assert.Equal(0x01u, ((uint)part >> 24) & 0xFFu));
|
||||||
|
Assert.Equal(0u, (uint)setup.DefaultAnimation);
|
||||||
|
Assert.Equal(0u, (uint)setup.DefaultScript);
|
||||||
|
Assert.Equal(0u, (uint)setup.DefaultScriptTable);
|
||||||
|
var hook = Assert.Single(animation.PartFrames.SelectMany(frame => frame.Hooks));
|
||||||
|
Assert.Equal(DatReaderWriter.Enums.AnimationHookType.SoundTweaked, hook.HookType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PortalTunnelCamera_UsesRetailEyeAndRotatesAroundAcUpAxis()
|
||||||
|
{
|
||||||
|
var camera = new PortalTunnelCamera
|
||||||
|
{
|
||||||
|
DirectionDegrees = 0f,
|
||||||
|
Aspect = 1f,
|
||||||
|
};
|
||||||
|
Assert.True(System.Numerics.Matrix4x4.Invert(camera.View, out var world));
|
||||||
|
Assert.Equal(PortalTunnelCamera.RetailEye.X, world.Translation.X, precision: 5);
|
||||||
|
Assert.Equal(PortalTunnelCamera.RetailEye.Y, world.Translation.Y, precision: 5);
|
||||||
|
Assert.Equal(PortalTunnelCamera.RetailEye.Z, world.Translation.Z, precision: 5);
|
||||||
|
|
||||||
|
camera.DirectionDegrees = 90f;
|
||||||
|
Assert.True(System.Numerics.Matrix4x4.Invert(camera.View, out var rotatedWorld));
|
||||||
|
System.Numerics.Vector3 up = System.Numerics.Vector3.Normalize(
|
||||||
|
System.Numerics.Vector3.TransformNormal(System.Numerics.Vector3.UnitY, rotatedWorld));
|
||||||
|
Assert.Equal(0f, up.X, precision: 5);
|
||||||
|
Assert.Equal(0f, up.Y, precision: 5);
|
||||||
|
Assert.Equal(1f, up.Z, precision: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(45f)]
|
||||||
|
[InlineData(60f)]
|
||||||
|
[InlineData(90f)]
|
||||||
|
public void PortalTunnelCamera_UsesActiveSmartBoxFieldOfView(float degrees)
|
||||||
|
{
|
||||||
|
float expected = degrees * (MathF.PI / 180f);
|
||||||
|
var smartBoxProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
|
||||||
|
expected,
|
||||||
|
16f / 9f,
|
||||||
|
0.1f,
|
||||||
|
5000f);
|
||||||
|
var camera = new PortalTunnelCamera();
|
||||||
|
|
||||||
|
camera.UseSmartBoxFov(smartBoxProjection);
|
||||||
|
|
||||||
|
Assert.Equal(expected, camera.FovRadians, precision: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveDatDir()
|
||||||
|
{
|
||||||
|
string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||||
|
if (!string.IsNullOrWhiteSpace(configured)
|
||||||
|
&& File.Exists(Path.Combine(configured, "client_portal.dat")))
|
||||||
|
return configured;
|
||||||
|
|
||||||
|
const string installed = @"C:\Turbine\Asheron's Call";
|
||||||
|
if (File.Exists(Path.Combine(installed, "client_portal.dat")))
|
||||||
|
return installed;
|
||||||
|
|
||||||
|
string conventional = Path.Combine(
|
||||||
|
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
|
||||||
|
"Documents",
|
||||||
|
"Asheron's Call");
|
||||||
|
return File.Exists(Path.Combine(conventional, "client_portal.dat"))
|
||||||
|
? conventional
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -73,7 +73,12 @@ public sealed class TeleportAnimSequencerTests
|
||||||
|
|
||||||
// Helper: drive the sequencer forward by total elapsed time using small fixed steps.
|
// Helper: drive the sequencer forward by total elapsed time using small fixed steps.
|
||||||
private static (TeleportAnimSnapshot snap, List<TeleportAnimEvent> allEvents)
|
private static (TeleportAnimSnapshot snap, List<TeleportAnimEvent> allEvents)
|
||||||
DriveSeconds(TeleportAnimSequencer seq, float seconds, bool worldReady, float step = 0.016f)
|
DriveSeconds(
|
||||||
|
TeleportAnimSequencer seq,
|
||||||
|
float seconds,
|
||||||
|
bool worldReady,
|
||||||
|
float step = 0.016f,
|
||||||
|
int tunnelAnimationFrame = 72)
|
||||||
{
|
{
|
||||||
var allEvts = new List<TeleportAnimEvent>();
|
var allEvts = new List<TeleportAnimEvent>();
|
||||||
float remaining = seconds;
|
float remaining = seconds;
|
||||||
|
|
@ -81,7 +86,7 @@ public sealed class TeleportAnimSequencerTests
|
||||||
while (remaining > 0f)
|
while (remaining > 0f)
|
||||||
{
|
{
|
||||||
float dt = Math.Min(step, remaining);
|
float dt = Math.Min(step, remaining);
|
||||||
var (snap, evts) = seq.Tick(dt, worldReady);
|
var (snap, evts) = seq.Tick(dt, worldReady, tunnelAnimationFrame);
|
||||||
last = snap;
|
last = snap;
|
||||||
allEvts.AddRange(evts);
|
allEvts.AddRange(evts);
|
||||||
remaining -= dt;
|
remaining -= dt;
|
||||||
|
|
@ -193,11 +198,54 @@ public sealed class TeleportAnimSequencerTests
|
||||||
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
||||||
|
|
||||||
// Drive MaxContinue + ε with worldReady=false (simulating "world never loaded")
|
// Drive MaxContinue + ε with worldReady=false (simulating "world never loaded")
|
||||||
DriveSeconds(seq, TeleportAnimSequencer.MaxContinue + 0.05f, worldReady: false);
|
DriveSeconds(
|
||||||
|
seq,
|
||||||
|
TeleportAnimSequencer.MaxContinue + 0.05f,
|
||||||
|
worldReady: false,
|
||||||
|
tunnelAnimationFrame: 0);
|
||||||
|
|
||||||
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
|
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(68, false)] // retail uses an open interval at 1.30 seconds
|
||||||
|
[InlineData(69, true)] // 1.275 seconds remaining
|
||||||
|
[InlineData(72, true)] // 1.20 seconds remaining
|
||||||
|
[InlineData(75, true)] // 1.125 seconds remaining
|
||||||
|
[InlineData(76, false)] // retail uses an open interval at 1.10 seconds
|
||||||
|
[InlineData(77, false)] // 1.075 seconds remaining
|
||||||
|
[InlineData(121, false)] // retail unsigned subtraction after the endpoint
|
||||||
|
public void TunnelContinue_UsesRetailAnimationExitWindow(int frame, bool shouldFade)
|
||||||
|
{
|
||||||
|
var seq = new TeleportAnimSequencer();
|
||||||
|
seq.Begin(TeleportEntryKind.Portal);
|
||||||
|
seq.Tick(0f, worldReady: false, tunnelAnimationFrame: frame);
|
||||||
|
seq.Tick(0.016f, worldReady: true, tunnelAnimationFrame: frame);
|
||||||
|
|
||||||
|
DriveSeconds(
|
||||||
|
seq,
|
||||||
|
TeleportAnimSequencer.MinContinue + 0.01f,
|
||||||
|
worldReady: true,
|
||||||
|
tunnelAnimationFrame: frame);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
shouldFade ? TeleportAnimState.TunnelFadeOut : TeleportAnimState.TunnelContinue,
|
||||||
|
seq.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.00f, 0)]
|
||||||
|
[InlineData(0.01f, 0)]
|
||||||
|
[InlineData(0.25f, 146)]
|
||||||
|
[InlineData(0.50f, 512)]
|
||||||
|
[InlineData(0.75f, 877)]
|
||||||
|
[InlineData(0.99f, 1024)]
|
||||||
|
[InlineData(1.00f, 1024)]
|
||||||
|
public void RetailAnimationLevel_MatchesGoldenTable(float t, short expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, TeleportAnimSequencer.GetRetailAnimationLevel(t));
|
||||||
|
}
|
||||||
|
|
||||||
// --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event ---
|
// --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -245,7 +293,7 @@ public sealed class TeleportAnimSequencerTests
|
||||||
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: smoothstep(0)=0 => alpha=0 (world fully visible).
|
// At elapsed=0 in WorldFadeOut: retail animation level 0 => fully visible.
|
||||||
Assert.Equal(0f, snap.FadeAlpha, precision: 4);
|
Assert.Equal(0f, snap.FadeAlpha, precision: 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,7 +309,7 @@ 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);
|
||||||
// smoothstep(clamp((1.0-0.02)/1.0,0,1)) should be close to 1
|
// The retail animation table should be close to fully opaque here.
|
||||||
Assert.True(snap.FadeAlpha > 0.95f, $"Expected FadeAlpha near 1, got {snap.FadeAlpha}");
|
Assert.True(snap.FadeAlpha > 0.95f, $"Expected FadeAlpha near 1, got {snap.FadeAlpha}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue