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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -580,6 +580,13 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
// remains authoritative and closes it on InWorld/error/reset.
|
||||
EnsureEnterWait();
|
||||
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)
|
||||
CloseContext(ref _enterWaitContext, suppressCallback: true);
|
||||
InvalidateAndTick();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
|
|
@ -163,7 +164,9 @@ public sealed class RetailUiAutomationProbe
|
|||
{
|
||||
var target = FindByDatElementId(datElementId);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
{
|
||||
(x, y) = CanvasToWindow(x, y);
|
||||
Advance(16);
|
||||
_root.OnMouseMove(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)
|
||||
{
|
||||
(startX, startY) = CanvasToWindow(startX, startY);
|
||||
(endX, endY) = CanvasToWindow(endX, endY);
|
||||
Advance(16);
|
||||
_root.OnMouseMove(startX, startY);
|
||||
_root.OnMouseDown(UiMouseButton.Left, startX, startY);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue