From fdc4fd496d3a59aa78553b4a221207a6f01fa009 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 17 Aug 2026 12:30:51 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20gate=20=E2=80=94=20no=20void=20frame?= =?UTF-8?q?s=20around=20the=20login=20wormhole;=20vitals=20icons=20centere?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../retail-divergence-register.md | 2 +- .../Composition/FrameRootComposition.cs | 24 +++- .../Rendering/LoginPresentationFrameProbe.cs | 95 +++++++++++++++ .../RenderFrameResourceController.cs | 26 +++- .../Rendering/WorldSceneRenderer.cs | 22 ++-- .../LocalPlayerTeleportController.cs | 16 ++- .../Streaming/WorldRevealCoordinator.cs | 16 +++ src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 30 ++++- .../UI/Testing/RetailUiAutomationProbe.cs | 15 +++ .../Testing/RetailUiAutomationScriptRunner.cs | 15 ++- src/AcDream.App/UI/UiMeter.cs | 114 +++++++++++++++--- .../World/RuntimeWorldTransitState.cs | 58 +++++++++ .../Rendering/WorldRenderFrameBuilderTests.cs | 7 +- .../Rendering/WorldSceneRendererTests.cs | 38 +++--- .../LocalPlayerTeleportControllerTests.cs | 92 ++++++++++++++ .../UI/Layout/VitalsDetailToggleTests.cs | 82 +++++++++++++ .../World/RuntimeWorldTransitStateTests.cs | 63 ++++++++++ 17 files changed, 649 insertions(+), 66 deletions(-) create mode 100644 src/AcDream.App/Rendering/LoginPresentationFrameProbe.cs 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() {