diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b1d2689d..091f74a4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -436,7 +436,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~TS-20~~ | **RETIRED AS A FALSE ATTRIBUTION 2026-07-16** — `CGfxObj::InitLoad` passes the complete polygon array to `D3DPolyRender::ConstructMesh`; ordinary GfxObj rendering does not filter it through DrawingBSP. Building DrawingBSP traversal discovers and orders portal apertures after `RemoveNonPortalNodes`; it is not a global visible-polygon selector. The alleged building-shell "orphans" are `DrawingBSPNode.Portals`, omitted by the old diagnostic collector; the corrected node-polygons ∪ portal-polygons audit finds no true orphans. Applying the proposed filter would repeat the door disappearance regression from `e46d3d9`. | `docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md`; `docs/research/2026-06-11-holistic-map/wf1-building-shells.md`; `tests/AcDream.Core.Tests/Rendering/Wb/Issue113DoorVanishDiagnosticTests.cs` | — | — | `CGfxObj::InitLoad @ 0x005346B0`; `D3DPolyRender::ConstructMesh @ 0x0059DFA0`; `BSPTREE::build_draw_portals_only @ 0x00539860` | | TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 | | TS-27 | **NARROWED 2026-07-29 (Campaign N Slice N1)** — OUTBOUND is ported: sent-packet cache + header-rebuilt resend on server `RequestRetransmit`, `ids[0]` implicit ack, wrap-safe watermark prune (`src/AcDream.Core.Net/Transport/`). Residual: INBOUND loss is still fatal — no sequence-aligned inbound ISAAC discipline, no client NAK emission, no `RejectRetransmit` consumption (Campaign N slices N2/N4) | `src/AcDream.Core.Net/WorldSession.cs` (`ProcessDatagram` inbound path); `docs/plans/2026-07-29-network-transport-campaign.md` §2.2/§2.3 | Campaign N executes the port one direction per slice; the N0 ACE double grades each slice before the next lands | One lost S2C packet still shifts the inbound keystream permanently — every later encrypted packet fails checksum and the session goes silently deaf until timeout | `SharedNet::ProcessPacket @ 0x00544790`; `ReceiverData::AddNakked @ 0x00549240`; `SharedNet::EnqueueNaks @ 0x00543BD0` | -| TS-28 | **NARROWED 2026-08-03** — F751 teleports resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login no longer acknowledges raw PlayerCreate receipt: graphical and prepared headless hosts send exactly once after canonical local-player first placement; content-less headless sends after its accepted direct Create because it has no placement conductor. Residual: initial login still does not enter the full portal-space presentation. | `src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs`; `src/AcDream.App/Net/GraphicalSessionEventRoute.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs`; `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs`; `src/AcDream.Core.Net/WorldSession.cs` | Initial placement is now the shared readiness contract that releases ACE's intentional Hidden/pink-bubble state without racing presentation. The content-less direct host uses its only truthful admission edge. | The persistent login materialization haze is fixed and server updates no longer unlock before canonical placement. The remaining difference is presentation-only: initial login skips retail's wormhole sequence. | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` | +| TS-28 | **NARROWED 2026-08-17 (enter-world round)** — the GRAPHICAL host now runs retail's full login wormhole: the login reveal (`RuntimeWorldTransitState.BeginLoginReveal`, shared by direct auto-select, character-select Enter, and enter-after-create) arms the same `TeleportAnimSequencer`/`PortalTunnelPresentation` machine the F751 pump uses (`LocalPlayerTeleportController` login arm), with `Sound_UI_EnterPortal`/`Sound_UI_ExitPortal` at retail's edges and LoginComplete sent at the WorldFadeIn end gated on canonical first placement. Residual: HEADLESS hosts have no presentation — prepared headless sends LoginComplete once after canonical local-player first placement; content-less headless sends after its accepted direct Create because it has no placement conductor. | `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs` (login arm); `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (first-entry completion latch); `src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs`; `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` | Headless hosts are bots — an animation hold would only delay automation; their placement-edge send remains the truthful admission contract. | A headless bot's LoginComplete reaches ACE seconds earlier than a graphical client's, so its observer-visible materialization is earlier than retail cadence. | `SmartBox::teleport_in_progress @ 0x00451C20`; `gmSmartBoxUI::UseTime @ 0x004D6E30` (login edge @ 0x004D6EAB, LoginComplete @ 0x004D745D); `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300` (enter cue @ 0x004D638E); `SmartBox::UseTime @ 0x00455410` (position_update_complete @ 0x00455483); `CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90`; holtburger `client/messages.rs:391-422` | | ~~TS-29~~ | **RETIRED 2026-08-08 (Campaign A slices A5/A6).** Both halves are resolved, in opposite directions. **Ambient:** ported. `AmbientSoundGatherer` walks retail's 3x3 landblock ring x 64 land cells off the region file's `SoundInfo`/`SceneInfo`/`TerrainInfo` chain, `AmbientSoundScheduler` runs the absolute-deadline queue, and continuous beds are re-fired one-shots on `min_rate` rather than looping voices — retail never sets the DirectSound loop flag, so the `StartAmbient`/`StopAmbient` handle API this row described modelled a mechanism that does not exist and is deleted. **Music:** there is nothing to port. Retail EoR links a complete winmm MIDI player and never feeds it — `midiPlay` has zero callers, the string "music" appears zero times in the 65 MB decomp, `SoundType` has no music member, `InitPrefs` registers no music key, and the retail install ships no music files. What players remember as dungeon music is the AdminEnvirons `UI_*` stinger family (TS-54, landed at A4). | retired | — | — | `Ambient::UpdatePlayQueue @ 0x551A50`; `Ambient::Play @ 0x5517A0`; `Ambient::UseTime @ 0x551880`; `CLandBlock::add_ambient_sounds @ 0x530310`; `docs/research/2026-08-08-audio-retail-ambient-runtime.md`; `docs/research/2026-08-08-audio-retail-music-absence.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 | diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index ee8903d3..faa2e1d5 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -141,13 +141,39 @@ internal sealed class PlayerModeController : if (Controller is null && !TryEnter("teleport")) return false; - if (Controller is not { } controller) + // Enter-world round (2026-08-17): the Runtime first-entry conductor + // can have CONSTRUCTED the controller without publishing it yet + // (CandidatePreparing/Sealed/Dormant). A State write in that window + // throws (EnsurePublishedForRuntimeOperation); CanExecuteLiveMovement + // is the documented skip-instead-of-fault predicate for exactly this + // pre-publication login window. Refuse — every caller retries on its + // own tick cadence. + if (Controller is not { CanExecuteLiveMovement: true } controller) return false; controller.State = PlayerState.PortalSpace; return true; } + /// + /// Enter-world round (2026-08-17): the login tunnel's portal-space entry. + /// The login wormhole begins before any player-mode entry has happened + /// (retail's camera set simply follows its always-present player), so + /// this first performs the SAME presentation attach the post-reveal + /// auto-entry used to run ( — chase camera, shadow, + /// animation sinks, approach lifetime), then flips into portal space. + /// itself refuses until the Runtime first-entry + /// conductor commits, so pre-publication calls fail closed and retry. + /// + public bool TryEnterPortalSpaceForLogin() + { + _autoEntry?.Cancel(); + if (!_mode.IsPlayerMode && !TryEnter("login")) + return false; + + return TryEnterPortalSpace(); + } + public void EnterWorld() { if (Controller is { } controller) diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index cecf9923..9791477e 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -337,16 +337,25 @@ internal sealed class LiveSessionRuntimeFactory _world.FirstEntryDrive, _ => { - session.SendGameAction(GameActionLoginComplete.Build()); + // Enter-world round (2026-08-17): the graphical host no + // longer sends LoginComplete here. Retail sends 0xA1 at the + // END of the login wormhole (gmSmartBoxUI::UseTime + // @0x004D745D -> CPlayerSystem::SendLoginCompleteNotification + // @0x00562E90), and the login portal-space presentation now + // runs on this host, so the send rides its + // FireLoginComplete edge (LocalPlayerTeleportController's + // login pump) — the same edge the F751 pump already uses. + // This notification is the presentation's worldReady latch: + // the canonical local-player first placement committed + // (retail's position_update_complete=1 analogue, + // SmartBox::UseTime @0x00455483). + _world.Teleport.OnLocalPlayerFirstEntryCompleted(); // Night-round review F2: CM_House::Event_QueryHouse @0x006aaa00 // is tail-called, unconditionally, from the end of - // CPlayerSystem::InitializePlayer @0x00563570 — the SAME - // once-per-session function AttemptSendLoginCompleteNotification - // lives in (guarded by player_initialized), right after that - // notification. This is the graphical host's direct - // (non-portal) first-entry completion edge — the exact - // analogue. Portal-space re-entries (LocalPlayerTeleportController) - // do NOT resend it, matching retail's single-shot guard. + // CPlayerSystem::InitializePlayer @0x00563570 (guarded by + // player_initialized, once per session) — an object-arrival + // edge, NOT a tunnel edge, so it stays at first-entry + // completion rather than moving with LoginComplete. session.SendHouseQuery(); }, _world.AcceptedPositionDrive, diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index ec960380..9b447e78 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -25,6 +25,21 @@ internal interface ILocalPlayerTeleportNetworkSink RuntimeTeleportDestination destination, bool teleportTimestampAdvanced); + /// + /// Enter-world round (2026-08-17): the graphical host's local-player + /// first-entry conductor completed its canonical initial placement. + /// This is the login analogue of retail's + /// SmartBox::UseTime @ 0x00455410 setting + /// position_update_complete = 1 — the fact that lets the login + /// portal-space presentation leave its Tunnel hold. LoginComplete + /// (0xA1) itself now rides the presentation's own + /// edge, matching + /// retail's send at the WorldFadeIn end + /// (gmSmartBoxUI::UseTime @ 0x004D745D → + /// CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90). + /// + void OnLocalPlayerFirstEntryCompleted(); + void ResetSession(); void ResetGenerationPresentation(); @@ -65,6 +80,9 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink bool teleportTimestampAdvanced) => Required().OfferDestination(destination, teleportTimestampAdvanced); + public void OnLocalPlayerFirstEntryCompleted() => + Required().OnLocalPlayerFirstEntryCompleted(); + public void ResetSession() => Required().ResetSession(); public void ResetGenerationPresentation() => @@ -102,6 +120,19 @@ internal interface ILocalPlayerTeleportModeOperations PlayerMovementController? Controller { get; } Matrix4x4 Projection { get; } bool TryEnterPortalSpace(); + + /// + /// Enter-world round (2026-08-17): the LOGIN arm's portal-space entry. + /// Unlike — whose F751 callers run when + /// player-mode presentation is already attached (or deliberately absent, + /// e.g. fly mode) — the login tunnel begins BEFORE any player-mode entry + /// has happened, so this operation must also perform the same + /// presentation attach the post-reveal auto-entry used to do (chase + /// camera, shadow, animation sinks) before flipping the controller into + /// portal space. Refuses (retryable) until the Runtime first-entry + /// conductor has published the movement controller. + /// + bool TryEnterPortalSpaceForLogin(); void EnterWorld(); } @@ -449,6 +480,37 @@ internal sealed class LocalPlayerTeleportController private long _lifetimeGeneration; private bool _disposed; + /// + /// Enter-world round (2026-08-17): the login reveal generation this + /// controller's presentation currently owns (0 = none). Retail runs the + /// SAME wormhole machine at initial login as at an F751 teleport — + /// SmartBox::teleport_in_progress @ 0x00451C20 returns 1 whenever + /// the SmartBox has a player whose position_update_complete is + /// still 0, which is true the moment the login CreatePlayer lands, and + /// gmSmartBoxUI::UseTime @ 0x004D6EAB edge-detects that flag into + /// BeginTeleportAnimation(TAS_TUNNEL) @ 0x004D6EC9 (which plays + /// Sound_UI_EnterPortal @ 0x004D638E) with no F751 involved. + /// acdream's canonical equivalent of that condition is Runtime's login + /// reveal (, + /// begun on the first accepted local-player position) — so this arm keys + /// the same presentation off that Runtime-owned lifecycle instead of a + /// teleport start. + /// + private long _loginRevealGeneration; + private bool _loginPresentationActive; + + /// + /// Latched by — the + /// first-entry conductor's canonical initial placement committed. The + /// login pump's worldReady requires it, so the sequencer cannot + /// leave its Tunnel hold (and therefore cannot reach + /// ) before the same + /// placement contract that previously gated the immediate LoginComplete + /// send. Session-scoped: cleared only by session-level resets. + /// + private bool _loginPlacementCompleted; + private float _loginHoldSeconds; + public LocalPlayerTeleportController( ILocalPlayerTeleportAuthority authority, ILocalPlayerTeleportInputLifetime input, @@ -476,8 +538,35 @@ internal sealed class LocalPlayerTeleportController public bool IsActive => _transit.IsTeleportActive; public bool IsPortalViewportVisible => _presentation.IsPortalViewportVisible; - public uint ActiveDestinationCell => - _transit.IsTeleportActive ? _pendingCell : 0u; + + /// + /// The destination the portal viewport is currently holding for. The + /// render frame's reveal-preparation arm + /// () + /// keys composite-texture preparation and readiness evaluation off this + /// cell; the login presentation must report its own destination here + /// because entering portal space flips ChaseModeEverEntered, which + /// retires the fallback "waiting for login" cell source that used to keep + /// preparation running pre-entry. + /// + public uint ActiveDestinationCell + { + get + { + if (_transit.IsTeleportActive) + return _pendingCell; + if (_loginPresentationActive) + { + RuntimePortalSnapshot snapshot = _transit.Snapshot; + if (snapshot.Kind == RuntimePortalKind.Login + && snapshot.Generation == _loginRevealGeneration) + { + return snapshot.Readiness.DestinationCell; + } + } + return 0u; + } + } public void OnTeleportStarted(uint sequence) { @@ -515,13 +604,22 @@ internal sealed class LocalPlayerTeleportController TryAimAcceptedDestination(); } + public void OnLocalPlayerFirstEntryCompleted() + { + ThrowIfDisposed(); + _loginPlacementCompleted = true; + } + public void Tick(float deltaSeconds) { ThrowIfDisposed(); TryActivatePendingPresentation(); TryAimAcceptedDestination(); if (!_transit.IsTeleportActive) + { + TickLoginPresentation(deltaSeconds); return; + } long generation = _lifetimeGeneration; ushort sequence = _transit.ActiveTeleportSequence; @@ -902,6 +1000,225 @@ internal sealed class LocalPlayerTeleportController + $"(seq={_transit.ActiveTeleportSequence})"); } + /// + /// Enter-world round (2026-08-17): the login half of retail's ONE + /// wormhole machine. Retail begins the identical TAS_TUNNEL animation for + /// initial login and for F751 teleports from the same + /// gmSmartBoxUI::UseTime @ 0x004D6EAB flag edge — + /// SmartBox::teleport_in_progress @ 0x00451C20 goes high the + /// moment the login player object exists with + /// position_update_complete == 0, no F751 required. acdream's + /// canonical login edge is Runtime's login reveal generation + /// (, begun on the + /// first accepted local-player position on every entry route: direct + /// auto-select, character-select Enter, and enter-after-create). + /// + /// + /// Activation retries every Tick until + /// + /// succeeds — the same retry shape the F751 arm uses — which requires the + /// Runtime first-entry conductor's published movement controller. + /// Entering portal space cancels the player-mode auto-entry (its first + /// statement), so this arm owns the reveal's + /// EnterWorld/LoginComplete/Complete suffix exactly like the teleport + /// pump owns its own; the update-frame order (teleport phase before + /// auto-entry) guarantees this claim happens before auto-entry could + /// fire. + /// + /// + private void TryActivateLoginPresentation() + { + if (_transit.HasPendingTeleportStart || _transit.IsTeleportActive) + return; + + RuntimePortalSnapshot snapshot = _transit.Snapshot; + if (snapshot.Kind != RuntimePortalKind.Login + || snapshot.Generation == 0 + || snapshot.Completed + || snapshot.Cancelled + || _loginRevealGeneration == snapshot.Generation) + { + return; + } + + // Quiet pre-gate: until the first-entry conductor PUBLISHES the + // movement controller, portal-space entry cannot succeed (and the + // full entry path would log a refusal every tick). Retry silently. + if (_mode.Controller is not { CanExecuteLiveMovement: true }) + return; + + long generation = _lifetimeGeneration; + if (!_mode.TryEnterPortalSpaceForLogin() + || _lifetimeGeneration != generation + || _mode.Controller is null) + { + return; + } + + // Re-read after the mode entry: TryEnterPortalSpaceForLogin can run + // arbitrary presentation attach work. + snapshot = _transit.Snapshot; + if (snapshot.Kind != RuntimePortalKind.Login + || snapshot.Generation == 0 + || snapshot.Completed + || snapshot.Cancelled) + { + return; + } + + _loginRevealGeneration = snapshot.Generation; + _loginPresentationActive = true; + _loginHoldSeconds = 0f; + _presentation.Begin(_mode.Projection); + Console.WriteLine( + $"live: login portal-space presentation started " + + $"(gen={snapshot.Generation} " + + $"cell=0x{snapshot.Readiness.DestinationCell:X8})"); + } + + /// + /// Per-frame pump for the login presentation — the login mirror of the + /// teleport pump in . Differences, each anchored in + /// retail: there is no Place edge to drive (the first-entry conductor + /// committed the canonical placement before this presentation could + /// begin — retail's SmartBox::UseTime @ 0x00455483 likewise only + /// flips position_update_complete, it does not place), and + /// LoginComplete rides + /// at the WorldFadeIn end (gmSmartBoxUI::UseTime @ 0x004D745D → + /// CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90) + /// instead of at raw first placement. + /// + private void TickLoginPresentation(float deltaSeconds) + { + TryActivateLoginPresentation(); + + RuntimePortalSnapshot snapshot = _transit.Snapshot; + bool revealActive = snapshot.Kind == RuntimePortalKind.Login + && snapshot.Generation != 0 + && !snapshot.Completed + && !snapshot.Cancelled; + if (!revealActive || _loginRevealGeneration != snapshot.Generation) + { + if (_loginPresentationActive) + { + // The reveal this presentation was serving ended underneath + // it (cancel, supersession, or session reset that did not + // route through this controller's own reset). Drop the claim + // and retire the visuals; a successor reveal re-activates + // through TryActivateLoginPresentation above. + _loginRevealGeneration = 0; + _loginPresentationActive = false; + _loginHoldSeconds = 0f; + _presentation.Reset(); + } + return; + } + + if (!_loginPresentationActive) + return; + + long generation = _lifetimeGeneration; + long revealGeneration = snapshot.Generation; + uint destinationCell = snapshot.Readiness.DestinationCell; + + bool originReady = !_streaming.IsRecenterPending; + bool worldReady = _loginPlacementCompleted + && originReady + && _worldReveal.Evaluate(destinationCell).IsReady; + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + + if (!worldReady) + _loginHoldSeconds += deltaSeconds; + _presentation.SetWaitCue( + !worldReady + && _worldReveal.ObserveWait( + TimeSpan.FromSeconds(_loginHoldSeconds))); + + var (_, events) = _presentation.Tick(deltaSeconds, worldReady); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + + foreach (TeleportAnimEvent teleportEvent in events) + { + switch (teleportEvent) + { + case TeleportAnimEvent.PlayEnterSound: + // Sound_UI_EnterPortal as the animation begins — + // BeginTeleportAnimation @ 0x004D638E, identical for the + // login entry. Logged (once per login) as the audio-start + // evidence line for connected gates. + Console.WriteLine( + "live: login portal-space enter cue " + + "(Sound_UI_EnterPortal)"); + _presentation.PlayEnterCue(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + break; + case TeleportAnimEvent.EnterTunnel: + _presentation.EnterTunnel(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + break; + case TeleportAnimEvent.Place: + // No login Place edge: the canonical initial placement is + // the first-entry conductor's, already committed (the + // worldReady latch above requires it). Retail's login + // analogue only flips position_update_complete + // (SmartBox::UseTime @ 0x00455483). + break; + case TeleportAnimEvent.PlayExitSound: + // Release destination cell blocking at the exact + // portal/world viewport swap — same edge as the teleport + // pump (gmSmartBoxUI::UseTime @ 0x004D6E30), with + // Sound_UI_ExitPortal @ 0x004D7405. + _worldReveal.RevealWorldViewport(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _presentation.PlayExitCue(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _presentation.ExitTunnel(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + break; + case TeleportAnimEvent.FireLoginComplete: + _mode.EnterWorld(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _session.SendLoginComplete(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _worldReveal.Complete(); + _loginRevealGeneration = 0; + _loginPresentationActive = false; + _loginHoldSeconds = 0f; + Console.WriteLine( + "live: login portal-space presentation complete"); + return; + default: + break; + } + } + + _presentation.TickTunnel(deltaSeconds); + } + + /// + /// The login pump's currency check — the login mirror of + /// : same controller + /// lifetime, still no active teleport (an F751 supersedes the login + /// presentation), and the transit snapshot still carries the exact + /// claimed login reveal generation. + /// + private bool IsCurrentLoginLifetime( + long lifetimeGeneration, + long revealGeneration) => + _lifetimeGeneration == lifetimeGeneration + && !_transit.IsTeleportActive + && _loginRevealGeneration == revealGeneration + && _transit.Snapshot.Generation == revealGeneration; + private void TryAimAcceptedDestination() { if (!_transit.TryGetAcceptedTeleportDestination( @@ -1030,6 +1347,18 @@ internal sealed class LocalPlayerTeleportController _placementCommitted = false; _awaitingDeferredWake = false; _holdSeconds = 0f; + // Enter-world round (2026-08-17): an F751 arriving mid-login-tunnel + // (clearSession: false) withdraws the login presentation's claim — + // the portal pump supersedes it and owns the single LoginComplete, + // exactly as retail's one teleportInProgress flag stays high across + // both and sends once at the eventual WorldFadeIn end. The + // placement-completed latch is a session fact: it survives the + // teleport-scoped reset and clears only with the session. + _loginRevealGeneration = 0; + _loginPresentationActive = false; + _loginHoldSeconds = 0f; + if (clearSession) + _loginPlacementCompleted = false; _streaming.ResetRecenter(clearSession); if (_lifetimeGeneration != generation) diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs index cb21a238..8d8d99ff 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs @@ -1825,6 +1825,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests bool teleportTimestampAdvanced) { } + public void OnLocalPlayerFirstEntryCompleted() { } + public void ResetSession() { } public void ResetGenerationPresentation() { } diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs index 74f03454..1b5eaa34 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs @@ -1059,6 +1059,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests bool teleportTimestampAdvanced) { } + public void OnLocalPlayerFirstEntryCompleted() { } + public void ResetSession() { } public void ResetGenerationPresentation() { } diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index b1f9ae53..e9174380 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -911,12 +911,22 @@ public sealed class LocalPlayerTeleportControllerTests public readonly RuntimeLocalPlayerMovementState Movement; public IPreparedCollisionSource DiagnosticCollisionSource => new UnusedCollisionSource(); + /// + /// Enter-world round (2026-08-17): mutable so a login-arm test can + /// flip destination readiness mid-flight (the login tunnel's hold is + /// exactly "readiness not yet true"). Initialized from the ctor's + /// existing worldReady parameter, so every prior test reads + /// identically. + /// + public bool WorldReady; + public Harness( int centerX = 0x20, int centerY = 0x21, bool worldReady = true, List? order = null) { + WorldReady = worldReady; order ??= new List(); Mode = new FakeMode(order); Streaming = new FakeStreaming(centerX, centerY, order); @@ -927,10 +937,10 @@ public sealed class LocalPlayerTeleportControllerTests Reveal = new WorldRevealCoordinator( Transit, revealWindow: () => RevealWindow, - isRenderNeighborhoodReady: (_, _, _) => worldReady, - isSpawnCellReady: _ => worldReady, - isTerrainNeighborhoodReady: (_, _) => worldReady, - areCompositeTexturesReady: () => worldReady, + isRenderNeighborhoodReady: (_, _, _) => WorldReady, + isSpawnCellReady: _ => WorldReady, + isTerrainNeighborhoodReady: (_, _) => WorldReady, + areCompositeTexturesReady: () => WorldReady, prepareCompositeTextures: (_, _) => { }, invalidateCompositeTextures: () => { }, isSpawnClaimUnhydratable: _ => false, @@ -1330,6 +1340,14 @@ public sealed class LocalPlayerTeleportControllerTests return true; } + public int EnterPortalForLoginCount; + + public bool TryEnterPortalSpaceForLogin() + { + EnterPortalForLoginCount++; + return TryEnterPortalSpace(); + } + public void EnterWorld() { _order.Add("enter-world"); @@ -1439,12 +1457,14 @@ public sealed class LocalPlayerTeleportControllerTests private sealed class FakeNetworkSink : ILocalPlayerTeleportNetworkSink { public List Starts { get; } = []; + public int FirstEntryCompletions; public void OnTeleportStarted(uint sequence) => Starts.Add(sequence); public void OfferDestination( RuntimeTeleportDestination destination, bool teleportTimestampAdvanced) { } + public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++; public void ResetSession() { } @@ -1482,6 +1502,135 @@ public sealed class LocalPlayerTeleportControllerTests Assert.False(harness.Presentation.IsPortalViewportVisible); } + // ── Enter-world round (2026-08-17): the LOGIN portal-space arm ────── + // + // Retail runs the identical TAS_TUNNEL wormhole on initial login — + // SmartBox::teleport_in_progress @ 0x00451C20 goes high the moment the + // login player exists with position_update_complete == 0, and + // gmSmartBoxUI::UseTime @ 0x004D6EAB begins the same animation (and the + // same Sound_UI_EnterPortal @ 0x004D638E) it begins for an F751. These + // tests drive the login reveal (RuntimeWorldTransitState.BeginLoginReveal + // via WorldRevealCoordinator.BeginLogin — the shared edge of every entry + // route) through the controller's login pump. + + [Fact] + public void LoginReveal_ArmsThePortalSpacePresentation_WithRetailCues() + { + var order = new List(); + var harness = new Harness(worldReady: false, order: order); + + harness.Reveal.BeginLogin(0x20210001u); + Assert.Equal(RuntimePortalKind.Login, harness.Reveal.Snapshot.Kind); + + // First tick: the arm claims the reveal, enters portal space, and + // begins the presentation. + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Mode.EnterPortalCount); + Assert.Equal(Matrix4x4.Identity, harness.Presentation.BeginProjection); + Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell); + + // Enter cue at animation begin, tunnel viewport on its own edge — + // the same event contract as the F751 pump. + harness.Presentation.Enqueue(TeleportAnimEvent.PlayEnterSound); + harness.Controller.Tick(0.016f); + Assert.Equal(["enter"], harness.Presentation.Cues); + + harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel); + harness.Controller.Tick(0.016f); + Assert.True(harness.Presentation.IsPortalViewportVisible); + + // Destination not ready and first placement not yet completed: the + // sequencer must keep seeing worldReady == false (its Tunnel hold). + Assert.All(harness.Presentation.WorldReadyValues, value => Assert.False(value)); + Assert.Equal(0, harness.Session.LoginCompleteCount); + + // Readiness alone is not enough — the first-entry conductor's + // canonical placement is half of the latch. + harness.WorldReady = true; + harness.Controller.Tick(0.016f); + Assert.False(harness.Presentation.WorldReadyValues[^1]); + + harness.Controller.OnLocalPlayerFirstEntryCompleted(); + harness.Controller.Tick(0.016f); + Assert.True(harness.Presentation.WorldReadyValues[^1]); + + // The login pump has no Place edge of its own: the conductor already + // placed the player (retail: SmartBox::UseTime @ 0x00455483 only + // flips position_update_complete). + harness.Presentation.Enqueue(TeleportAnimEvent.Place); + harness.Controller.Tick(0.016f); + Assert.False(harness.Placement.Called); + + // Viewport swap: reservation release + exit cue + tunnel retire + // (gmSmartBoxUI::UseTime, Sound_UI_ExitPortal @ 0x004D7405). + harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound); + harness.Controller.Tick(0.016f); + Assert.Equal(["enter", "exit"], harness.Presentation.Cues); + Assert.False(harness.Presentation.IsPortalViewportVisible); + Assert.Single(harness.Streaming.ReservationEnds); + + // WorldFadeIn end: EnterWorld + the ONE LoginComplete + reveal + // completion (gmSmartBoxUI::UseTime @ 0x004D745D). + harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete); + harness.Controller.Tick(0.016f); + Assert.Contains("enter-world", order); + Assert.Equal(1, harness.Session.LoginCompleteCount); + Assert.True(harness.Reveal.Snapshot.Completed); + Assert.Equal(0u, harness.Controller.ActiveDestinationCell); + + // The completed reveal stays completed; no re-arm, no second send. + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Mode.EnterPortalCount); + Assert.Equal(1, harness.Session.LoginCompleteCount); + } + + [Fact] + public void LoginPump_SendsLoginCompleteOnlyAtThePresentationEnd() + { + var harness = new Harness(worldReady: true); + harness.Reveal.BeginLogin(0x20210001u); + harness.Controller.OnLocalPlayerFirstEntryCompleted(); + + // Ticks before the FireLoginComplete edge never send. + for (int i = 0; i < 5; i++) + harness.Controller.Tick(0.016f); + Assert.Equal(0, harness.Session.LoginCompleteCount); + + harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete); + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Session.LoginCompleteCount); + Assert.True(harness.Reveal.Snapshot.Completed); + } + + [Fact] + public void RealTeleportStart_SupersedesTheLoginPresentation() + { + var order = new List(); + var harness = new Harness(worldReady: true, order: order); + harness.Reveal.BeginLogin(0x20210001u); + harness.Controller.OnLocalPlayerFirstEntryCompleted(); + harness.Controller.Tick(0.016f); + harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel); + harness.Controller.Tick(0.016f); + Assert.True(harness.Presentation.IsPortalViewportVisible); + + // An F751 mid-login-tunnel (ACE can relocate a broken spawn at + // login): the portal pump takes the presentation over and owns the + // single LoginComplete, mirroring retail's one teleportInProgress + // flag spanning both. + harness.Controller.OnTeleportStarted(3); + Assert.Contains("presentation-reset", order); + Assert.True(harness.Controller.IsActive); + Assert.Equal(0, harness.Session.LoginCompleteCount); + + // The login claim is gone: ticking the portal-active controller + // never re-enters the login pump (no second presentation-begin from + // the login arm beyond the teleport activation's own). + int loginCompletes = harness.Session.LoginCompleteCount; + harness.Controller.Tick(0.016f); + Assert.Equal(loginCompletes, harness.Session.LoginCompleteCount); + } + private sealed class FakePresentation : ILocalPlayerTeleportPresentation { private readonly List _order;