feat(world): the login wormhole — every world entry runs retail's portal-space presentation with sound (TS-28 narrowed)
Retail runs the SAME TAS_TUNNEL wormhole at initial login as at an F751 teleport, with no F751 involved: SmartBox::teleport_in_progress @0x00451C20 returns 1 the moment the login player exists with position_update_complete == 0, gmSmartBoxUI::UseTime @0x004D6EAB edge-detects it into BeginTeleportAnimation(TAS_TUNNEL) @0x004D6EC9 (playing Sound_UI_EnterPortal @0x004D638E), SmartBox::UseTime @0x00455483 ends the hold once destination cells stop blocking, Sound_UI_ExitPortal plays at the viewport swap @0x004D7405, and LoginComplete goes out at the WorldFadeIn end @0x004D745D -> CPlayerSystem::SendLoginCompleteNotification @0x00562E90 (ACE's own GameActionLoginComplete comment names this contract: 'called when the client player exits portal space. It includes initial login'). acdream skipped all of it at login — every entry route (direct auto-select, character-select Enter, enter-after-create) dropped onto the sky-only 'waiting for login' backdrop until the world reveal completed. The fix engages the EXISTING F751 presentation machinery on Runtime's login reveal — no duplicated presentation code, no timers: - LocalPlayerTeleportController gains a login arm keyed off the Runtime-owned login reveal generation (RuntimeWorldTransitState .BeginLoginReveal, begun on the first accepted local-player position on every entry route). It drives the same TeleportAnimSequencer/ PortalTunnelPresentation lifecycle and the same enter/exit cues; the Place edge is a no-op at login (the first-entry conductor already committed the canonical placement — retail's analogue only flips position_update_complete), and FireLoginComplete now performs EnterWorld + the single LoginComplete send + reveal Complete, exactly like the F751 pump. worldReady is latched on BOTH canonical first placement (OnLocalPlayerFirstEntryCompleted, the repointed GraphicalSessionEventRoute completion callback that used to send LoginComplete immediately) AND destination reveal readiness. ActiveDestinationCell now also reports the login destination so the render frame's reveal-preparation arm keeps running after portal-space entry flips ChaseModeEverEntered. - PlayerModeController.TryEnterPortalSpaceForLogin performs the player-mode presentation attach (the same BuildControllerAndCamera the post-reveal auto-entry used to run) before flipping into portal space — at login no player-mode entry has happened yet. TryEnterPortalSpace itself now refuses (retryable) on a constructed-but-unpublished Runtime controller via the documented CanExecuteLiveMovement skip predicate instead of faulting — the first connected run crashed on exactly that pre-publication State write. - HouseQuery stays at first-entry completion (retail: tail-called from CPlayerSystem::InitializePlayer @0x00563570, an object-arrival edge, not a tunnel edge). - An F751 arriving mid-login-tunnel withdraws the login claim and hands the presentation to the portal pump, which owns the single LoginComplete — matching retail's one teleportInProgress flag. TS-28 narrowed: the graphical host now runs the full login wormhole; the residual is headless-only (no presentation; placement-edge send). Live gates (testaccount2/+Horan vs local ACE, Release): the character-select Enter route and the --session-config direct auto-select route both play the wormhole with Sound_UI_EnterPortal at animation begin, hold with retail's 'In Portal Space - Please Wait...' notice until readiness, fade out with the view-plane warp, send LoginComplete at the WorldFadeIn end, and materialize in Holtburg; ACE-confirmed graceful logout. Tests: App 5493/3 skips (baseline 5490 + 3 new login tests), Runtime 1744/0, full solution green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
302d90209d
commit
5ca1d47d7a
7 changed files with 533 additions and 16 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<see cref="TryEnter"/> — chase camera, shadow,
|
||||
/// animation sinks, approach lifetime), then flips into portal space.
|
||||
/// <see cref="TryEnter"/> itself refuses until the Runtime first-entry
|
||||
/// conductor commits, so pre-publication calls fail closed and retry.
|
||||
/// </summary>
|
||||
public bool TryEnterPortalSpaceForLogin()
|
||||
{
|
||||
_autoEntry?.Cancel();
|
||||
if (!_mode.IsPlayerMode && !TryEnter("login"))
|
||||
return false;
|
||||
|
||||
return TryEnterPortalSpace();
|
||||
}
|
||||
|
||||
public void EnterWorld()
|
||||
{
|
||||
if (Controller is { } controller)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,21 @@ internal interface ILocalPlayerTeleportNetworkSink
|
|||
RuntimeTeleportDestination destination,
|
||||
bool teleportTimestampAdvanced);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>SmartBox::UseTime @ 0x00455410</c> setting
|
||||
/// <c>position_update_complete = 1</c> — the fact that lets the login
|
||||
/// portal-space presentation leave its Tunnel hold. LoginComplete
|
||||
/// (0xA1) itself now rides the presentation's own
|
||||
/// <see cref="TeleportAnimEvent.FireLoginComplete"/> edge, matching
|
||||
/// retail's send at the WorldFadeIn end
|
||||
/// (<c>gmSmartBoxUI::UseTime @ 0x004D745D</c> →
|
||||
/// <c>CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90</c>).
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// Enter-world round (2026-08-17): the LOGIN arm's portal-space entry.
|
||||
/// Unlike <see cref="TryEnterPortalSpace"/> — 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.
|
||||
/// </summary>
|
||||
bool TryEnterPortalSpaceForLogin();
|
||||
void EnterWorld();
|
||||
}
|
||||
|
||||
|
|
@ -449,6 +480,37 @@ internal sealed class LocalPlayerTeleportController
|
|||
private long _lifetimeGeneration;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// 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 —
|
||||
/// <c>SmartBox::teleport_in_progress @ 0x00451C20</c> returns 1 whenever
|
||||
/// the SmartBox has a player whose <c>position_update_complete</c> is
|
||||
/// still 0, which is true the moment the login CreatePlayer lands, and
|
||||
/// <c>gmSmartBoxUI::UseTime @ 0x004D6EAB</c> edge-detects that flag into
|
||||
/// <c>BeginTeleportAnimation(TAS_TUNNEL) @ 0x004D6EC9</c> (which plays
|
||||
/// <c>Sound_UI_EnterPortal @ 0x004D638E</c>) with no F751 involved.
|
||||
/// acdream's canonical equivalent of that condition is Runtime's login
|
||||
/// reveal (<see cref="RuntimeWorldTransitState.BeginLoginReveal"/>,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private long _loginRevealGeneration;
|
||||
private bool _loginPresentationActive;
|
||||
|
||||
/// <summary>
|
||||
/// Latched by <see cref="OnLocalPlayerFirstEntryCompleted"/> — the
|
||||
/// first-entry conductor's canonical initial placement committed. The
|
||||
/// login pump's <c>worldReady</c> requires it, so the sequencer cannot
|
||||
/// leave its Tunnel hold (and therefore cannot reach
|
||||
/// <see cref="TeleportAnimEvent.FireLoginComplete"/>) before the same
|
||||
/// placement contract that previously gated the immediate LoginComplete
|
||||
/// send. Session-scoped: cleared only by session-level resets.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The destination the portal viewport is currently holding for. The
|
||||
/// render frame's reveal-preparation arm
|
||||
/// (<see cref="AcDream.App.Rendering.RuntimeRenderFrameLivePreparation"/>)
|
||||
/// keys composite-texture preparation and readiness evaluation off this
|
||||
/// cell; the login presentation must report its own destination here
|
||||
/// because entering portal space flips <c>ChaseModeEverEntered</c>, which
|
||||
/// retires the fallback "waiting for login" cell source that used to keep
|
||||
/// preparation running pre-entry.
|
||||
/// </summary>
|
||||
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})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>gmSmartBoxUI::UseTime @ 0x004D6EAB</c> flag edge —
|
||||
/// <c>SmartBox::teleport_in_progress @ 0x00451C20</c> goes high the
|
||||
/// moment the login player object exists with
|
||||
/// <c>position_update_complete == 0</c>, no F751 required. acdream's
|
||||
/// canonical login edge is Runtime's login reveal generation
|
||||
/// (<see cref="RuntimeWorldTransitState.BeginLoginReveal"/>, begun on the
|
||||
/// first accepted local-player position on every entry route: direct
|
||||
/// auto-select, character-select Enter, and enter-after-create).
|
||||
///
|
||||
/// <para>
|
||||
/// Activation retries every Tick until
|
||||
/// <see cref="ILocalPlayerTeleportModeOperations.TryEnterPortalSpace"/>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame pump for the login presentation — the login mirror of the
|
||||
/// teleport pump in <see cref="Tick"/>. 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 <c>SmartBox::UseTime @ 0x00455483</c> likewise only
|
||||
/// flips <c>position_update_complete</c>, it does not place), and
|
||||
/// LoginComplete rides <see cref="TeleportAnimEvent.FireLoginComplete"/>
|
||||
/// at the WorldFadeIn end (<c>gmSmartBoxUI::UseTime @ 0x004D745D</c> →
|
||||
/// <c>CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90</c>)
|
||||
/// instead of at raw first placement.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The login pump's currency check — the login mirror of
|
||||
/// <see cref="IsCurrentLifetime(long, ushort)"/>: 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.
|
||||
/// </summary>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1825,6 +1825,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
|||
bool teleportTimestampAdvanced)
|
||||
{ }
|
||||
|
||||
public void OnLocalPlayerFirstEntryCompleted() { }
|
||||
|
||||
public void ResetSession() { }
|
||||
|
||||
public void ResetGenerationPresentation() { }
|
||||
|
|
|
|||
|
|
@ -1059,6 +1059,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
|
|||
bool teleportTimestampAdvanced)
|
||||
{ }
|
||||
|
||||
public void OnLocalPlayerFirstEntryCompleted() { }
|
||||
|
||||
public void ResetSession() { }
|
||||
|
||||
public void ResetGenerationPresentation() { }
|
||||
|
|
|
|||
|
|
@ -911,12 +911,22 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
public readonly RuntimeLocalPlayerMovementState Movement;
|
||||
public IPreparedCollisionSource DiagnosticCollisionSource => new UnusedCollisionSource();
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>worldReady</c> parameter, so every prior test reads
|
||||
/// identically.
|
||||
/// </summary>
|
||||
public bool WorldReady;
|
||||
|
||||
public Harness(
|
||||
int centerX = 0x20,
|
||||
int centerY = 0x21,
|
||||
bool worldReady = true,
|
||||
List<string>? order = null)
|
||||
{
|
||||
WorldReady = worldReady;
|
||||
order ??= new List<string>();
|
||||
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<uint> 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<string>();
|
||||
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<string>();
|
||||
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<string> _order;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue