diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 3c210847..51c73089 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -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 (`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 The old R1-R8 architecture sequence was a useful early refactor sketch, but it diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a90f708e..90fd320d 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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 | |---|---|---|---|---|---| @@ -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-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-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-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`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-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-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) | | AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 | | AP-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 | @@ -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-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) @@ -239,7 +238,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 | | TS-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-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-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 | diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index 1a97d18b..ccbae299 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -113,6 +113,18 @@ loader when preloading emitter meshes. This is not a second DAT reader: `CreateBlockingParticleHook::Execute` `0x00526EF0` and `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 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 diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index d66ede28..924ab690 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -593,7 +593,7 @@ Research: R9 + R12 + R13. - **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys. - **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload. - **Indoor portal-based cell tracking (follow-up to Indoor walking Phase 1 / issue #87).** Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's `CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses a cell portal boundary, `CellId` propagates through the `CEnvCell` portal connectivity graph. Prerequisite for wall collision from outside (#85) and the remaining #84 threshold symptom. PDB symbols and `acclient.h` `CCellStructure` refs are in place (see #87). **Unblocks G.3.** -- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `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. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 78f821d1..453ada72 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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** — 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) diff --git a/docs/research/2026-07-15-retail-portal-space-pseudocode.md b/docs/research/2026-07-15-retail-portal-space-pseudocode.md new file mode 100644 index 00000000..3c44bf30 --- /dev/null +++ b/docs/research/2026-07-15-retail-portal-space-pseudocode.md @@ -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. diff --git a/src/AcDream.App/Rendering/FadeOverlay.cs b/src/AcDream.App/Rendering/FadeOverlay.cs index caca95fd..085080e4 100644 --- a/src/AcDream.App/Rendering/FadeOverlay.cs +++ b/src/AcDream.App/Rendering/FadeOverlay.cs @@ -4,11 +4,10 @@ using Silk.NET.OpenGL; namespace AcDream.App.Rendering; /// -/// Fullscreen black quad at a given alpha — the teleport fade cover (retail-teleport -/// spec §C, 2026-06-22). Drawn LAST in the frame (over the world + UI) so it covers -/// everything; the drives the alpha -/// (opaque-black through the transit, ramping the world back in on WorldFadeIn). The -/// authentic 3D portal-swirl is deferred — this black cover replaces it for now. +/// Fullscreen black quad at a given alpha for retail's world/portal viewport +/// transitions. It is drawn after the active 3-D viewport but before retained +/// UI, matching gmSmartBoxUI::UseTime: the world or portal scene fades +/// while chat, toolbar, windows, and cursor remain fully visible. /// /// Draws in NDC, so it needs no view/projection. Self-contained GL state /// (feedback_render_self_contained_gl_state): sets blend + disables depth, restores the @@ -85,8 +84,7 @@ void main() { FragColor = vec4(0.0, 0.0, 0.0, uAlpha); }"; /// /// Draw the fullscreen black cover at (0 = clear → no-op, - /// 1 = opaque). Must be called LAST in the frame, after the world and UI, so it covers - /// everything on screen. + /// 1 = opaque). Call after the active 3-D viewport and before retained UI. /// public void Draw(float alpha) { diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1d3d0a48..50faf792 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -221,7 +221,8 @@ public sealed class GameWindow : IDisposable // each frame on an indoor root (null on the outdoor root). private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; 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; // 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). _portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_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) @@ -8872,9 +8881,9 @@ public sealed class GameWindow : IDisposable // destination landblock this frame) and the live-session drain, so a destination // that became resident this frame materializes the same frame. The TAS holds at // Tunnel until worldReady, fires Place (materialize, hidden), then after the min- - // continue + fades fires FireLoginComplete (regain control + ack). The fade overlay - // is opaque black during the tunnel states (we render a fade, not the 3D swirl) and - // ramps the world back in on WorldFadeIn. + // continue + fades fires FireLoginComplete (regain control + ack). PortalTunnelPresentation + // replaces the world viewport through the tunnel-family states; FadeOverlay transitions + // between the two viewports below the retained UI. if (_teleportInProgress) { bool haveDest = _pendingTeleportCell != 0u; @@ -8889,8 +8898,14 @@ public sealed class GameWindow : IDisposable } } - var (snap, evts) = _teleportAnim.Tick((float)dt, ready); - _teleportFadeAlpha = snap.ShowTunnel ? 1f : snap.FadeAlpha; + int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0; + 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) { @@ -8904,6 +8919,12 @@ public sealed class GameWindow : IDisposable _streamingController.PriorityRadius = 0; } 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: if (_playerController is not null) _playerController.State = AcDream.App.Input.PlayerState.InWorld; @@ -8916,10 +8937,14 @@ public sealed class GameWindow : IDisposable _teleportFadeAlpha = 0f; break; 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; } } + + _portalTunnel?.Tick((float)dt); } // Phase K.1a — tick the input dispatcher so Hold-type bindings @@ -10455,6 +10480,16 @@ public sealed class GameWindow : IDisposable 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 // 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). @@ -10481,11 +10516,6 @@ public sealed class GameWindow : IDisposable _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 // so ImGui composites on top. ImGuiController save/restores the // GL state it touches (blend, scissor, VAO, shader, texture); any @@ -14649,6 +14679,8 @@ public sealed class GameWindow : IDisposable _effectPoses.Clear(); } _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context + _portalTunnel?.Dispose(); + _portalTunnel = null; _wbDrawDispatcher?.Dispose(); _envCellRenderer?.Dispose(); // Phase A8 _portalDepthMask?.Dispose(); // T1 diff --git a/src/AcDream.App/Rendering/PortalTunnelCamera.cs b/src/AcDream.App/Rendering/PortalTunnelCamera.cs new file mode 100644 index 00000000..f6d54eb6 --- /dev/null +++ b/src/AcDream.App/Rendering/PortalTunnelCamera.cs @@ -0,0 +1,53 @@ +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Retail portal-space camera from gmSmartBoxUI::PostInit +/// (0x004D6D8A) and CreatureMode::SetCameraDirection_Degrees +/// (0x00453760). Identity looks along AC +Y with +Z up; the animated +/// angle rotates that view around +Z. +/// +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; + + /// + /// Retail CreatureMode::UseSmartboxFOV 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. + /// + 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); +} diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs new file mode 100644 index 00000000..73aaaa29 --- /dev/null +++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs @@ -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; + +/// +/// The retail portal-space CreatureMode scene owned by +/// gmSmartBoxUI (PostInit 0x004D6B60, +/// UseTime 0x004D6E30). It is a synthetic DAT Setup animated at +/// 40 frames/second and drawn as a replacement 3-D viewport beneath the +/// retained gameplay UI. +/// +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 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? _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? 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); + } + + /// + /// 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. + /// + public static PortalTunnelPresentation? TryCreate( + GL gl, + DatCollection dats, + IAnimationLoader animationLoader, + IAnimationHookSink hookSink, + WbDrawDispatcher dispatcher, + SceneLightingUboBinding lightUbo, + IWbMeshAdapter meshAdapter, + Action? 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(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; + + /// + /// Retail set_sequence_animation(anim, clear=1, low=1, fps=40) + /// on the edge where portal space becomes visible. + /// + public void Enter() + { + ThrowIfDisposed(); + _animationHooks.Clear(); + _sequence.ClearAnimations(); + _sequence.AppendAnimation(new AnimData + { + AnimId = (QualifiedDataId)_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(); + } + + /// Hide the CreatureMode viewport and clear its sequence. + 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); + } + + /// + /// Replace the already-rendered world viewport with retail portal space. + /// The caller then draws the fade and retained UI above this pass. + /// + 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, IReadOnlyDictionary?)[] + { + (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); + + /// + /// 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. + /// + private sealed class PortalAnimationHookQueue : IAnimHookQueue + { + private readonly IAnimationHookSink _sink; + private readonly uint _ownerId; + private readonly List _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; + } +} diff --git a/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs b/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs index 9d9c7feb..84dc5313 100644 --- a/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs +++ b/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs @@ -26,11 +26,11 @@ namespace AcDream.Core.Net.Messages; /// /// /// -/// Should be sent immediately after CharacterEnterWorld (0xF657) -/// completes the in-world transition. Retail clients send it once the -/// portal-space transition animation finishes; we send it as soon as we -/// flag the session InWorld because acdream doesn't have a portal- -/// space animation yet. +/// Retail clients send it once the portal-space transition animation finishes. +/// acdream's F751 teleport path now does the same through +/// TeleportAnimSequencer.FireLoginComplete. Initial session bootstrap +/// still sends from the PlayerCreate handler; that remaining ordering gap is +/// tracked as TS-28 in the divergence register. /// /// public static class GameActionLoginComplete diff --git a/src/AcDream.Core/World/TeleportAnimSequencer.cs b/src/AcDream.Core/World/TeleportAnimSequencer.cs index 4280ea73..3042a07d 100644 --- a/src/AcDream.Core/World/TeleportAnimSequencer.cs +++ b/src/AcDream.Core/World/TeleportAnimSequencer.cs @@ -49,6 +49,14 @@ public sealed class TeleportAnimSequencer public const float FadeTime = 1.0f; public const float MinContinue = 2.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 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. /// public (TeleportAnimSnapshot snapshot, IReadOnlyList events) - Tick(float dt, bool worldReady) + Tick(float dt, bool worldReady, int tunnelAnimationFrame = 72) { var evts = new List(); @@ -116,10 +124,15 @@ public sealed class TeleportAnimSequencer case TeleportAnimState.TunnelContinue: _continueElapsed += dt; - // worldReady stays true once the landblock loads, so this advances at the 2s - // minimum in the normal case; gating min-advance on worldReady keeps the - // MaxContinue safety net authoritative if readiness ever regresses mid-continue. - bool minMet = worldReady && _continueElapsed >= MinContinue; + // gmSmartBoxUI::UseTime @ 0x004D7264: after the two-second + // minimum, start the one-second fade only while the portal + // animation has 1.1..1.3 seconds left. Retail performs the + // 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; if (minMet || maxForce) Advance(TeleportAnimState.TunnelFadeOut, enterTunnel: false); @@ -167,27 +180,60 @@ public sealed class TeleportAnimSequencer return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait); } - private static float Smoothstep(float t) + /// + /// Retail UIGlobals::GetAnimLevel (0x004EE540), returning + /// an integer level in the inclusive range 0..1024. + /// + public static short GetRetailAnimationLevel(float t) { 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) { float t = Math.Clamp(elapsed / FadeTime, 0f, 1f); + float level = GetRetailAnimationLevel(t) / 1024f; return state switch { // Fading TO black (alpha 0→1): - TeleportAnimState.WorldFadeOut => Smoothstep(t), - TeleportAnimState.TunnelFadeIn => 1f - Smoothstep(t), // tunnel fades IN: overlay goes clear + TeleportAnimState.WorldFadeOut => level, + TeleportAnimState.TunnelFadeIn => 1f - level, // tunnel fades IN: overlay goes clear // Full black / fully clear inside tunnel states: TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed TeleportAnimState.TunnelContinue => 0f, // Fading back out of tunnel: - TeleportAnimState.TunnelFadeOut => Smoothstep(t), // tunnel fades out: overlay goes black - TeleportAnimState.WorldFadeIn => 1f - Smoothstep(t), // world fades in: overlay clears + TeleportAnimState.TunnelFadeOut => level, // tunnel fades out: overlay goes black + TeleportAnimState.WorldFadeIn => 1f - level, // world fades in: overlay clears _ => 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; + } } diff --git a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs new file mode 100644 index 00000000..11926271 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs @@ -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(dats.Get(setupDid)); + Animation animation = Assert.IsType( + 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; + } +} diff --git a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs index 1b200eef..59d8f110 100644 --- a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs +++ b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs @@ -73,7 +73,12 @@ public sealed class TeleportAnimSequencerTests // Helper: drive the sequencer forward by total elapsed time using small fixed steps. private static (TeleportAnimSnapshot snap, List 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(); float remaining = seconds; @@ -81,7 +86,7 @@ public sealed class TeleportAnimSequencerTests while (remaining > 0f) { float dt = Math.Min(step, remaining); - var (snap, evts) = seq.Tick(dt, worldReady); + var (snap, evts) = seq.Tick(dt, worldReady, tunnelAnimationFrame); last = snap; allEvts.AddRange(evts); remaining -= dt; @@ -193,11 +198,54 @@ public sealed class TeleportAnimSequencerTests seq.Tick(0.016f, worldReady: true); // -> TunnelContinue // 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); } + [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 --- [Fact] @@ -245,7 +293,7 @@ public sealed class TeleportAnimSequencerTests var seq = new TeleportAnimSequencer(); seq.Begin(TeleportEntryKind.Logout); 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); } @@ -261,7 +309,7 @@ public sealed class TeleportAnimSequencerTests Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State); 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}"); }