Merge campaign-enter-portal into the round branch: login portal-space presentation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
2f8c046aba
9 changed files with 568 additions and 17 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-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-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-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-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-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
|
||||||
| TS-31 | **NARROWED 2026-07-13** — `/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
|
| TS-31 | **NARROWED 2026-07-13** — `/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
|
||||||
|
|
|
||||||
|
|
@ -141,13 +141,39 @@ internal sealed class PlayerModeController :
|
||||||
if (Controller is null && !TryEnter("teleport"))
|
if (Controller is null && !TryEnter("teleport"))
|
||||||
return false;
|
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;
|
return false;
|
||||||
|
|
||||||
controller.State = PlayerState.PortalSpace;
|
controller.State = PlayerState.PortalSpace;
|
||||||
return true;
|
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()
|
public void EnterWorld()
|
||||||
{
|
{
|
||||||
if (Controller is { } controller)
|
if (Controller is { } controller)
|
||||||
|
|
|
||||||
|
|
@ -337,16 +337,25 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
_world.FirstEntryDrive,
|
_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
|
// Night-round review F2: CM_House::Event_QueryHouse @0x006aaa00
|
||||||
// is tail-called, unconditionally, from the end of
|
// is tail-called, unconditionally, from the end of
|
||||||
// CPlayerSystem::InitializePlayer @0x00563570 — the SAME
|
// CPlayerSystem::InitializePlayer @0x00563570 (guarded by
|
||||||
// once-per-session function AttemptSendLoginCompleteNotification
|
// player_initialized, once per session) — an object-arrival
|
||||||
// lives in (guarded by player_initialized), right after that
|
// edge, NOT a tunnel edge, so it stays at first-entry
|
||||||
// notification. This is the graphical host's direct
|
// completion rather than moving with LoginComplete.
|
||||||
// (non-portal) first-entry completion edge — the exact
|
|
||||||
// analogue. Portal-space re-entries (LocalPlayerTeleportController)
|
|
||||||
// do NOT resend it, matching retail's single-shot guard.
|
|
||||||
session.SendHouseQuery();
|
session.SendHouseQuery();
|
||||||
},
|
},
|
||||||
_world.AcceptedPositionDrive,
|
_world.AcceptedPositionDrive,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,21 @@ internal interface ILocalPlayerTeleportNetworkSink
|
||||||
RuntimeTeleportDestination destination,
|
RuntimeTeleportDestination destination,
|
||||||
bool teleportTimestampAdvanced);
|
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 ResetSession();
|
||||||
|
|
||||||
void ResetGenerationPresentation();
|
void ResetGenerationPresentation();
|
||||||
|
|
@ -65,6 +80,9 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
|
||||||
bool teleportTimestampAdvanced) =>
|
bool teleportTimestampAdvanced) =>
|
||||||
Required().OfferDestination(destination, teleportTimestampAdvanced);
|
Required().OfferDestination(destination, teleportTimestampAdvanced);
|
||||||
|
|
||||||
|
public void OnLocalPlayerFirstEntryCompleted() =>
|
||||||
|
Required().OnLocalPlayerFirstEntryCompleted();
|
||||||
|
|
||||||
public void ResetSession() => Required().ResetSession();
|
public void ResetSession() => Required().ResetSession();
|
||||||
|
|
||||||
public void ResetGenerationPresentation() =>
|
public void ResetGenerationPresentation() =>
|
||||||
|
|
@ -102,6 +120,19 @@ internal interface ILocalPlayerTeleportModeOperations
|
||||||
PlayerMovementController? Controller { get; }
|
PlayerMovementController? Controller { get; }
|
||||||
Matrix4x4 Projection { get; }
|
Matrix4x4 Projection { get; }
|
||||||
bool TryEnterPortalSpace();
|
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();
|
void EnterWorld();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -449,6 +480,37 @@ internal sealed class LocalPlayerTeleportController
|
||||||
private long _lifetimeGeneration;
|
private long _lifetimeGeneration;
|
||||||
private bool _disposed;
|
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(
|
public LocalPlayerTeleportController(
|
||||||
ILocalPlayerTeleportAuthority authority,
|
ILocalPlayerTeleportAuthority authority,
|
||||||
ILocalPlayerTeleportInputLifetime input,
|
ILocalPlayerTeleportInputLifetime input,
|
||||||
|
|
@ -476,8 +538,35 @@ internal sealed class LocalPlayerTeleportController
|
||||||
|
|
||||||
public bool IsActive => _transit.IsTeleportActive;
|
public bool IsActive => _transit.IsTeleportActive;
|
||||||
public bool IsPortalViewportVisible => _presentation.IsPortalViewportVisible;
|
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)
|
public void OnTeleportStarted(uint sequence)
|
||||||
{
|
{
|
||||||
|
|
@ -515,13 +604,22 @@ internal sealed class LocalPlayerTeleportController
|
||||||
TryAimAcceptedDestination();
|
TryAimAcceptedDestination();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnLocalPlayerFirstEntryCompleted()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
_loginPlacementCompleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
public void Tick(float deltaSeconds)
|
public void Tick(float deltaSeconds)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
TryActivatePendingPresentation();
|
TryActivatePendingPresentation();
|
||||||
TryAimAcceptedDestination();
|
TryAimAcceptedDestination();
|
||||||
if (!_transit.IsTeleportActive)
|
if (!_transit.IsTeleportActive)
|
||||||
|
{
|
||||||
|
TickLoginPresentation(deltaSeconds);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
long generation = _lifetimeGeneration;
|
long generation = _lifetimeGeneration;
|
||||||
ushort sequence = _transit.ActiveTeleportSequence;
|
ushort sequence = _transit.ActiveTeleportSequence;
|
||||||
|
|
@ -902,6 +1000,225 @@ internal sealed class LocalPlayerTeleportController
|
||||||
+ $"(seq={_transit.ActiveTeleportSequence})");
|
+ $"(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()
|
private void TryAimAcceptedDestination()
|
||||||
{
|
{
|
||||||
if (!_transit.TryGetAcceptedTeleportDestination(
|
if (!_transit.TryGetAcceptedTeleportDestination(
|
||||||
|
|
@ -1030,6 +1347,18 @@ internal sealed class LocalPlayerTeleportController
|
||||||
_placementCommitted = false;
|
_placementCommitted = false;
|
||||||
_awaitingDeferredWake = false;
|
_awaitingDeferredWake = false;
|
||||||
_holdSeconds = 0f;
|
_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);
|
_streaming.ResetRecenter(clearSession);
|
||||||
if (_lifetimeGeneration != generation)
|
if (_lifetimeGeneration != generation)
|
||||||
|
|
|
||||||
|
|
@ -580,6 +580,13 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
// remains authoritative and closes it on InWorld/error/reset.
|
// remains authoritative and closes it on InWorld/error/reset.
|
||||||
EnsureEnterWait();
|
EnsureEnterWait();
|
||||||
RuntimeCommandResult result = _bindings.Enter();
|
RuntimeCommandResult result = _bindings.Enter();
|
||||||
|
// Enter-world round (2026-08-17): once-per-click outcome line — a
|
||||||
|
// refused Enter was previously indistinguishable from a click that
|
||||||
|
// never dispatched (both silent), which cost a full connected-gate
|
||||||
|
// round to tell apart.
|
||||||
|
Console.WriteLine(result.Accepted
|
||||||
|
? "[UI] character enter accepted"
|
||||||
|
: $"[UI] character enter rejected status={result.Status}");
|
||||||
if (!result.Accepted)
|
if (!result.Accepted)
|
||||||
CloseContext(ref _enterWaitContext, suppressCallback: true);
|
CloseContext(ref _enterWaitContext, suppressCallback: true);
|
||||||
InvalidateAndTick();
|
InvalidateAndTick();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Numerics;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
|
@ -163,7 +164,9 @@ public sealed class RetailUiAutomationProbe
|
||||||
{
|
{
|
||||||
var target = FindByDatElementId(datElementId);
|
var target = FindByDatElementId(datElementId);
|
||||||
if (target is null) return Fail($"element 0x{datElementId:X8} not found");
|
if (target is null) return Fail($"element 0x{datElementId:X8} not found");
|
||||||
_root.OnMouseMove((int)target.CenterX, (int)target.CenterY);
|
// Same canvas→window conversion as ClickAt — see CanvasToWindow.
|
||||||
|
(int x, int y) = CanvasToWindow((int)target.CenterX, (int)target.CenterY);
|
||||||
|
_root.OnMouseMove(x, y);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,8 +301,30 @@ public sealed class RetailUiAutomationProbe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enter-world round (2026-08-17): element coordinates from the probe's
|
||||||
|
/// tree walk are CANVAS coordinates, but <see cref="UiRoot.OnMouseMove"/>/
|
||||||
|
/// <c>OnMouseDown</c>/<c>OnMouseUp</c> take WINDOW coordinates and map
|
||||||
|
/// window→canvas internally (<c>UiRoot.MapWindowToCanvas</c>). The two
|
||||||
|
/// spaces are identical on every ordinary screen (no
|
||||||
|
/// <see cref="UiRoot.FixedCanvasSize"/>), which is why every prior probe
|
||||||
|
/// gate passed — the character-select/chargen screens are the first
|
||||||
|
/// STRETCHED canvases this apparatus drove, and there the mismatch sent
|
||||||
|
/// every synthetic click to canvas·(canvas/window), i.e. nowhere near the
|
||||||
|
/// target element. Element-derived pointer paths therefore convert
|
||||||
|
/// canvas→window here before touching the root.
|
||||||
|
/// </summary>
|
||||||
|
private (int x, int y) CanvasToWindow(int x, int y)
|
||||||
|
{
|
||||||
|
Vector2 scale = _root.CanvasScale;
|
||||||
|
return scale == Vector2.One
|
||||||
|
? (x, y)
|
||||||
|
: ((int)MathF.Round(x * scale.X), (int)MathF.Round(y * scale.Y));
|
||||||
|
}
|
||||||
|
|
||||||
private void ClickAt(int x, int y)
|
private void ClickAt(int x, int y)
|
||||||
{
|
{
|
||||||
|
(x, y) = CanvasToWindow(x, y);
|
||||||
Advance(16);
|
Advance(16);
|
||||||
_root.OnMouseMove(x, y);
|
_root.OnMouseMove(x, y);
|
||||||
_root.OnMouseDown(UiMouseButton.Left, x, y);
|
_root.OnMouseDown(UiMouseButton.Left, x, y);
|
||||||
|
|
@ -310,6 +335,8 @@ public sealed class RetailUiAutomationProbe
|
||||||
|
|
||||||
private void DragAt(int startX, int startY, int endX, int endY)
|
private void DragAt(int startX, int startY, int endX, int endY)
|
||||||
{
|
{
|
||||||
|
(startX, startY) = CanvasToWindow(startX, startY);
|
||||||
|
(endX, endY) = CanvasToWindow(endX, endY);
|
||||||
Advance(16);
|
Advance(16);
|
||||||
_root.OnMouseMove(startX, startY);
|
_root.OnMouseMove(startX, startY);
|
||||||
_root.OnMouseDown(UiMouseButton.Left, startX, startY);
|
_root.OnMouseDown(UiMouseButton.Left, startX, startY);
|
||||||
|
|
|
||||||
|
|
@ -1825,6 +1825,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
|
||||||
bool teleportTimestampAdvanced)
|
bool teleportTimestampAdvanced)
|
||||||
{ }
|
{ }
|
||||||
|
|
||||||
|
public void OnLocalPlayerFirstEntryCompleted() { }
|
||||||
|
|
||||||
public void ResetSession() { }
|
public void ResetSession() { }
|
||||||
|
|
||||||
public void ResetGenerationPresentation() { }
|
public void ResetGenerationPresentation() { }
|
||||||
|
|
|
||||||
|
|
@ -1059,6 +1059,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
|
||||||
bool teleportTimestampAdvanced)
|
bool teleportTimestampAdvanced)
|
||||||
{ }
|
{ }
|
||||||
|
|
||||||
|
public void OnLocalPlayerFirstEntryCompleted() { }
|
||||||
|
|
||||||
public void ResetSession() { }
|
public void ResetSession() { }
|
||||||
|
|
||||||
public void ResetGenerationPresentation() { }
|
public void ResetGenerationPresentation() { }
|
||||||
|
|
|
||||||
|
|
@ -911,12 +911,22 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
public readonly RuntimeLocalPlayerMovementState Movement;
|
public readonly RuntimeLocalPlayerMovementState Movement;
|
||||||
public IPreparedCollisionSource DiagnosticCollisionSource => new UnusedCollisionSource();
|
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(
|
public Harness(
|
||||||
int centerX = 0x20,
|
int centerX = 0x20,
|
||||||
int centerY = 0x21,
|
int centerY = 0x21,
|
||||||
bool worldReady = true,
|
bool worldReady = true,
|
||||||
List<string>? order = null)
|
List<string>? order = null)
|
||||||
{
|
{
|
||||||
|
WorldReady = worldReady;
|
||||||
order ??= new List<string>();
|
order ??= new List<string>();
|
||||||
Mode = new FakeMode(order);
|
Mode = new FakeMode(order);
|
||||||
Streaming = new FakeStreaming(centerX, centerY, order);
|
Streaming = new FakeStreaming(centerX, centerY, order);
|
||||||
|
|
@ -927,10 +937,10 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
Reveal = new WorldRevealCoordinator(
|
Reveal = new WorldRevealCoordinator(
|
||||||
Transit,
|
Transit,
|
||||||
revealWindow: () => RevealWindow,
|
revealWindow: () => RevealWindow,
|
||||||
isRenderNeighborhoodReady: (_, _, _) => worldReady,
|
isRenderNeighborhoodReady: (_, _, _) => WorldReady,
|
||||||
isSpawnCellReady: _ => worldReady,
|
isSpawnCellReady: _ => WorldReady,
|
||||||
isTerrainNeighborhoodReady: (_, _) => worldReady,
|
isTerrainNeighborhoodReady: (_, _) => WorldReady,
|
||||||
areCompositeTexturesReady: () => worldReady,
|
areCompositeTexturesReady: () => WorldReady,
|
||||||
prepareCompositeTextures: (_, _) => { },
|
prepareCompositeTextures: (_, _) => { },
|
||||||
invalidateCompositeTextures: () => { },
|
invalidateCompositeTextures: () => { },
|
||||||
isSpawnClaimUnhydratable: _ => false,
|
isSpawnClaimUnhydratable: _ => false,
|
||||||
|
|
@ -1330,6 +1340,14 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int EnterPortalForLoginCount;
|
||||||
|
|
||||||
|
public bool TryEnterPortalSpaceForLogin()
|
||||||
|
{
|
||||||
|
EnterPortalForLoginCount++;
|
||||||
|
return TryEnterPortalSpace();
|
||||||
|
}
|
||||||
|
|
||||||
public void EnterWorld()
|
public void EnterWorld()
|
||||||
{
|
{
|
||||||
_order.Add("enter-world");
|
_order.Add("enter-world");
|
||||||
|
|
@ -1439,12 +1457,14 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
private sealed class FakeNetworkSink : ILocalPlayerTeleportNetworkSink
|
private sealed class FakeNetworkSink : ILocalPlayerTeleportNetworkSink
|
||||||
{
|
{
|
||||||
public List<uint> Starts { get; } = [];
|
public List<uint> Starts { get; } = [];
|
||||||
|
public int FirstEntryCompletions;
|
||||||
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
|
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
|
||||||
public void OfferDestination(
|
public void OfferDestination(
|
||||||
RuntimeTeleportDestination destination,
|
RuntimeTeleportDestination destination,
|
||||||
bool teleportTimestampAdvanced)
|
bool teleportTimestampAdvanced)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++;
|
||||||
public void ResetSession()
|
public void ResetSession()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
@ -1482,6 +1502,135 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
Assert.False(harness.Presentation.IsPortalViewportVisible);
|
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 sealed class FakePresentation : ILocalPlayerTeleportPresentation
|
||||||
{
|
{
|
||||||
private readonly List<string> _order;
|
private readonly List<string> _order;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue