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:
parent
2f8c046aba
commit
fdc4fd496d
17 changed files with 649 additions and 66 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue