fix(ui): gate — no void frames around the login wormhole; vitals icons centered

The login tunnel now covers from the first world-facing frame (the
sky-void backdrop can never present pre-tunnel) and holds through an
atomic tunnel-to-world swap at reveal completion — the void is
structurally unreachable on both edges, pinned by frame-sequence tests
across WorldSceneRenderer/WorldRevealCoordinator/LocalPlayerTeleport-
Controller/RuntimeWorldTransitState. Vitals detail icons draw at their
authored centered offsets in both stacked and side-by-side layouts.
Implemented and live-probed by the fix agent; finalized by the lead
after the agent parked post-verification (gates re-run green:
App 5512/3, Runtime 1747/0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 12:30:51 +02:00
parent 2f8c046aba
commit fdc4fd496d
17 changed files with 649 additions and 66 deletions

View file

@ -0,0 +1,95 @@
using System;
namespace AcDream.App.Rendering;
/// <summary>
/// Diagnostic owner for the render-presentation probe family (CLAUDE.md Code
/// Structure Rules §5 — one static class per subsystem, typed properties read
/// from the environment once at startup, never per-call-site
/// <c>GetEnvironmentVariable</c> reads).
/// </summary>
internal static class RenderPresentationDiagnostics
{
/// <summary>
/// Enter-world gate round (2026-08-17): per-frame presentation
/// classification for the login wormhole edges. When set, every completed
/// render frame is classified by WHAT PRESENTED — <c>world</c> /
/// <c>tunnel</c> / <c>black</c> / <c>void</c> — and a <c>[login-frames]</c>
/// line is written on every classification transition. The gate contract
/// is retail's: the sequence over a login must contain NO <c>void</c>
/// entry on either edge (black → tunnel → world, each swap atomic).
/// Not a user setting; not in <c>RuntimeOptions</c>; not persisted.
/// </summary>
public static bool ProbeLoginFrames { get; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LOGIN_FRAMES") == "1";
}
/// <summary>
/// The frame-level truth for the login wormhole gate: classifies each
/// completed render frame from the same outcome facts the orchestrator
/// publishes, plus the two raw presentation inputs (actual tunnel-scene
/// visibility and the live waiting-for-login latch), and logs one line per
/// TRANSITION so the exact frame sequence at both wormhole edges is
/// auditable.
///
/// <list type="bullet">
/// <item><description><c>world</c> — the normal world viewport drew
/// (<see cref="WorldRenderFrameOutcome.NormalWorldDrawn"/>).</description></item>
/// <item><description><c>tunnel</c> — the portal-space scene was visible and
/// drew over the frame's opaque black.</description></item>
/// <item><description><c>black</c> — the frame presented the portal-viewport
/// black clear with NO tunnel scene (retail's empty pre-player SmartBox:
/// the gameplay screen before CreatePlayer draws no world and no
/// tunnel).</description></item>
/// <item><description><c>void</c> — the world path ran but drew nothing
/// (the sky-only "waiting for login" backdrop, or an unavailable world
/// generation). Retail NEVER presents this during a login — any void entry
/// in a login sequence is the gate defect.</description></item>
/// </list>
/// </summary>
internal sealed class LoginPresentationFrameProbe : IRenderFramePostDiagnosticsPhase
{
private readonly Func<bool> _tunnelSceneVisible;
private readonly IRenderLoginStateSource _login;
private readonly Action<string> _log;
private long _frame;
private double _elapsedSeconds;
private string? _lastClass;
public LoginPresentationFrameProbe(
Func<bool> tunnelSceneVisible,
IRenderLoginStateSource login,
Action<string> log)
{
_tunnelSceneVisible = tunnelSceneVisible
?? throw new ArgumentNullException(nameof(tunnelSceneVisible));
_login = login ?? throw new ArgumentNullException(nameof(login));
_log = log ?? throw new ArgumentNullException(nameof(log));
}
public void Process(RenderFrameInput input, RenderFrameOutcome outcome)
{
_frame++;
_elapsedSeconds += input.DeltaSeconds;
bool world = outcome.World.NormalWorldDrawn;
bool tunnel = _tunnelSceneVisible();
bool cover = outcome.Presentation.PortalViewportDrawn;
bool waiting = _login.IsWaitingForLogin;
string presentClass =
world ? "world"
: tunnel ? "tunnel"
: cover ? "black"
: "void";
if (presentClass == _lastClass)
return;
_lastClass = presentClass;
_log(
$"[login-frames] frame={_frame} t={_elapsedSeconds:F3}s "
+ $"present={presentClass} waiting={(waiting ? 1 : 0)} "
+ $"cover={(cover ? 1 : 0)} tunnel={(tunnel ? 1 : 0)} "
+ $"world={(world ? 1 : 0)}");
}
}

View file

@ -136,13 +136,35 @@ internal sealed class LocalPlayerTeleportRenderStateSource
: IRenderFramePortalStateSource
{
private readonly LocalPlayerTeleportController _teleport;
private readonly IRenderLoginStateSource _login;
public LocalPlayerTeleportRenderStateSource(LocalPlayerTeleportController teleport)
public LocalPlayerTeleportRenderStateSource(
LocalPlayerTeleportController teleport,
IRenderLoginStateSource login)
{
_teleport = teleport ?? throw new ArgumentNullException(nameof(teleport));
_login = login ?? throw new ArgumentNullException(nameof(login));
}
public bool IsPortalViewportVisible => _teleport.IsPortalViewportVisible;
/// <summary>
/// The frame presents the portal-viewport shape (opaque black clear, no
/// world draw, retained UI on top) when the tunnel scene is visible OR
/// while a live login is still pre-world. The second arm is retail's
/// pre-player gameplay screen: after char-select Enter queues UI mode
/// 0x10000008 (<c>CM_Login::SendNotice_BeginEnterWorld @ 0x006AD810</c>
/// from <c>CPlayerSystem::LogOnCharacter @ 0x0055F890</c>), the SmartBox
/// has no player and draws NO world — the screen behind the UI is black
/// until <c>SmartBox::teleport_in_progress @ 0x00451C20</c> goes high at
/// CreatePlayer and <c>gmSmartBoxUI::UseTime @ 0x004D6EAB</c> begins the
/// tunnel in the same tick. The former sky-only "waiting for login"
/// backdrop had no retail counterpart and presented as the gate's
/// entry-edge VOID (2026-08-17). Both flags flip on the update thread
/// (the login activation tick flips ChaseModeEverEntered AND makes the
/// tunnel visible before the next render), so the black → tunnel → world
/// sequence swaps atomically per frame.
/// </summary>
public bool IsPortalViewportVisible =>
_teleport.IsPortalViewportVisible || _login.IsWaitingForLogin;
public uint ActiveDestinationCell => _teleport.ActiveDestinationCell;
}

View file

@ -159,11 +159,13 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
_sky.DayFraction);
}
// Retail keeps the live sky during EnterWorld while suppressing
// terrain and object geometry until chase mode has engaged.
if (_login.IsWaitingForLogin)
return CompleteSkippedWorld();
// The former sky-only "waiting for login" skip lived here.
// It is unreachable now: LocalPlayerTeleportRenderStateSource
// folds IsWaitingForLogin into PortalViewportVisible (retail's
// pre-player gameplay screen draws NO world — black, not sky),
// so the portal-visible return above already covers every
// waiting frame. One gate computes the frame's visibility;
// this phase only enforces it.
_passes.DrawFlatTerrain(in camera, roots.PlayerLandblockId);
terrainDrawn = true;
}
@ -291,16 +293,6 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
diagnostic.VisibleLandblocks,
diagnostic.TotalLandblocks,
NormalWorldDrawn: true);
WorldRenderFrameOutcome CompleteSkippedWorld()
{
CompleteWorldFrame();
worldFrameStarted = false;
pviewFrameStarted = false;
_selection?.CompleteFrame();
selectionFrameStarted = false;
return default;
}
}
catch (Exception renderFailure)
{