diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index d2bf3806..59aff0dc 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -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 |
diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index 2d31ed23..c48ee93e 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -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
diff --git a/src/AcDream.App/Rendering/LoginPresentationFrameProbe.cs b/src/AcDream.App/Rendering/LoginPresentationFrameProbe.cs
new file mode 100644
index 00000000..df2b35ec
--- /dev/null
+++ b/src/AcDream.App/Rendering/LoginPresentationFrameProbe.cs
@@ -0,0 +1,95 @@
+using System;
+
+namespace AcDream.App.Rendering;
+
+///
+/// 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
+/// GetEnvironmentVariable reads).
+///
+internal static class RenderPresentationDiagnostics
+{
+ ///
+ /// 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 — world /
+ /// tunnel / black / void — and a [login-frames]
+ /// line is written on every classification transition. The gate contract
+ /// is retail's: the sequence over a login must contain NO void
+ /// entry on either edge (black → tunnel → world, each swap atomic).
+ /// Not a user setting; not in RuntimeOptions; not persisted.
+ ///
+ public static bool ProbeLoginFrames { get; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_LOGIN_FRAMES") == "1";
+}
+
+///
+/// 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.
+///
+///
+/// - world — the normal world viewport drew
+/// ().
+/// - tunnel — the portal-space scene was visible and
+/// drew over the frame's opaque black.
+/// - black — 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).
+/// - void — 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.
+///
+///
+internal sealed class LoginPresentationFrameProbe : IRenderFramePostDiagnosticsPhase
+{
+ private readonly Func _tunnelSceneVisible;
+ private readonly IRenderLoginStateSource _login;
+ private readonly Action _log;
+ private long _frame;
+ private double _elapsedSeconds;
+ private string? _lastClass;
+
+ public LoginPresentationFrameProbe(
+ Func tunnelSceneVisible,
+ IRenderLoginStateSource login,
+ Action 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)}");
+ }
+}
diff --git a/src/AcDream.App/Rendering/RenderFrameResourceController.cs b/src/AcDream.App/Rendering/RenderFrameResourceController.cs
index 0d9eb916..feef4867 100644
--- a/src/AcDream.App/Rendering/RenderFrameResourceController.cs
+++ b/src/AcDream.App/Rendering/RenderFrameResourceController.cs
@@ -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;
+ ///
+ /// 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 (CM_Login::SendNotice_BeginEnterWorld @ 0x006AD810
+ /// from CPlayerSystem::LogOnCharacter @ 0x0055F890), the SmartBox
+ /// has no player and draws NO world — the screen behind the UI is black
+ /// until SmartBox::teleport_in_progress @ 0x00451C20 goes high at
+ /// CreatePlayer and gmSmartBoxUI::UseTime @ 0x004D6EAB 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.
+ ///
+ public bool IsPortalViewportVisible =>
+ _teleport.IsPortalViewportVisible || _login.IsWaitingForLogin;
public uint ActiveDestinationCell => _teleport.ActiveDestinationCell;
}
diff --git a/src/AcDream.App/Rendering/WorldSceneRenderer.cs b/src/AcDream.App/Rendering/WorldSceneRenderer.cs
index 3a5f1d47..bb8b93d7 100644
--- a/src/AcDream.App/Rendering/WorldSceneRenderer.cs
+++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs
@@ -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)
{
diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
index 9b447e78..3d672184 100644
--- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
+++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
@@ -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
diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs
index d14117ff..d8ee6b44 100644
--- a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs
+++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs
@@ -244,6 +244,22 @@ internal sealed class WorldRevealCoordinator
return acknowledged;
}
+ ///
+ /// The login mirror of : acknowledges
+ /// the login reveal's materialization/simulation-release edge (retail
+ /// resumes CObjectMaint/CPhysics when destination cells
+ /// stop blocking, SmartBox::UseTime @ 0x00455483 — while the
+ /// tunnel is still in front). See
+ /// RuntimeWorldTransitState.AcknowledgeLoginMaterialized.
+ ///
+ public bool ObserveLoginMaterialized(long generation)
+ {
+ bool acknowledged =
+ _transit.AcknowledgeLoginMaterialized(generation);
+ RetryPendingHostWork();
+ return acknowledged;
+ }
+
public void ObserveWorldViewportVisible()
{
RetryPendingHostWork();
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index a037ed75..29b71eeb 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -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);
+ ///
+ /// 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).
+ ///
+ 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
diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs
index 3818ca03..4afe87f0 100644
--- a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs
+++ b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs
@@ -148,6 +148,21 @@ public sealed class RetailUiAutomationProbe
return true;
}
+ ///
+ /// 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 pointer route as
+ /// — never the OS cursor.
+ ///
+ public bool DragAtPoint(int startX, int startY, int endX, int endY)
+ {
+ DragAt(startX, startY, endX, endY);
+ return true;
+ }
+
///
/// 2026-08-17 morning gate: synthetic pointer HOVER (no click) at an
/// element's center, for rollover/tooltip verification. Deliberately
diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
index 7a0c9c7c..ca5ab3a9 100644
--- a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
+++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
@@ -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 ` — 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 ");
+ 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 element | drag item item | drag item outside ");
+ return Stop(command, "usage: drag item element | drag item item | drag item outside | drag at ");
if (!TryParseUInt(p[2], out uint sourceGuid)) return Stop(command, $"bad item guid '{p[2]}'");
string target = p[3].ToLowerInvariant();
diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs
index 6a90d87e..bfa0f4bb 100644
--- a/src/AcDream.App/UI/UiMeter.cs
+++ b/src/AcDream.App/UI/UiMeter.cs
@@ -7,6 +7,24 @@ namespace AcDream.App.UI;
/// — the same mapping ElementReader applies at element level).
public enum UiMeterLabelAlign : byte { Left = 0, Center = 1, Right = 2 }
+///
+/// One absorbed vitals detail-icon overlay (the 0x100004A9 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 (ElementDesc Left/Top/Right/Bottom, values 0–4),
+/// and the container's authored size. The overlays author L3/R3 —
+/// retail's CENTER anchors — so when the meter is resized away from its
+/// authored width the icon re-centers through the exact
+/// port of
+/// UIElement::UpdateForParentSizeChange @0x00462640 instead of staying
+/// pinned at its authored X (the gate-observed left-drift).
+///
+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);
+
///
/// 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;
/// True when this meter absorbed the vitals detail-icon overlays. Exposed for tests.
internal bool HasDetailOverlay => _detailConfigured;
/// The dim back-container detail icon (ShowDetail media). Exposed for tests.
- internal uint DetailBackSprite => _detailBackSprite;
+ internal uint DetailBackSprite => _detailBack.Sprite;
/// The bright fill-clipped front-container detail icon. Exposed for tests.
- internal uint DetailFrontSprite => _detailFrontSprite;
+ internal uint DetailFrontSprite => _detailFront.Sprite;
/// The back overlay's authored meter-local rect. Exposed for tests.
- 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);
+ /// The complete absorbed back overlay. Exposed for tests.
+ internal UiMeterDetailOverlaySpec DetailBack => _detailBack;
+ /// The complete absorbed front overlay. Exposed for tests.
+ internal UiMeterDetailOverlaySpec DetailFront => _detailFront;
/// Dat element id, set by the layout importer so duplicated page copies can be scoped.
public uint ElementId { get; set; }
@@ -142,22 +163,69 @@ public sealed class UiMeter : UiElement, IUiDatStateful
/// m_pcChildImage child, element id 2, to the 0x69 fraction), so
/// the icon itself fills up with the vital. Both overlays author media
/// ONLY for ShowDetail (HideDetail 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
+ /// for how those position the icon
+ /// on a resized meter.
///
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;
}
+ ///
+ /// 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 UIElement::UpdateForParentSizeChange @0x00462640 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 , the exact port of
+ /// that retail routine — the overlays' authored L3/R3 center
+ /// anchors are what keep the heart/sword/scepter centered when the
+ /// vitals window is resized (retail near mode 3 =
+ /// curParentW/2 - origChildW/2 @0x004627ca; far mode 3 =
+ /// curParentW/2 + origChildW/2 - 1 @0x00462827, integer
+ /// arithmetic, so odd authored widths lose one pixel exactly as retail
+ /// does).
+ ///
+ 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);
+ }
}
}
}
diff --git a/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs b/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs
index 43d4a34d..cb531b66 100644
--- a/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs
+++ b/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs
@@ -649,6 +649,64 @@ public sealed class RuntimeWorldTransitState
return true;
}
+ ///
+ /// The LOGIN half of the materialization/simulation edge. Retail's
+ /// SmartBox::UseTime @ 0x00455410 blocking_for_cells branch
+ /// resumes CObjectMaint/CPhysics 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
+ /// teleport_in_progress flow). The portal route rides
+ /// 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 — the WorldFadeIn
+ /// second presented an empty (void) world frame instead of retail's
+ /// world-under-warp (2026-08-17 gate).
+ ///
+ 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(
diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs
index a2812357..ca519a80 100644
--- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs
@@ -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(
diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
index d2ac9dc6..1be9e085 100644
--- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
@@ -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]
diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
index e9174380..66bbd11d 100644
--- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
@@ -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 _order;
diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs
index 33bc7009..fadebc27 100644
--- a/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs
@@ -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(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]
diff --git a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs
index 59f2debe..790ae838 100644
--- a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs
@@ -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()
{