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

@ -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-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-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-28 | **NARROWED 2026-08-17 (enter-world round; gate-fix round same day)** — 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. The gate-fix round closed both void edges: live pre-world frames present retail's BLACK empty-viewport frame (`LocalPlayerTeleportRenderStateSource` folds `IsWaitingForLogin` into the portal-viewport frame shape — retail's pre-player gameplay screen draws no world; the invented sky-only backdrop is deleted), and the login pump's Place edge acknowledges `RuntimeWorldTransitState.AcknowledgeLoginMaterialized` (retail resumes `CObjectMaint`/`CPhysics` when destination cells stop blocking, while the tunnel is in front) so WorldFadeIn draws the world instead of a void. 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-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 |

View file

@ -239,11 +239,13 @@ internal sealed class FrameRootCompositionPhase
// deleted at slice V11. The graph is the clear pass, the private-
// presentation phase, and the retained UI inside it — the client's
// own frame, drawn entirely through the RHI.
var teleportRenderState =
new LocalPlayerTeleportRenderStateSource(session.LocalTeleport);
var renderLoginState = new RenderLoginStateSource(
d.Options.LiveMode,
d.PlayerMode);
var teleportRenderState =
new LocalPlayerTeleportRenderStateSource(
session.LocalTeleport,
renderLoginState);
// Campaign V slice V6i-3: on Vulkan the frame's clear is a load op of the
// world pass rather than a pass of its own, so the two phases share this
// one value. See VulkanWorldScenePhase for why the merge is required
@ -561,6 +563,24 @@ internal sealed class FrameRootCompositionPhase
lifecycleAutomation)
: (IRenderFramePostDiagnosticsPhase?)lifecycleAutomation
?? NullRenderFramePostDiagnosticsPhase.Instance;
if (RenderPresentationDiagnostics.ProbeLoginFrames)
{
// Enter-world gate round (2026-08-17): the per-frame presentation
// classification probe (world/tunnel/black/void transitions). The
// tunnel fact is the controller's RAW portal-scene visibility, not
// the composed render-state source, so the probe can distinguish
// "covered black" from "tunnel scene drawn".
var loginFrameProbe = new LoginPresentationFrameProbe(
() => session.LocalTeleport.IsPortalViewportVisible,
renderLoginState,
d.Log);
postDiagnostics =
postDiagnostics is NullRenderFramePostDiagnosticsPhase
? loginFrameProbe
: new SerialRenderFramePostDiagnosticsPhase(
postDiagnostics,
loginFrameProbe);
}
var renderFrame = new RenderFrameOrchestrator(
host.GpuFrameLifetime,
// Campaign V slice V8: the Vulkan arm measures the frame bracket

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)
{

View file

@ -1161,11 +1161,23 @@ internal sealed class LocalPlayerTeleportController
return;
break;
case TeleportAnimEvent.Place:
// No login Place edge: the canonical initial placement is
// No login PLACEMENT: 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).
// (SmartBox::UseTime @ 0x00455483) — but that same
// blocking-ends edge is where retail RESUMES
// CObjectMaint/CPhysics (@ 0x00455410), while the tunnel
// is still in front. Acknowledge the login
// materialization/simulation release here, the exact
// login mirror of the teleport pump's
// ObserveMaterialized — without it the world generation
// stayed unavailable until Complete, so the whole
// WorldFadeIn second presented an EMPTY world frame (the
// 2026-08-17 gate's exit-edge void).
_worldReveal.ObserveLoginMaterialized(revealGeneration);
if (!IsCurrentLoginLifetime(generation, revealGeneration))
return;
break;
case TeleportAnimEvent.PlayExitSound:
// Release destination cell blocking at the exact

View file

@ -244,6 +244,22 @@ internal sealed class WorldRevealCoordinator
return acknowledged;
}
/// <summary>
/// The login mirror of <see cref="ObserveMaterialized"/>: acknowledges
/// the login reveal's materialization/simulation-release edge (retail
/// resumes <c>CObjectMaint</c>/<c>CPhysics</c> when destination cells
/// stop blocking, <c>SmartBox::UseTime @ 0x00455483</c> — while the
/// tunnel is still in front). See
/// <c>RuntimeWorldTransitState.AcknowledgeLoginMaterialized</c>.
/// </summary>
public bool ObserveLoginMaterialized(long generation)
{
bool acknowledged =
_transit.AcknowledgeLoginMaterialized(generation);
RetryPendingHostWork();
return acknowledged;
}
public void ObserveWorldViewportVisible()
{
RetryPendingHostWork();

View file

@ -542,13 +542,14 @@ public static class DatWidgetFactory
{
bool passToChildren = info.States.Values.Any(
static s => s.Name is "HideDetail" or "ShowDetail" && s.PassToChildren);
// Each spec carries the overlay's authored edge modes and its
// container's authored size: the overlays author L3/R3 (retail
// CENTER anchors), which is what re-centers the icon when the
// vitals window resizes the meter away from its authored
// width (UiMeter.ComputeDetailOverlayRect).
m.ConfigureDetailOverlay(
backOverlay is not null ? backOverlay.StateMedia["ShowDetail"].File : 0u,
backOverlay?.X ?? 0f, backOverlay?.Y ?? 0f,
backOverlay?.Width ?? 0f, backOverlay?.Height ?? 0f,
frontOverlay is not null ? frontOverlay.StateMedia["ShowDetail"].File : 0u,
frontOverlay?.X ?? 0f, frontOverlay?.Y ?? 0f,
frontOverlay?.Width ?? 0f, frontOverlay?.Height ?? 0f,
DetailOverlaySpec(backOverlay, containers[0]),
DetailOverlaySpec(frontOverlay, containers[1]),
passToChildren);
}
}
@ -672,6 +673,23 @@ public static class DatWidgetFactory
&& c.StateMedia.TryGetValue("ShowDetail", out var media)
&& media.File != 0);
/// <summary>
/// Builds one absorbed overlay spec: the ShowDetail sprite, the authored
/// container-local rect, the authored raw edge-anchor modes, and the
/// container's authored size (the overlay's reflow parent — the container
/// itself authors L1/T1/R1/B1, so its current box always equals the
/// meter's). A null overlay yields the empty spec (Sprite 0 never draws).
/// </summary>
private static UiMeterDetailOverlaySpec DetailOverlaySpec(
ElementInfo? overlay, ElementInfo container)
=> overlay is null
? default
: new UiMeterDetailOverlaySpec(
overlay.StateMedia["ShowDetail"].File,
overlay.X, overlay.Y, overlay.Width, overlay.Height,
overlay.Left, overlay.Top, overlay.Right, overlay.Bottom,
container.Width, container.Height);
private static bool HasStatefulFill(ElementInfo container)
=> container.States.Any(pair =>
pair.Key != UiStateInfo.DirectStateId

View file

@ -148,6 +148,21 @@ public sealed class RetailUiAutomationProbe
return true;
}
/// <summary>
/// Raw synthetic drag between two canvas points (gate-fix round,
/// 2026-08-17): the vitals icon-centering verify needs the vitals window
/// RESIZED away from its authored width (the retail L3/R3 center anchors
/// only become observable on a non-authored meter width), and window
/// resize rides an edge-grip drag no element/item-addressed drag form can
/// express. Same synthetic <see cref="UiRoot"/> pointer route as
/// <see cref="DragItemToElement"/> — never the OS cursor.
/// </summary>
public bool DragAtPoint(int startX, int startY, int endX, int endY)
{
DragAt(startX, startY, endX, endY);
return true;
}
/// <summary>
/// 2026-08-17 morning gate: synthetic pointer HOVER (no click) at an
/// element's center, for rollover/tooltip verification. Deliberately

