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

@ -362,11 +362,16 @@ public sealed class WorldRenderFrameBuilderTests
"private AcDream.App.Rendering.SkyPesFrameController?",
gameWindow,
StringComparison.Ordinal);
// Gate-fix round (2026-08-17): the renderer's own IsWaitingForLogin
// short-circuit is deleted — the frame gate
// (LocalPlayerTeleportRenderStateSource) folds the waiting state
// into PortalViewportVisible, so the portal-visible return above
// covers every waiting frame (one gate computes, this phase
// enforces).
AssertAppearsInOrder(
worldScene,
"_alpha.BeginFrame();",
"_frames.Build(",
"if (_login.IsWaitingForLogin)",
"_passes.DrawFlatTerrain(",
"NormalWorldDrawn: true");
AssertAppearsInOrder(

View file

@ -45,29 +45,31 @@ public sealed class WorldSceneRendererTests
}
[Fact]
public void LoginWait_DrawsFlatSkyThenCompletesFrameWithoutWorldGeometry()
public void LoginWait_IsOwnedByTheFrameGate_NotByARendererShortCircuit()
{
// Gate-fix round (2026-08-17): the sky-only waiting skip is DELETED.
// Live pre-world frames present the portal-viewport frame shape —
// LocalPlayerTeleportRenderStateSource folds IsWaitingForLogin into
// PortalViewportVisible (retail's pre-player gameplay screen draws
// NO world: black behind the UI, never a sky-only backdrop), so in
// production this renderer NEVER runs while waiting. One gate
// computes the frame's visibility and this phase only enforces it: a
// waiting flag without the portal cover (unreachable in production)
// draws the ordinary flat world rather than re-implementing a second
// gate here.
var rig = new Rig(portalVisible: false, waitingForLogin: true, clipRoot: null);
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
Assert.False(result.NormalWorldDrawn);
Assert.Equal(0, result.VisibleLandblocks);
Assert.Equal(0, result.TotalLandblocks);
Assert.Equal(
[
"selection:begin",
"alpha:begin",
"frame:build",
"passes:begin",
"flat:clip",
"flat:sky",
"alpha:end",
"visibility:mark",
"visibility:complete",
"selection:complete",
],
rig.Calls);
Assert.True(result.NormalWorldDrawn);
Assert.True(rig.Frames.WaitingForLogin);
Assert.Contains("flat:terrain", rig.Calls);
// The production waiting frame: the portal cover is set, the world
// is skipped whole, and only the selection frame brackets run.
var covered = new Rig(portalVisible: true, waitingForLogin: true, clipRoot: null);
Assert.Equal(default, covered.Renderer.Render(default));
Assert.Equal(["selection:begin", "selection:complete"], covered.Calls);
}
[Fact]

View file

