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:
Erik 2026-07-15 21:59:23 +02:00
parent 0eab7497c1
commit eab23cbdd1
14 changed files with 983 additions and 45 deletions

View file

@ -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

View file

@ -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:309573309597 | | 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:309573309597 |
| 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 1115) + 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 1115) + 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 |

View file

@ -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

View file

@ -593,7 +593,7 @@ Research: R9 + R12 + R13.
- **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys. - **✓ SHIPPED — G.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.

View file

@ -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)

View 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.

View file

@ -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)
{ {

View file

@ -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

View 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);
}

View 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;
}
}

View file

@ -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

View file

@ -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;
}
} }

View 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;
}
}

View file

@ -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}");
} }