View file

@ -286,8 +286,21 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
private bool DoDrag(ScriptCommand command)
{
var p = command.Parts;
if (p.Length >= 6
&& string.Equals(p[1], "at", StringComparison.OrdinalIgnoreCase))
{
// `drag at <x1> <y1> <x2> <y2>` — raw synthetic pointer drag in
// canvas coordinates (gate-fix round 2026-08-17: window-resize
// choreography for the vitals icon-centering verify; see
// RetailUiAutomationProbe.DragAtPoint).
if (!TryParseInt(p[2], out int x1) || !TryParseInt(p[3], out int y1)
|| !TryParseInt(p[4], out int x2) || !TryParseInt(p[5], out int y2))
return Stop(command, "usage: drag at <x1> <y1> <x2> <y2>");
return _probe.DragAtPoint(x1, y1, x2, y2)
|| Stop(command, "drag at failed");
}
if (p.Length < 5 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
return Stop(command, "usage: drag item <guid> element <datId> | drag item <guid> item <guid> | drag item <guid> outside <x> <y>");
return Stop(command, "usage: drag item <guid> element <datId> | drag item <guid> item <guid> | drag item <guid> outside <x> <y> | drag at <x1> <y1> <x2> <y2>");
if (!TryParseUInt(p[2], out uint sourceGuid)) return Stop(command, $"bad item guid '{p[2]}'");
string target = p[3].ToLowerInvariant();

View file

@ -7,6 +7,24 @@ namespace AcDream.App.UI;
/// — the same mapping ElementReader applies at element level).</summary>
public enum UiMeterLabelAlign : byte { Left = 0, Center = 1, Right = 2 }
/// <summary>
/// One absorbed vitals detail-icon overlay (the <c>0x100004A9</c> child of a
/// meter's back/front slice container): its ShowDetail sprite, authored rect
/// (local to the container, which spans the meter at 0,0), the authored raw
/// edge-anchor modes (<c>ElementDesc</c> Left/Top/Right/Bottom, values 04),
/// and the container's authored size. The overlays author <c>L3/R3</c> —
/// retail's CENTER anchors — so when the meter is resized away from its
/// authored width the icon re-centers through the exact
/// <see cref="UiLayoutPolicy"/> port of
/// <c>UIElement::UpdateForParentSizeChange @0x00462640</c> instead of staying
/// pinned at its authored X (the gate-observed left-drift).
/// </summary>
internal readonly record struct UiMeterDetailOverlaySpec(
uint Sprite,
float X, float Y, float W, float H,
uint LeftMode, uint TopMode, uint RightMode, uint BottomMode,
float ParentW, float ParentH);
/// <summary>
/// A horizontal vital bar (retail HP/Stamina/Mana style): a background rect, a
/// partial-width solid fill, and an optional centered "current/max" numeric
@ -28,19 +46,22 @@ public sealed class UiMeter : UiElement, IUiDatStateful
// Vitals ShowDetail icon overlays (see ConfigureDetailOverlay).
private bool _detailConfigured;
private bool _detailPassToChildren;
private uint _detailBackSprite;
private uint _detailFrontSprite;
private (float X, float Y, float W, float H) _detailBackRect;
private (float X, float Y, float W, float H) _detailFrontRect;
private UiMeterDetailOverlaySpec _detailBack;
private UiMeterDetailOverlaySpec _detailFront;
/// <summary>True when this meter absorbed the vitals detail-icon overlays. Exposed for tests.</summary>
internal bool HasDetailOverlay => _detailConfigured;
/// <summary>The dim back-container detail icon (ShowDetail media). Exposed for tests.</summary>
internal uint DetailBackSprite => _detailBackSprite;
internal uint DetailBackSprite => _detailBack.Sprite;
/// <summary>The bright fill-clipped front-container detail icon. Exposed for tests.</summary>
internal uint DetailFrontSprite => _detailFrontSprite;
internal uint DetailFrontSprite => _detailFront.Sprite;
/// <summary>The back overlay's authored meter-local rect. Exposed for tests.</summary>
internal (float X, float Y, float W, float H) DetailBackRect => _detailBackRect;
internal (float X, float Y, float W, float H) DetailBackRect =>
(_detailBack.X, _detailBack.Y, _detailBack.W, _detailBack.H);
/// <summary>The complete absorbed back overlay. Exposed for tests.</summary>
internal UiMeterDetailOverlaySpec DetailBack => _detailBack;
/// <summary>The complete absorbed front overlay. Exposed for tests.</summary>
internal UiMeterDetailOverlaySpec DetailFront => _detailFront;
/// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary>
public uint ElementId { get; set; }
@ -142,22 +163,69 @@ public sealed class UiMeter : UiElement, IUiDatStateful
/// <c>m_pcChildImage</c> child, element id 2, to the 0x69 fraction), so
/// the icon itself fills up with the vital. Both overlays author media
/// ONLY for <c>ShowDetail</c> (<c>HideDetail</c> authors File=0), so they
/// draw solely in that state. Rects are the overlays' authored X/Y/W/H
/// local to the meter (the containers span the meter at 0,0).
/// draw solely in that state. Each spec carries the overlay's authored
/// rect (local to its container, which spans the meter at 0,0), its raw
/// edge-anchor modes, and the container's authored size — see
/// <see cref="ComputeDetailOverlayRect"/> for how those position the icon
/// on a resized meter.
/// </summary>
internal void ConfigureDetailOverlay(
uint backSprite, float backX, float backY, float backW, float backH,
uint frontSprite, float frontX, float frontY, float frontW, float frontH,
in UiMeterDetailOverlaySpec back,
in UiMeterDetailOverlaySpec front,
bool passToChildren)
{
_detailBackSprite = backSprite;
_detailBackRect = (backX, backY, backW, backH);
_detailFrontSprite = frontSprite;
_detailFrontRect = (frontX, frontY, frontW, frontH);
_detailConfigured = backSprite != 0 || frontSprite != 0;
_detailBack = back;
_detailFront = front;
_detailConfigured = back.Sprite != 0 || front.Sprite != 0;
_detailPassToChildren = passToChildren;
}
/// <summary>
/// The overlay's effective meter-local rect at the meter's CURRENT size.
/// At the authored container size the authored rect stands verbatim —
/// retail's <c>UIElement::UpdateForParentSizeChange @0x00462640</c> runs
/// only when a parent actually resizes, and the designers hand-placed
/// rects that are not always the exact center formula (the stamina sword
/// authors X=32 where the mode-3 formula yields 33). On any other size
/// the authored edge modes are applied from the ORIGINAL child/parent
/// rects through <see cref="UiLayoutPolicy.Apply"/>, the exact port of
/// that retail routine — the overlays' authored <c>L3/R3</c> center
/// anchors are what keep the heart/sword/scepter centered when the
/// vitals window is resized (retail near mode 3 =
/// <c>curParentW/2 - origChildW/2</c> @0x004627ca; far mode 3 =
/// <c>curParentW/2 + origChildW/2 - 1</c> @0x00462827, integer
/// arithmetic, so odd authored widths lose one pixel exactly as retail
/// does).
/// </summary>
internal static (float X, float Y, float W, float H) ComputeDetailOverlayRect(
in UiMeterDetailOverlaySpec overlay, float meterW, float meterH)
{
int parentW = (int)meterW, parentH = (int)meterH;
int origParentW = (int)overlay.ParentW, origParentH = (int)overlay.ParentH;
if (origParentW <= 0 || origParentH <= 0
|| (parentW == origParentW && parentH == origParentH))
{
return (overlay.X, overlay.Y, overlay.W, overlay.H);
}
var originalChild = UiPixelRect.FromPositionAndSize(
(int)overlay.X, (int)overlay.Y, (int)overlay.W, (int)overlay.H);
var originalParent = UiPixelRect.FromPositionAndSize(
0, 0, origParentW, origParentH);
var currentParent = UiPixelRect.FromPositionAndSize(
0, 0, parentW, parentH);
UiPixelRect effective = UiLayoutPolicy.Apply(
overlay.LeftMode,
overlay.TopMode,
overlay.RightMode,
overlay.BottomMode,
originalChild,
originalParent,
originalChild,
currentParent);
return (effective.X0, effective.Y0, effective.Width, effective.Height);
}
public bool TrySetRetailState(uint stateId)
{
// Vitals detail toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00
@ -271,12 +339,22 @@ public sealed class UiMeter : UiElement, IUiDatStateful
&& ActiveRetailStateId == RetailUiStateIds.ShowDetail;
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
if (detail)
DrawDetailIcon(ctx, resolve, _detailBackSprite, _detailBackRect, Width);
{
DrawDetailIcon(
ctx, resolve, _detailBack.Sprite,
ComputeDetailOverlayRect(in _detailBack, Width, Height),
Width);
}
if (pct is not null && p > 0f)
{
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
if (detail)
DrawDetailIcon(ctx, resolve, _detailFrontSprite, _detailFrontRect, Width * p);
{
DrawDetailIcon(
ctx, resolve, _detailFront.Sprite,
ComputeDetailOverlayRect(in _detailFront, Width, Height),
Width * p);
}
}
}
}

View file

@ -649,6 +649,64 @@ public sealed class RuntimeWorldTransitState
return true;
}
/// <summary>
/// The LOGIN half of the materialization/simulation edge. Retail's
/// <c>SmartBox::UseTime @ 0x00455410</c> <c>blocking_for_cells</c> branch
/// resumes <c>CObjectMaint</c>/<c>CPhysics</c> the moment destination
/// cells stop blocking — while the tunnel is still in front, and
/// IDENTICALLY for the initial login and an F751 teleport (retail has one
/// <c>teleport_in_progress</c> flow). The portal route rides
/// <see cref="AcknowledgePortalMaterialized"/> at its Place edge; the
/// login route has no Place (the first-entry conductor already committed
/// the canonical placement), so this acknowledges the same simulation
/// release at the login pump's tunnel-hold-end edge. Without it the world
/// stayed unavailable until <see cref="Complete"/> — the WorldFadeIn
/// second presented an empty (void) world frame instead of retail's
/// world-under-warp (2026-08-17 gate).
/// </summary>
public bool AcknowledgeLoginMaterialized(long generation)
{
if (generation == 0
|| generation != _snapshot.Generation
|| _snapshot.Kind != RuntimePortalKind.Login)
{
LogRejected(
"materialized-login-mismatch",
$"generation={generation} kind={_snapshot.Kind}");
return false;
}
if (!ValidateActive(
generation,
_snapshot.DestinationCell,
"materialized"))
{
return false;
}
if (_snapshot.Materialized)
return false;
if (!_snapshot.Readiness.IsReady)
{
FailInvariant("materialized-before-ready", null);
return false;
}
// PortalMaterializationCount deliberately unchanged: it counts
// Kind == Portal materializations only, exactly like the portal
// acknowledgement's own conditional increment.
_snapshot = _snapshot with
{
Materialized = true,
WorldSimulationAvailable = true,
};
RequireHostStage(
generation,
RuntimeWorldHostAcknowledgementStage
.SimulationReleaseProjected);
Log("materialized", _snapshot);
return true;
}
public bool AcknowledgeWorldViewportVisible(long generation)
{
if (!ValidateGeneration(

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()
{