@ -1631,6 +1631,98 @@ public sealed class LocalPlayerTeleportControllerTests
Assert.Equal(loginCompletes, harness.Session.LoginCompleteCount);
}
// ── Gate-fix round (2026-08-17): void frames at the wormhole edges ──
[Fact]
public void LoginPlaceEdge_ReleasesWorldSimulationWhileTunnelInFront()
{
var harness = new Harness(worldReady: false);
harness.Reveal.BeginLogin(0x20210001u);
harness.Controller.Tick(0.016f);
harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel);
harness.Controller.Tick(0.016f);
Assert.True(harness.Presentation.IsPortalViewportVisible);
Assert.False(harness.Transit.IsWorldSimulationAvailable);
// The hold ends: destination readiness + canonical first placement.
harness.WorldReady = true;
harness.Controller.OnLocalPlayerFirstEntryCompleted();
harness.Controller.Tick(0.016f);
// The login Place edge is retail's blocking-ends edge —
// CObjectMaint/CPhysics resume (SmartBox::UseTime @ 0x00455410)
// while the tunnel is STILL in front. The world generation must be
// available before the viewport swap so WorldFadeIn's first frame
// draws the world instead of an empty void.
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
harness.Controller.Tick(0.016f);
Assert.True(harness.Presentation.IsPortalViewportVisible);
Assert.True(harness.Transit.IsWorldSimulationAvailable);
Assert.True(harness.Transit.Snapshot.Materialized);
Assert.False(harness.Transit.Snapshot.Completed);
Assert.False(harness.Placement.Called);
// Swap edge: the tunnel retires on this same tick and the world is
// already available — no frame can exist where neither presents.
harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
harness.Controller.Tick(0.016f);
Assert.False(harness.Presentation.IsPortalViewportVisible);
Assert.True(harness.Transit.IsWorldSimulationAvailable);
// The pump completes exactly as before.
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 LoginCover_PresentsPortalViewportShapeUntilChaseModeEntered()
{
// Entry-edge contract: retail's pre-player gameplay screen draws NO
// world (black behind the UI) between char-select Enter and
// CreatePlayer (CPlayerSystem::LogOnCharacter @ 0x0055F890 queues
// game mode immediately; SmartBox::teleport_in_progress
// @ 0x00451C20 stays low until the player exists). The composed
// render-state source must therefore present the portal-viewport
// frame shape for every live pre-world frame — the sky-only void
// backdrop must never present.
var harness = new Harness(worldReady: false);
var login = new StubLoginState { IsWaitingForLogin = true };
var source = new LocalPlayerTeleportRenderStateSource(
harness.Controller, login);
// Pre-reveal: no tunnel scene, but the login cover holds the frame
// black.
Assert.False(harness.Presentation.IsPortalViewportVisible);
Assert.True(source.IsPortalViewportVisible);
// Tunnel armed (the activation tick flips ChaseModeEverEntered and
// the tunnel visibility together, before the next render): the
// cover hands off to the tunnel with no gap.
harness.Reveal.BeginLogin(0x20210001u);
harness.Controller.Tick(0.016f);
harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel);
harness.Controller.Tick(0.016f);
login.IsWaitingForLogin = false;
Assert.True(source.IsPortalViewportVisible);
// Swap edge: tunnel retires -> the world frame presents (the cover
// stays off; ChaseModeEverEntered never reverts mid-session).
harness.WorldReady = true;
harness.Controller.OnLocalPlayerFirstEntryCompleted();
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
harness.Controller.Tick(0.016f);
harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
harness.Controller.Tick(0.016f);
Assert.False(source.IsPortalViewportVisible);
}
private sealed class StubLoginState : IRenderLoginStateSource
{
public bool IsWaitingForLogin { get; set; }
}
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
{
private readonly List<string> _order;

View file

@ -72,6 +72,88 @@ public class VitalsDetailToggleTests
Assert.Equal((66f, 0f, 18f, 16f), health.DetailBackRect);
}
[Fact]
public void VitalsTree_OverlaysCarryTheAuthoredCenterAnchors()
{
// The 0x100004A9 overlays author LeftEdge=3 / RightEdge=3 — retail's
// CENTER anchor modes (UIElement::UpdateForParentSizeChange
// @ 0x004627ca / 0x00462827) — with Top=1/Bottom=1, inside the
// 150x16 slice containers. These are what keep the icons centered
// when the vitals window resizes the meters (2026-08-17 gate:
// absorbed overlays previously kept only the authored rect, pinning
// the icon at its authored X on any wider meter).
var layout = FixtureLoader.LoadVitals();
foreach (uint meterId in new[]
{
VitalsController.Health,
VitalsController.Stamina,
VitalsController.Mana,
})
{
var m = Assert.IsType<UiMeter>(layout.FindElement(meterId));
foreach (UiMeterDetailOverlaySpec overlay in new[] { m.DetailBack, m.DetailFront })
{
Assert.Equal(3u, overlay.LeftMode);
Assert.Equal(1u, overlay.TopMode);
Assert.Equal(3u, overlay.RightMode);
Assert.Equal(1u, overlay.BottomMode);
Assert.Equal(150f, overlay.ParentW);
Assert.Equal(16f, overlay.ParentH);
}
}
}
// ── Icon placement: authored at authored size, centered on resize ────
[Fact]
public void DetailIcon_AuthoredRectStandsAtTheAuthoredMeterSize()
{
// Retail reflows a child ONLY when its parent actually resizes; at
// the authored size the designer's hand-placed rect stands — the
// stamina sword authors X=32 where the mode-3 center formula yields
// 33, so recomputing unconditionally would shift the un-resized
// window by a pixel.
var sword = new UiMeterDetailOverlaySpec(
0x06007492u, 32f, 0f, 85f, 16f, 3u, 1u, 3u, 1u, 150f, 16f);
Assert.Equal(
(32f, 0f, 85f, 16f),
UiMeter.ComputeDetailOverlayRect(in sword, 150f, 16f));
}
[Theory]
// Heart 18x16: near = W/2 - 9, far = W/2 + 9 - 1 -> width preserved.
[InlineData(66f, 18f, 300f, 141f, 18f)]
[InlineData(66f, 18f, 220f, 101f, 18f)]
// Scepter 100x16: near = W/2 - 50.
[InlineData(25f, 100f, 300f, 100f, 100f)]
public void DetailIcon_CentersOnAWiderMeter(
float authoredX, float authoredW, float meterW,
float expectedX, float expectedW)
{
var overlay = new UiMeterDetailOverlaySpec(
0x06007490u, authoredX, 0f, authoredW, 16f, 3u, 1u, 3u, 1u, 150f, 16f);
(float x, float y, float w, float h) =
UiMeter.ComputeDetailOverlayRect(in overlay, meterW, 16f);
Assert.Equal(expectedX, x);
Assert.Equal(0f, y);
Assert.Equal(expectedW, w);
Assert.Equal(16f, h);
}
[Fact]
public void DetailIcon_OddWidthLosesOnePixelExactlyLikeRetail()
{
// Sword 85px: retail's integer halves (>>1) give near = 150 - 42 =
// 108 and far = 150 + 42 - 1 = 191 on a 300px meter -> width 84,
// one pixel narrower than authored. Faithful, not a bug.
var sword = new UiMeterDetailOverlaySpec(
0x06007492u, 32f, 0f, 85f, 16f, 3u, 1u, 3u, 1u, 150f, 16f);
(float x, float _, float w, float _) =
UiMeter.ComputeDetailOverlayRect(in sword, 300f, 16f);
Assert.Equal(108f, x);
Assert.Equal(84f, w);
}
// ── The press toggle ─────────────────────────────────────────────────────
[Fact]

View file

@ -192,6 +192,69 @@ public sealed class RuntimeWorldTransitStateTests
Assert.Equal(0, state.Snapshot.InvariantFailureCount);
}
[Fact]
public void LoginMaterialization_ReleasesSimulationBeforeViewportAndCompletion()
{
// The login mirror of the portal materialization edge: retail's
// SmartBox::UseTime @ 0x00455410 blocking_for_cells branch resumes
// CObjectMaint/CPhysics when destination cells stop blocking — while
// the tunnel is still in front — identically for the initial login.
// Without this edge the login world stayed unavailable until
// Complete, so the WorldFadeIn second presented an empty world frame
// (the 2026-08-17 gate's exit-edge void).
var state = new RuntimeWorldTransitState();
long generation = state.BeginLoginReveal(OutdoorCell);
Assert.True(state.AcknowledgeDestinationReadiness(
Ready(generation, OutdoorCell)));
Assert.True(state.AcknowledgeLoginMaterialized(generation));
Assert.True(state.IsWorldSimulationAvailable);
Assert.True(state.Snapshot.Materialized);
Assert.False(state.Snapshot.WorldViewportObserved);
Assert.False(state.Snapshot.Completed);
// The portal materialization counter counts Kind == Portal only.
Assert.Equal(0, state.Snapshot.PortalMaterializationCount);
Assert.False(state.AcknowledgeLoginMaterialized(generation));
Assert.True(state.AcknowledgeWorldViewportVisible(generation));
Assert.True(state.Complete(generation));
Assert.True(state.Snapshot.Completed);
Assert.Equal(0, state.Snapshot.InvariantFailureCount);
}
[Fact]
public void LoginMaterializationBeforeReadiness_IsRejectedWithoutOpeningWorld()
{
var state = new RuntimeWorldTransitState();
long generation = state.BeginLoginReveal(OutdoorCell);
Assert.False(state.AcknowledgeLoginMaterialized(generation));
Assert.False(state.IsWorldSimulationAvailable);
Assert.False(state.Snapshot.Materialized);
Assert.Equal(1, state.Snapshot.InvariantFailureCount);
}
[Fact]
public void LoginMaterialization_RejectsPortalRevealsAndStaleGenerations()
{
var state = new RuntimeWorldTransitState();
long generation = BeginPortal(state, OutdoorCell);
Assert.True(state.AcknowledgeDestinationReadiness(
Ready(generation, OutdoorCell)));
// Wrong kind: a portal reveal must ride the sequence-correlated
// portal acknowledgement, never the login edge.
Assert.False(state.AcknowledgeLoginMaterialized(generation));
Assert.False(state.IsWorldSimulationAvailable);
// Stale/zero generations are rejected without invariant failures.
Assert.False(state.AcknowledgeLoginMaterialized(0));
Assert.False(state.AcknowledgeLoginMaterialized(generation + 1));
Assert.Equal(0, state.Snapshot.InvariantFailureCount);
}
[Fact]
public void EarlyViewport_IsRejectedWithoutFabricatingVisibility()
{