diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index b1d2689d..d2bf3806 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -53,7 +53,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port β keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms). Synthetic wrapper borders/whole-window drag regions use the exact DAT Type-2/Type-9 control cursors. `gmPanelUI` children are independently imported retained frames rather than one physical parent, but `RetailPanelUiController` owns their one canonical geometry and exclusive child lifecycle | `src/AcDream.App/UI/README.md:3`; `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/UI/Layout/RetailPanelUiController.cs` | keystone.dll has no PDB/decomp; semantics are reconstructed from retail UI deep-dives, named client methods, and production LayoutDesc media. Separate child wrappers preserve each LayoutDesc's content tree while typed move/resize synchronization gives all registered toolbar/detail children the same persistent parent rectangle | Edge-case low-level input semantics can differ silently even though outer geometry, visibility, and restore-previous ownership match | `UIElementManager::CheckCursor` 0x0045ABF0; `UIElement_Resizebar::StartMouseResizing` 0x0046B7E0; `UIElement_Dragbar::StartMouseMoving` 0x0046C760; `gmPanelUI::SetupChildren` 0x004BC9E0; docs/research/retail-ui/04-input-events.md |
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them β dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WBβretail deltas resolved case-by-case β terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's π’/π΄ split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
-| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts with per-window authored-geometry revisions, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x2100006F` (Campaign CH slice CH6a corrected this from the previously-imported, unrelated `0x21000006`), toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/ExternalContainerController.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` |
+| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts with per-window authored-geometry revisions, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C` plus the side-by-side vitals row `0x21000075` (the vitals retail-modes round, 2026-08-17 β visibility swapped on the SideBySideVitals option by `VitalsSideBySideController`, retail `@0x004E9DA0`), chat `0x2100006F` (Campaign CH slice CH6a corrected this from the previously-imported, unrelated `0x21000006`), toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/ExternalContainerController.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` |
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) β retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE β pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 β the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) β the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`). This row is auto-acquisition-only: as of #298, explicit-target admission and the combat camera route through the separate, retail-exact `WorldSelectionQuery.IsAttackableTarget` (`ObjectIsAttackable`-backed) instead, so a compatible-PK player is a valid manual attack/camera target β do not assume one predicate still serves both concerns. | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` |
@@ -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-03** β F751 teleports resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login no longer acknowledges raw PlayerCreate receipt: graphical and prepared headless hosts send exactly once after canonical local-player first placement; content-less headless sends after its accepted direct Create because it has no placement conductor. Residual: initial login still does not enter the full portal-space presentation. | `src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs`; `src/AcDream.App/Net/GraphicalSessionEventRoute.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs`; `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs`; `src/AcDream.Core.Net/WorldSession.cs` | Initial placement is now the shared readiness contract that releases ACE's intentional Hidden/pink-bubble state without racing presentation. The content-less direct host uses its only truthful admission edge. | The persistent login materialization haze is fixed and server updates no longer unlock before canonical placement. The remaining difference is presentation-only: initial login skips retail's wormhole sequence. | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` |
+| 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-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/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs
index ee8903d3..faa2e1d5 100644
--- a/src/AcDream.App/Input/PlayerModeController.cs
+++ b/src/AcDream.App/Input/PlayerModeController.cs
@@ -141,13 +141,39 @@ internal sealed class PlayerModeController :
if (Controller is null && !TryEnter("teleport"))
return false;
- if (Controller is not { } controller)
+ // Enter-world round (2026-08-17): the Runtime first-entry conductor
+ // can have CONSTRUCTED the controller without publishing it yet
+ // (CandidatePreparing/Sealed/Dormant). A State write in that window
+ // throws (EnsurePublishedForRuntimeOperation); CanExecuteLiveMovement
+ // is the documented skip-instead-of-fault predicate for exactly this
+ // pre-publication login window. Refuse β every caller retries on its
+ // own tick cadence.
+ if (Controller is not { CanExecuteLiveMovement: true } controller)
return false;
controller.State = PlayerState.PortalSpace;
return true;
}
+ ///
+ /// Enter-world round (2026-08-17): the login tunnel's portal-space entry.
+ /// The login wormhole begins before any player-mode entry has happened
+ /// (retail's camera set simply follows its always-present player), so
+ /// this first performs the SAME presentation attach the post-reveal
+ /// auto-entry used to run ( β chase camera, shadow,
+ /// animation sinks, approach lifetime), then flips into portal space.
+ /// itself refuses until the Runtime first-entry
+ /// conductor commits, so pre-publication calls fail closed and retry.
+ ///
+ public bool TryEnterPortalSpaceForLogin()
+ {
+ _autoEntry?.Cancel();
+ if (!_mode.IsPlayerMode && !TryEnter("login"))
+ return false;
+
+ return TryEnterPortalSpace();
+ }
+
public void EnterWorld()
{
if (Controller is { } controller)
diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
index cecf9923..9791477e 100644
--- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
+++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
@@ -337,16 +337,25 @@ internal sealed class LiveSessionRuntimeFactory
_world.FirstEntryDrive,
_ =>
{
- session.SendGameAction(GameActionLoginComplete.Build());
+ // Enter-world round (2026-08-17): the graphical host no
+ // longer sends LoginComplete here. Retail sends 0xA1 at the
+ // END of the login wormhole (gmSmartBoxUI::UseTime
+ // @0x004D745D -> CPlayerSystem::SendLoginCompleteNotification
+ // @0x00562E90), and the login portal-space presentation now
+ // runs on this host, so the send rides its
+ // FireLoginComplete edge (LocalPlayerTeleportController's
+ // login pump) β the same edge the F751 pump already uses.
+ // This notification is the presentation's worldReady latch:
+ // the canonical local-player first placement committed
+ // (retail's position_update_complete=1 analogue,
+ // SmartBox::UseTime @0x00455483).
+ _world.Teleport.OnLocalPlayerFirstEntryCompleted();
// Night-round review F2: CM_House::Event_QueryHouse @0x006aaa00
// is tail-called, unconditionally, from the end of
- // CPlayerSystem::InitializePlayer @0x00563570 β the SAME
- // once-per-session function AttemptSendLoginCompleteNotification
- // lives in (guarded by player_initialized), right after that
- // notification. This is the graphical host's direct
- // (non-portal) first-entry completion edge β the exact
- // analogue. Portal-space re-entries (LocalPlayerTeleportController)
- // do NOT resend it, matching retail's single-shot guard.
+ // CPlayerSystem::InitializePlayer @0x00563570 (guarded by
+ // player_initialized, once per session) β an object-arrival
+ // edge, NOT a tunnel edge, so it stays at first-entry
+ // completion rather than moving with LoginComplete.
session.SendHouseQuery();
},
_world.AcceptedPositionDrive,
diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
index ec960380..9b447e78 100644
--- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
+++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
@@ -25,6 +25,21 @@ internal interface ILocalPlayerTeleportNetworkSink
RuntimeTeleportDestination destination,
bool teleportTimestampAdvanced);
+ ///
+ /// Enter-world round (2026-08-17): the graphical host's local-player
+ /// first-entry conductor completed its canonical initial placement.
+ /// This is the login analogue of retail's
+ /// SmartBox::UseTime @ 0x00455410 setting
+ /// position_update_complete = 1 β the fact that lets the login
+ /// portal-space presentation leave its Tunnel hold. LoginComplete
+ /// (0xA1) itself now rides the presentation's own
+ /// edge, matching
+ /// retail's send at the WorldFadeIn end
+ /// (gmSmartBoxUI::UseTime @ 0x004D745D β
+ /// CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90).
+ ///
+ void OnLocalPlayerFirstEntryCompleted();
+
void ResetSession();
void ResetGenerationPresentation();
@@ -65,6 +80,9 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
bool teleportTimestampAdvanced) =>
Required().OfferDestination(destination, teleportTimestampAdvanced);
+ public void OnLocalPlayerFirstEntryCompleted() =>
+ Required().OnLocalPlayerFirstEntryCompleted();
+
public void ResetSession() => Required().ResetSession();
public void ResetGenerationPresentation() =>
@@ -102,6 +120,19 @@ internal interface ILocalPlayerTeleportModeOperations
PlayerMovementController? Controller { get; }
Matrix4x4 Projection { get; }
bool TryEnterPortalSpace();
+
+ ///
+ /// Enter-world round (2026-08-17): the LOGIN arm's portal-space entry.
+ /// Unlike β whose F751 callers run when
+ /// player-mode presentation is already attached (or deliberately absent,
+ /// e.g. fly mode) β the login tunnel begins BEFORE any player-mode entry
+ /// has happened, so this operation must also perform the same
+ /// presentation attach the post-reveal auto-entry used to do (chase
+ /// camera, shadow, animation sinks) before flipping the controller into
+ /// portal space. Refuses (retryable) until the Runtime first-entry
+ /// conductor has published the movement controller.
+ ///
+ bool TryEnterPortalSpaceForLogin();
void EnterWorld();
}
@@ -449,6 +480,37 @@ internal sealed class LocalPlayerTeleportController
private long _lifetimeGeneration;
private bool _disposed;
+ ///
+ /// Enter-world round (2026-08-17): the login reveal generation this
+ /// controller's presentation currently owns (0 = none). Retail runs the
+ /// SAME wormhole machine at initial login as at an F751 teleport β
+ /// SmartBox::teleport_in_progress @ 0x00451C20 returns 1 whenever
+ /// the SmartBox has a player whose position_update_complete is
+ /// still 0, which is true the moment the login CreatePlayer lands, and
+ /// gmSmartBoxUI::UseTime @ 0x004D6EAB edge-detects that flag into
+ /// BeginTeleportAnimation(TAS_TUNNEL) @ 0x004D6EC9 (which plays
+ /// Sound_UI_EnterPortal @ 0x004D638E) with no F751 involved.
+ /// acdream's canonical equivalent of that condition is Runtime's login
+ /// reveal (,
+ /// begun on the first accepted local-player position) β so this arm keys
+ /// the same presentation off that Runtime-owned lifecycle instead of a
+ /// teleport start.
+ ///
+ private long _loginRevealGeneration;
+ private bool _loginPresentationActive;
+
+ ///
+ /// Latched by β the
+ /// first-entry conductor's canonical initial placement committed. The
+ /// login pump's worldReady requires it, so the sequencer cannot
+ /// leave its Tunnel hold (and therefore cannot reach
+ /// ) before the same
+ /// placement contract that previously gated the immediate LoginComplete
+ /// send. Session-scoped: cleared only by session-level resets.
+ ///
+ private bool _loginPlacementCompleted;
+ private float _loginHoldSeconds;
+
public LocalPlayerTeleportController(
ILocalPlayerTeleportAuthority authority,
ILocalPlayerTeleportInputLifetime input,
@@ -476,8 +538,35 @@ internal sealed class LocalPlayerTeleportController
public bool IsActive => _transit.IsTeleportActive;
public bool IsPortalViewportVisible => _presentation.IsPortalViewportVisible;
- public uint ActiveDestinationCell =>
- _transit.IsTeleportActive ? _pendingCell : 0u;
+
+ ///
+ /// The destination the portal viewport is currently holding for. The
+ /// render frame's reveal-preparation arm
+ /// ()
+ /// keys composite-texture preparation and readiness evaluation off this
+ /// cell; the login presentation must report its own destination here
+ /// because entering portal space flips ChaseModeEverEntered, which
+ /// retires the fallback "waiting for login" cell source that used to keep
+ /// preparation running pre-entry.
+ ///
+ public uint ActiveDestinationCell
+ {
+ get
+ {
+ if (_transit.IsTeleportActive)
+ return _pendingCell;
+ if (_loginPresentationActive)
+ {
+ RuntimePortalSnapshot snapshot = _transit.Snapshot;
+ if (snapshot.Kind == RuntimePortalKind.Login
+ && snapshot.Generation == _loginRevealGeneration)
+ {
+ return snapshot.Readiness.DestinationCell;
+ }
+ }
+ return 0u;
+ }
+ }
public void OnTeleportStarted(uint sequence)
{
@@ -515,13 +604,22 @@ internal sealed class LocalPlayerTeleportController
TryAimAcceptedDestination();
}
+ public void OnLocalPlayerFirstEntryCompleted()
+ {
+ ThrowIfDisposed();
+ _loginPlacementCompleted = true;
+ }
+
public void Tick(float deltaSeconds)
{
ThrowIfDisposed();
TryActivatePendingPresentation();
TryAimAcceptedDestination();
if (!_transit.IsTeleportActive)
+ {
+ TickLoginPresentation(deltaSeconds);
return;
+ }
long generation = _lifetimeGeneration;
ushort sequence = _transit.ActiveTeleportSequence;
@@ -902,6 +1000,225 @@ internal sealed class LocalPlayerTeleportController
+ $"(seq={_transit.ActiveTeleportSequence})");
}
+ ///
+ /// Enter-world round (2026-08-17): the login half of retail's ONE
+ /// wormhole machine. Retail begins the identical TAS_TUNNEL animation for
+ /// initial login and for F751 teleports from the same
+ /// gmSmartBoxUI::UseTime @ 0x004D6EAB flag edge β
+ /// SmartBox::teleport_in_progress @ 0x00451C20 goes high the
+ /// moment the login player object exists with
+ /// position_update_complete == 0, no F751 required. acdream's
+ /// canonical login edge is Runtime's login reveal generation
+ /// (, begun on the
+ /// first accepted local-player position on every entry route: direct
+ /// auto-select, character-select Enter, and enter-after-create).
+ ///
+ ///
+ /// Activation retries every Tick until
+ ///
+ /// succeeds β the same retry shape the F751 arm uses β which requires the
+ /// Runtime first-entry conductor's published movement controller.
+ /// Entering portal space cancels the player-mode auto-entry (its first
+ /// statement), so this arm owns the reveal's
+ /// EnterWorld/LoginComplete/Complete suffix exactly like the teleport
+ /// pump owns its own; the update-frame order (teleport phase before
+ /// auto-entry) guarantees this claim happens before auto-entry could
+ /// fire.
+ ///
+ ///
+ private void TryActivateLoginPresentation()
+ {
+ if (_transit.HasPendingTeleportStart || _transit.IsTeleportActive)
+ return;
+
+ RuntimePortalSnapshot snapshot = _transit.Snapshot;
+ if (snapshot.Kind != RuntimePortalKind.Login
+ || snapshot.Generation == 0
+ || snapshot.Completed
+ || snapshot.Cancelled
+ || _loginRevealGeneration == snapshot.Generation)
+ {
+ return;
+ }
+
+ // Quiet pre-gate: until the first-entry conductor PUBLISHES the
+ // movement controller, portal-space entry cannot succeed (and the
+ // full entry path would log a refusal every tick). Retry silently.
+ if (_mode.Controller is not { CanExecuteLiveMovement: true })
+ return;
+
+ long generation = _lifetimeGeneration;
+ if (!_mode.TryEnterPortalSpaceForLogin()
+ || _lifetimeGeneration != generation
+ || _mode.Controller is null)
+ {
+ return;
+ }
+
+ // Re-read after the mode entry: TryEnterPortalSpaceForLogin can run
+ // arbitrary presentation attach work.
+ snapshot = _transit.Snapshot;
+ if (snapshot.Kind != RuntimePortalKind.Login
+ || snapshot.Generation == 0
+ || snapshot.Completed
+ || snapshot.Cancelled)
+ {
+ return;
+ }
+
+ _loginRevealGeneration = snapshot.Generation;
+ _loginPresentationActive = true;
+ _loginHoldSeconds = 0f;
+ _presentation.Begin(_mode.Projection);
+ Console.WriteLine(
+ $"live: login portal-space presentation started "
+ + $"(gen={snapshot.Generation} "
+ + $"cell=0x{snapshot.Readiness.DestinationCell:X8})");
+ }
+
+ ///
+ /// Per-frame pump for the login presentation β the login mirror of the
+ /// teleport pump in . Differences, each anchored in
+ /// retail: there is no Place edge to drive (the first-entry conductor
+ /// committed the canonical placement before this presentation could
+ /// begin β retail's SmartBox::UseTime @ 0x00455483 likewise only
+ /// flips position_update_complete, it does not place), and
+ /// LoginComplete rides
+ /// at the WorldFadeIn end (gmSmartBoxUI::UseTime @ 0x004D745D β
+ /// CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90)
+ /// instead of at raw first placement.
+ ///
+ private void TickLoginPresentation(float deltaSeconds)
+ {
+ TryActivateLoginPresentation();
+
+ RuntimePortalSnapshot snapshot = _transit.Snapshot;
+ bool revealActive = snapshot.Kind == RuntimePortalKind.Login
+ && snapshot.Generation != 0
+ && !snapshot.Completed
+ && !snapshot.Cancelled;
+ if (!revealActive || _loginRevealGeneration != snapshot.Generation)
+ {
+ if (_loginPresentationActive)
+ {
+ // The reveal this presentation was serving ended underneath
+ // it (cancel, supersession, or session reset that did not
+ // route through this controller's own reset). Drop the claim
+ // and retire the visuals; a successor reveal re-activates
+ // through TryActivateLoginPresentation above.
+ _loginRevealGeneration = 0;
+ _loginPresentationActive = false;
+ _loginHoldSeconds = 0f;
+ _presentation.Reset();
+ }
+ return;
+ }
+
+ if (!_loginPresentationActive)
+ return;
+
+ long generation = _lifetimeGeneration;
+ long revealGeneration = snapshot.Generation;
+ uint destinationCell = snapshot.Readiness.DestinationCell;
+
+ bool originReady = !_streaming.IsRecenterPending;
+ bool worldReady = _loginPlacementCompleted
+ && originReady
+ && _worldReveal.Evaluate(destinationCell).IsReady;
+ if (!IsCurrentLoginLifetime(generation, revealGeneration))
+ return;
+
+ if (!worldReady)
+ _loginHoldSeconds += deltaSeconds;
+ _presentation.SetWaitCue(
+ !worldReady
+ && _worldReveal.ObserveWait(
+ TimeSpan.FromSeconds(_loginHoldSeconds)));
+
+ var (_, events) = _presentation.Tick(deltaSeconds, worldReady);
+ if (!IsCurrentLoginLifetime(generation, revealGeneration))
+ return;
+
+ foreach (TeleportAnimEvent teleportEvent in events)
+ {
+ switch (teleportEvent)
+ {
+ case TeleportAnimEvent.PlayEnterSound:
+ // Sound_UI_EnterPortal as the animation begins β
+ // BeginTeleportAnimation @ 0x004D638E, identical for the
+ // login entry. Logged (once per login) as the audio-start
+ // evidence line for connected gates.
+ Console.WriteLine(
+ "live: login portal-space enter cue "
+ + "(Sound_UI_EnterPortal)");
+ _presentation.PlayEnterCue();
+ if (!IsCurrentLoginLifetime(generation, revealGeneration))
+ return;
+ break;
+ case TeleportAnimEvent.EnterTunnel:
+ _presentation.EnterTunnel();
+ if (!IsCurrentLoginLifetime(generation, revealGeneration))
+ return;
+ break;
+ case TeleportAnimEvent.Place:
+ // No login Place edge: 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).
+ break;
+ case TeleportAnimEvent.PlayExitSound:
+ // Release destination cell blocking at the exact
+ // portal/world viewport swap β same edge as the teleport
+ // pump (gmSmartBoxUI::UseTime @ 0x004D6E30), with
+ // Sound_UI_ExitPortal @ 0x004D7405.
+ _worldReveal.RevealWorldViewport();
+ if (!IsCurrentLoginLifetime(generation, revealGeneration))
+ return;
+ _presentation.PlayExitCue();
+ if (!IsCurrentLoginLifetime(generation, revealGeneration))
+ return;
+ _presentation.ExitTunnel();
+ if (!IsCurrentLoginLifetime(generation, revealGeneration))
+ return;
+ break;
+ case TeleportAnimEvent.FireLoginComplete:
+ _mode.EnterWorld();
+ if (!IsCurrentLoginLifetime(generation, revealGeneration))
+ return;
+ _session.SendLoginComplete();
+ if (!IsCurrentLoginLifetime(generation, revealGeneration))
+ return;
+ _worldReveal.Complete();
+ _loginRevealGeneration = 0;
+ _loginPresentationActive = false;
+ _loginHoldSeconds = 0f;
+ Console.WriteLine(
+ "live: login portal-space presentation complete");
+ return;
+ default:
+ break;
+ }
+ }
+
+ _presentation.TickTunnel(deltaSeconds);
+ }
+
+ ///
+ /// The login pump's currency check β the login mirror of
+ /// : same controller
+ /// lifetime, still no active teleport (an F751 supersedes the login
+ /// presentation), and the transit snapshot still carries the exact
+ /// claimed login reveal generation.
+ ///
+ private bool IsCurrentLoginLifetime(
+ long lifetimeGeneration,
+ long revealGeneration) =>
+ _lifetimeGeneration == lifetimeGeneration
+ && !_transit.IsTeleportActive
+ && _loginRevealGeneration == revealGeneration
+ && _transit.Snapshot.Generation == revealGeneration;
+
private void TryAimAcceptedDestination()
{
if (!_transit.TryGetAcceptedTeleportDestination(
@@ -1030,6 +1347,18 @@ internal sealed class LocalPlayerTeleportController
_placementCommitted = false;
_awaitingDeferredWake = false;
_holdSeconds = 0f;
+ // Enter-world round (2026-08-17): an F751 arriving mid-login-tunnel
+ // (clearSession: false) withdraws the login presentation's claim β
+ // the portal pump supersedes it and owns the single LoginComplete,
+ // exactly as retail's one teleportInProgress flag stays high across
+ // both and sends once at the eventual WorldFadeIn end. The
+ // placement-completed latch is a session fact: it survives the
+ // teleport-scoped reset and clears only with the session.
+ _loginRevealGeneration = 0;
+ _loginPresentationActive = false;
+ _loginHoldSeconds = 0f;
+ if (clearSession)
+ _loginPlacementCompleted = false;
_streaming.ResetRecenter(clearSession);
if (_lifetimeGeneration != generation)
diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
index 963d5e23..f42e241a 100644
--- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
@@ -580,6 +580,13 @@ internal sealed class CharacterManagementUiController : IDisposable
// remains authoritative and closes it on InWorld/error/reset.
EnsureEnterWait();
RuntimeCommandResult result = _bindings.Enter();
+ // Enter-world round (2026-08-17): once-per-click outcome line β a
+ // refused Enter was previously indistinguishable from a click that
+ // never dispatched (both silent), which cost a full connected-gate
+ // round to tell apart.
+ Console.WriteLine(result.Accepted
+ ? "[UI] character enter accepted"
+ : $"[UI] character enter rejected status={result.Status}");
if (!result.Accepted)
CloseContext(ref _enterWaitContext, suppressCallback: true);
InvalidateAndTick();
diff --git a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
index 6e7158f0..d4ee0084 100644
--- a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
@@ -111,7 +111,7 @@ public static class CharacterOptionsPageController
/// steps 13-18, written after the code landed):
///
///
- /// - NOT dimmed (16 rows, real acdream-side
+ ///
- NOT dimmed (17 rows, real acdream-side
/// consumer): Group B rows actually bound β
/// ViewCombatTarget/AutoTarget/AutoRepeatAttack
/// (combat: LiveCombatAttackOperations.cs,
@@ -124,15 +124,20 @@ public static class CharacterOptionsPageController
/// DragItemOnPlayerOpensSecureTrade (TS-48,
/// InteractionRetainedUiComposition.cs:326), all six
/// ListenTo*Chat ids (TurbineChatMembershipGate.cs:105-136
- /// gates every Turbine room join on the matching bit), and β landed at
+ /// gates every Turbine room join on the matching bit), β landed at
/// Campaign FA slice FA4, D7, SURVIVING the fix-round correction below
/// β FellowshipShareXP (the Create-flow click genuinely reads
- /// it as the sent shareXP bit, SocialFellowshipPageController.WireButtons).
- /// - Dimmed (34 rows, store-only): every
+ /// it as the sent shareXP bit, SocialFellowshipPageController.WireButtons),
+ /// and β the vitals retail-modes round, 2026-08-17 β
+ /// SideBySideVitals (VitalsSideBySideController swaps the
+ /// stacked 0x2100006C / side-by-side 0x21000075 vitals windows on the
+ /// bit, retail's gmGamePlayUI::RecvNotice_PlayerOptionChanged
+ /// @0x004E9DA0).
+ /// - Dimmed (33 rows, store-only): every
/// remaining Group A row (wire+store only β ACE, not acdream, is the
/// consumer) and every remaining Group B row the OP4 gate script's own
/// step 16 lists as "no consumer surface" (ShowTooltips,
- /// SideBySideVitals, SpellDuration,
+ /// SpellDuration,
/// AdvancedCombatUI, StayInChatMode,
/// DisableMostWeatherEffects, PersistentAtDay,
/// FilterLanguage, MainPackPreferred), plus every Group D
@@ -181,9 +186,9 @@ public static class CharacterOptionsPageController
///
///
/// Net: 35 (pre-FA4) β FA4 shipped 31 β fix round reverts three of the
- /// four un-dims (Ignore, AutoAccept, ShareLoot) β 34 of 50 dimmed /
- /// 16 live, ONE net un-dim from pre-FA4 baseline
- /// (FellowshipShareXP only).
+ /// four un-dims (Ignore, AutoAccept, ShareLoot) β 34 dimmed / 16 live β
+ /// the vitals retail-modes round (2026-08-17) un-dims
+ /// SideBySideVitals β 33 of 50 dimmed / 17 live.
///
/// - Flagged ambiguity, resolved by code evidence
/// (see final report, not re-litigated here): the research doc's
@@ -226,7 +231,7 @@ public static class CharacterOptionsPageController
new(CharacterOptionId.VividTargetingIndicator, "VividTargetingIndicator", Live), // Group C
new(CharacterOptionId.ShowTooltips, "ShowTooltips", StoreOnly), // Group B, unbound
new(CharacterOptionId.CoordinatesOnRadar, "CoordinatesOnRadar", Live), // Group C
- new(CharacterOptionId.SideBySideVitals, "SideBySideVitals", StoreOnly), // Group B, unbound
+ new(CharacterOptionId.SideBySideVitals, "SideBySideVitals", Live), // vitals retail-modes round (2026-08-17): consumed by VitalsSideBySideController β the bit swaps the stacked (0x2100006C) and side-by-side (0x21000075) vitals windows live, retail's gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0
new(CharacterOptionId.SpellDuration, "SpellDuration", StoreOnly), // Group B, unbound
new(CharacterOptionId.DisableMostWeatherEffects, "DisableMostWeatherEffects", StoreOnly), // Group B, unbound
new(CharacterOptionId.DisableDistanceFog, "DisableDistanceFog", Live), // Group B, bound (GameWindow.cs:657)
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 2a636103..a037ed75 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -27,10 +27,12 @@ namespace AcDream.App.UI.Layout;
///
///
///
-/// The expand-detail overlay present in the front container carries ONLY named
-/// states ("HideDetail"/"ShowDetail") β no "" DirectState entry β so the
-/// TryGetValue("") filter in excludes it
-/// automatically.
+/// The expand-detail icon overlay present in EACH container (back + front)
+/// carries ONLY named states ("HideDetail"/"ShowDetail") β no "" DirectState
+/// entry β so the TryGetValue("") filter in
+/// excludes it from slice extraction; then absorbs
+/// it into for the retail
+/// HideDetail/ShowDetail click toggle.
///
///
public static class DatWidgetFactory
@@ -76,6 +78,15 @@ public static class DatWidgetFactory
UiElement e = info.Type switch
{
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
+ // The vitals window roots β gmVitalsUI (0x10000009 @0x004BFE10),
+ // gmFloatyVitalsUI (0x1000004D, stacked 0x2100006C), and
+ // gmFloatySideVitalsUI (0x10000056, side-by-side 0x21000075). All
+ // three share the inherited HideDetail/ShowDetail press toggle
+ // (gmVitalsUI::ListenToElementMessage @0x004BFC00) β see UiVitalsRoot.
+ UiVitalsRoot.GmVitalsClassId
+ or UiVitalsRoot.GmFloatyVitalsClassId
+ or UiVitalsRoot.GmFloatySideVitalsClassId
+ => new UiVitalsRoot(info, resolve),
1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button
2 => new UiDatElement(info, resolve) // UIElement_Dragbar (Register @ 0x0046C840)
{
@@ -446,8 +457,11 @@ public static class DatWidgetFactory
/// β βββ left-cap image (β front-left sprite)
/// β βββ center image (β front-tile sprite)
/// β βββ right-cap image (β front-right sprite)
- /// β βββ expand overlay (named "ShowDetail"/"HideDetail" only β NO DirectState β IGNORED)
- /// βββ text label (Type 0) (IGNORED β Fill/Label providers bound by VitalsController)
+ /// β βββ expand overlay (named "ShowDetail"/"HideDetail" only β NO DirectState β
+ /// β absorbed as the bright fill-clipped detail icon; the back
+ /// β container has a matching dim one β see ConfigureDetailOverlay)
+ /// βββ text label (Type 0) (built as a real UiText child by LayoutImporter's meter
+ /// carve-out; Fill/Label providers bound by VitalsController)
///
///
///
@@ -508,6 +522,35 @@ public static class DatWidgetFactory
m.FrontLeft = fl;
m.FrontTile = ft;
m.FrontRight = fr;
+
+ // The expand-detail icon overlays (the 0x100004A9 child of EACH
+ // container β dim icon in the back track, bright icon in the
+ // fill-clipped front layer; health heart 0x06007490/91, stamina
+ // sword 0x06007492/93, mana scepter 0x06007494/95). They author
+ // media only for the named ShowDetail state (HideDetail authors
+ // File=0), which is why SliceIds' DirectState filter already
+ // excludes them from the slice extraction above. Absorb them into
+ // the meter alongside the slices so the HideDetail/ShowDetail
+ // click toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00)
+ // can draw retail's graphical vitals mode. PassToChildren on the
+ // meter's own media-less HideDetail/ShowDetail StateDescs gates
+ // the cascade to the absorbed number label
+ // (UIElement::SetState @0x00464E70).
+ ElementInfo? backOverlay = DetailOverlay(containers[0]);
+ ElementInfo? frontOverlay = DetailOverlay(containers[1]);
+ if (backOverlay is not null || frontOverlay is not null)
+ {
+ bool passToChildren = info.States.Values.Any(
+ static s => s.Name is "HideDetail" or "ShowDetail" && s.PassToChildren);
+ 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,
+ passToChildren);
+ }
}
else if (containers.Count == 1 && containers[0].StateMedia.ContainsKey(""))
{
@@ -617,6 +660,18 @@ public static class DatWidgetFactory
=> container.Children.Count(c =>
c.StateMedia.TryGetValue("", out var media) && media.File != 0) >= 3;
+ ///
+ /// Finds a container's expand-detail icon overlay: the child that authors a
+ /// non-zero image for the named ShowDetail state and NO DirectState image
+ /// (the 0x100004A9 shape in both vitals layouts 0x2100006C / 0x21000075).
+ /// Returns null when the container has none (non-vitals meters).
+ ///
+ private static ElementInfo? DetailOverlay(ElementInfo container)
+ => container.Children.FirstOrDefault(static c =>
+ !c.StateMedia.ContainsKey("")
+ && c.StateMedia.TryGetValue("ShowDetail", out var media)
+ && media.File != 0);
+
private static bool HasStatefulFill(ElementInfo container)
=> container.States.Any(pair =>
pair.Key != UiStateInfo.DirectStateId
diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs
index 32cbaa16..6bc56c04 100644
--- a/src/AcDream.App/UI/Layout/LayoutImporter.cs
+++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs
@@ -181,11 +181,15 @@ public static class LayoutImporter
// widgets via FindElement and bind LinesProvider without injecting new runtime nodes.
//
// Type-3 children are SKIPPED here because BuildMeter already consumed them (they
- // carry the 3-slice sprite ids, not text content; building them again would
- // double-draw the bar art). All other child types are built normally.
+ // carry the 3-slice sprite ids + the ShowDetail icon overlays, not text content;
+ // building them again would double-draw the bar art). All other child types are
+ // built normally.
//
- // Safe for vitals: the health/stamina/mana meters have ONLY Type-3 slice children
- // (no text children). This loop finds nothing for them β no change to vitals.
+ // For vitals this loop builds exactly the cur/max number label (0x100000EB/ED/EF,
+ // Type 0 β merged Type 12 β UiText): VitalsController binds its LinesProvider, and
+ // the HideDetail/ShowDetail cascade from the window root reaches it THROUGH the
+ // meter (UiMeter.TrySetRetailState forwards to stateful children), flipping its
+ // per-state 0x3B visibility exactly like retail's PassToChildren SetState walk.
foreach (var child in info.Children)
{
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter
diff --git a/src/AcDream.App/UI/Layout/UiVitalsRoot.cs b/src/AcDream.App/UI/Layout/UiVitalsRoot.cs
new file mode 100644
index 00000000..8afb068c
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/UiVitalsRoot.cs
@@ -0,0 +1,95 @@
+using System;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Behavioral root widget for the vitals windows β retail's gmVitalsUI
+/// family (gmVitalsUI 0x10000009, gmFloatyVitalsUI 0x1000004D =
+/// the stacked window LayoutDesc 0x2100006C, gmFloatySideVitalsUI
+/// 0x10000056 = the side-by-side window LayoutDesc 0x21000075; registrations
+/// @0x004BFE10 / @0x004CED90 / @0x004D0490).
+///
+///
+/// The click toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00,
+/// inherited verbatim by both floaty subclasses): on Element_mouse_press
+/// (0x1C) with dwParam1 7 (left) or 0xA (right) the root flips
+/// SetState(m_state == HideDetail ? ShowDetail : HideDetail).
+/// State semantics from the authored data + UIElement::OnSetAttribute
+/// case 8 (0x3B = invisible, SetVisible(value == 0) @0x00462DAE):
+///
+///
+/// - Undef (login default; DefaultState is authored
+/// Undef and nothing calls SetState at init): numbers visible, no icons β
+/// visually identical to HideDetail.
+/// - HideDetail (0x10000006): the numeric mode β the
+/// cur/max labels author {0x3B:false} (visible); the icon overlays author
+/// File=0 (nothing).
+/// - ShowDetail (0x10000007): the graphical mode β
+/// labels author {0x3B:true} (hidden); each bar shows its authored icon pair
+/// (dim back + bright fill-clipped front: heart / sword / scepter).
+///
+///
+///
+/// The first press from Undef lands on HideDetail (retail's exact expression:
+/// ecx = (m_state == 0x10000006); SetState(ecx + 0x10000006) β any
+/// state that is not HideDetail, including the initial Undef, goes to
+/// HideDetail first), so the first click appears to do nothing and the second
+/// enters icon mode. NOT persisted anywhere: gmGamePlayUI::SaveScreenLayout
+/// @0x004EAD50 writes only window rects, and no PlayerModule option is
+/// touched β the mode resets to Undef every session, per window.
+///
+///
+///
+/// Press routing: retail broadcasts the press to the pressed element
+/// and forwards up the parent chain (UIElement::ListenToElementMessage
+/// @0x00462340 β ForwardElementMessage), but UIElement_Dragbar
+/// (@0x0046C850) and UIElement_Resizebar (@0x0046B930) both return 2
+/// (consumed) unconditionally β a press that starts a window move or resize
+/// never reaches the vitals root. Mirrored here: presses whose hit target is a
+/// move handle or resize grip are ignored. The handler returns false so the
+/// event keeps bubbling, matching retail's fall-through to the base handler.
+///
+///
+public sealed class UiVitalsRoot : UiDatElement
+{
+ /// gmVitalsUI registered element class (@0x004BFE1A).
+ public const uint GmVitalsClassId = 0x10000009u;
+ /// gmFloatyVitalsUI registered element class (@0x004CED9A) β the stacked window root.
+ public const uint GmFloatyVitalsClassId = 0x1000004Du;
+ /// gmFloatySideVitalsUI registered element class (@0x004D049A) β the side-by-side window root.
+ public const uint GmFloatySideVitalsClassId = 0x10000056u;
+
+ public UiVitalsRoot(ElementInfo info, Func resolve)
+ : base(info, resolve)
+ {
+ }
+
+ public override bool OnEvent(in UiEvent e)
+ {
+ if (e.Type is UiEventType.MouseDown or UiEventType.RightDown
+ && !PressConsumedByChrome(e.Target))
+ {
+ // gmVitalsUI::ListenToElementMessage @0x004BFC04:
+ // this->SetState(m_state == HideDetail ? ShowDetail : HideDetail)
+ // then falls through to the base handler (keep bubbling).
+ TrySetRetailState(
+ ActiveRetailStateId == RetailUiStateIds.HideDetail
+ ? RetailUiStateIds.ShowDetail
+ : RetailUiStateIds.HideDetail);
+ }
+ return base.OnEvent(in e);
+ }
+
+ ///
+ /// True when the pressed element is (or sits inside) a window-move handle
+ /// or resize grip β the two retail element classes that consume the press
+ /// before it can bubble to the vitals root.
+ ///
+ private bool PressConsumedByChrome(UiElement? target)
+ {
+ for (UiElement? w = target; w is not null && w != this; w = w.Parent)
+ if (w.WindowMoveHandle || w is UiResizeGrip)
+ return true;
+ return false;
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/VitalsSideBySideController.cs b/src/AcDream.App/UI/Layout/VitalsSideBySideController.cs
new file mode 100644
index 00000000..da74c597
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/VitalsSideBySideController.cs
@@ -0,0 +1,84 @@
+using System;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Owns which of the two vitals windows is visible β retail's
+/// "Side By Side Vitals" character option (Options β Character tab).
+///
+///
+/// Retail authors TWO complete vitals windows and swaps their visibility on
+/// the option bit; nothing is rearranged in place:
+///
+///
+/// - gmFloatyVitalsUI (LayoutDesc 0x2100006C, the
+/// stacked 160x58 window): UpdateFromPlayerModule @0x004CF140 β
+/// visible iff PlayerModule::SideBySideVitals() == 0.
+/// - gmFloatySideVitalsUI (LayoutDesc 0x21000075, the
+/// 460x26 single-row window): UpdateFromPlayerModule @0x004D0810 β
+/// visible iff the bit is set.
+/// - Live apply: gmGamePlayUI::RecvNotice_PlayerOptionChanged
+/// @0x004E9DA0 flips both windows the moment the
+/// SideBySideVitals_PlayerOption (0x13) changes; both
+/// UpdateFromPlayerModule bodies re-apply on PlayerDescReceived.
+/// - The bit is PlayerModule::SideBySideVitals @0x005D3070
+/// = (options_ >> 0x15) & 1 β CharacterOptions1 0x00200000,
+/// option id 0x13, riding the 0x01A1 PlayerModule blob (not a 0x0005
+/// auto-save id) β CharacterOptionTable row confirmed against ACE.
+///
+///
+///
+/// acdream shape: the option lives in the J4 Runtime owner
+/// (RuntimeCharacterState.Options); this controller polls the borrowed
+/// bit once per frame from RetailUiRuntime.Tick (the OP campaign's
+/// established live-apply seam) and applies BOTH windows' visibility on the
+/// edge β covering the mount-time default, the PlayerModule blob arriving
+/// after mount, and the Character tab's live toggle with one mechanism.
+/// Both window names ride stateManagedVisibilityWindows so the saved
+/// window layout never restores a visibility this option owns.
+///
+///
+public sealed class VitalsSideBySideController
+{
+ private readonly UiRoot _root;
+ private readonly Func _sideBySideVitals;
+ private readonly string _stackedWindow;
+ private readonly string _sideWindow;
+ private bool? _applied;
+
+ public VitalsSideBySideController(
+ UiRoot root,
+ Func sideBySideVitals,
+ string stackedWindow,
+ string sideWindow)
+ {
+ _root = root ?? throw new ArgumentNullException(nameof(root));
+ _sideBySideVitals = sideBySideVitals
+ ?? throw new ArgumentNullException(nameof(sideBySideVitals));
+ _stackedWindow = stackedWindow;
+ _sideWindow = sideWindow;
+ }
+
+ /// The last applied bit (null before the first apply). Exposed for tests.
+ public bool? Applied => _applied;
+
+ public void Tick()
+ {
+ bool sideBySide = _sideBySideVitals();
+ if (_applied == sideBySide) return;
+ _applied = sideBySide;
+
+ // Retail's exact swap (RecvNotice_PlayerOptionChanged @0x004E9DBE):
+ // option ON β stacked hidden, side shown; OFF β the opposite.
+ if (sideBySide)
+ {
+ _root.HideWindow(_stackedWindow);
+ _root.ShowWindow(_sideWindow);
+ }
+ else
+ {
+ _root.ShowWindow(_stackedWindow);
+ _root.HideWindow(_sideWindow);
+ }
+ }
+}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index aca63939..dd52aada 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -495,6 +495,7 @@ public sealed class RetailUiRuntime : IDisposable
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
private ItemCooldownUiController? _itemCooldownController;
private VividTargetIndicatorController? _vividTargetIndicator;
+ private Layout.VitalsSideBySideController? _vitalsSideBySide;
private CharacterManagementUiMountCoordinator? _characterManagementMount;
private CharacterCreationUiMountCoordinator? _characterCreationMount;
private IDisposable? _characterSheetSubscription;
@@ -586,6 +587,14 @@ public sealed class RetailUiRuntime : IDisposable
WindowNames.JumpPowerbar,
WindowNames.ExternalContainer,
WindowNames.Vendor,
+ // Retail owns which vitals window shows via the
+ // SideBySideVitals character option (both
+ // UpdateFromPlayerModule bodies @0x004CF140/@0x004D0810
+ // apply it unconditionally at login) β the saved layout
+ // must never restore a visibility the option decides.
+ // See VitalsSideBySideController.
+ WindowNames.Vitals,
+ WindowNames.SideVitals,
// Trade gate round 2 (2026-08-14): the trade window is
// transient β visibility belongs to RuntimeTradeState's
// open/closed lifecycle, never to the layout file (a
@@ -827,6 +836,7 @@ public sealed class RetailUiRuntime : IDisposable
{
FpsController?.Tick();
_vividTargetIndicator?.Tick();
+ _vitalsSideBySide?.Tick();
SpellbookWindowController?.Tick();
AppraisalController?.Tick(deltaSeconds);
SpellcastingUiController?.Tick();
@@ -1312,14 +1322,7 @@ public sealed class RetailUiRuntime : IDisposable
return;
}
- VitalsVM vm = _bindings.Vitals.ViewModel;
- VitalsController.Bind(layout,
- () => vm.HealthPercent,
- () => vm.StaminaPercent ?? 0f,
- () => vm.ManaPercent ?? 0f,
- () => (vm.HealthCurrent, vm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "",
- () => (vm.StaminaCurrent, vm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "",
- () => (vm.ManaCurrent, vm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : "");
+ BindVitalsLayout(layout);
RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
@@ -1333,6 +1336,83 @@ public sealed class RetailUiRuntime : IDisposable
ContentClickThrough = false,
});
Console.WriteLine("[D.2b] retail UI active β vitals window from LayoutDesc importer (0x2100006C).");
+
+ MountSideVitals();
+ }
+
+ ///
+ /// The side-by-side vitals row β retail's SECOND complete vitals window
+ /// (gmFloatySideVitalsUI 0x10000056, LayoutDesc 0x21000075, 460x26: the
+ /// same three meters + cur/max labels laid out in one row). Retail swaps
+ /// it with the stacked window on the SideBySideVitals character option
+ /// (both UpdateFromPlayerModule bodies @0x004CF140/@0x004D0810 +
+ /// gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0) β see
+ /// , which owns both
+ /// windows' visibility from the per-frame option poll. Mounted hidden;
+ /// the controller's first Tick applies the real bit.
+ ///
+ /// Element ids (meters 0x100000E6/EC/EE, labels 0x100000EB/ED/EF)
+ /// and the detail-icon overlays are authored identically to 0x2100006C,
+ /// so the same and the inherited
+ /// click toggle apply as-is. Width constraints
+ /// ride the root's authored 0x3C..0x3F (min 360 / max 3000, fixed 26
+ /// height) through .
+ ///
+ private void MountSideVitals()
+ {
+ ElementInfo? info;
+ ImportedLayout? layout;
+ lock (_bindings.Assets.DatLock)
+ {
+ info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, 0x21000075u);
+ layout = info is null ? null : LayoutImporter.Build(
+ info,
+ _bindings.Assets.ResolveSprite,
+ _bindings.Assets.DefaultFont,
+ _bindings.Assets.ResolveFont);
+ }
+ if (info is null || layout is null)
+ {
+ Console.WriteLine("[D.2b] side vitals: LayoutDesc 0x21000075 not found β SideBySideVitals unavailable.");
+ return;
+ }
+
+ BindVitalsLayout(layout);
+ RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite,
+ new RetailWindowFrame.Options
+ {
+ WindowName = WindowNames.SideVitals,
+ Chrome = RetailWindowChrome.Imported,
+ Left = 10f,
+ Top = 30f,
+ ResizeX = true,
+ ResizeY = false,
+ DatConstraintSource = info,
+ Visible = false,
+ ContentClickThrough = false,
+ });
+ _vitalsSideBySide = new Layout.VitalsSideBySideController(
+ Host.Root,
+ () => _bindings.Options.CurrentCharacterOption(
+ (uint)CharacterOptionId.SideBySideVitals),
+ WindowNames.Vitals,
+ WindowNames.SideVitals);
+ Console.WriteLine("[D.2b] side-by-side vitals window from LayoutDesc importer (0x21000075).");
+ }
+
+ /// Binds the shared vitals ViewModel providers to a vitals layout β
+ /// both windows (0x2100006C stacked / 0x21000075 side-by-side) author the
+ /// same meter/label element ids.
+ private void BindVitalsLayout(ImportedLayout layout)
+ {
+ VitalsVM vm = _bindings.Vitals.ViewModel;
+ VitalsController.Bind(layout,
+ () => vm.HealthPercent,
+ () => vm.StaminaPercent ?? 0f,
+ () => vm.ManaPercent ?? 0f,
+ () => (vm.HealthCurrent, vm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "",
+ () => (vm.StaminaCurrent, vm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "",
+ () => (vm.ManaCurrent, vm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : "");
}
private void MountRadar()
diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs
index d3ab64c1..3818ca03 100644
--- a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs
+++ b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
+using System.Numerics;
using System.Text;
using AcDream.Core.Items;
@@ -131,6 +132,22 @@ public sealed class RetailUiAutomationProbe
return true;
}
+ ///
+ /// Raw synthetic click at canvas coordinates (vitals retail-modes round,
+ /// 2026-08-17): the Options panel's Character-tab rows are template
+ /// instances sharing ONE dat element id per control
+ /// (0x10000218/0x10000219 for every toggle row), so
+ /// element-id addressing cannot reach a SPECIFIC row's checkbox β a
+ /// drive script instead reads the row's rect from
+ /// and clicks its center. Same synthetic route as
+ /// β never the OS cursor.
+ ///
+ public bool ClickAtPoint(int x, int y)
+ {
+ ClickAt(x, y);
+ return true;
+ }
+
///
/// 2026-08-17 morning gate: synthetic pointer HOVER (no click) at an
/// element's center, for rollover/tooltip verification. Deliberately
@@ -147,7 +164,9 @@ public sealed class RetailUiAutomationProbe
{
var target = FindByDatElementId(datElementId);
if (target is null) return Fail($"element 0x{datElementId:X8} not found");
- _root.OnMouseMove((int)target.CenterX, (int)target.CenterY);
+ // Same canvasβwindow conversion as ClickAt β see CanvasToWindow.
+ (int x, int y) = CanvasToWindow((int)target.CenterX, (int)target.CenterY);
+ _root.OnMouseMove(x, y);
return true;
}
@@ -282,8 +301,30 @@ public sealed class RetailUiAutomationProbe
}
}
+ ///
+ /// Enter-world round (2026-08-17): element coordinates from the probe's
+ /// tree walk are CANVAS coordinates, but /
+ /// OnMouseDown/OnMouseUp take WINDOW coordinates and map
+ /// windowβcanvas internally (UiRoot.MapWindowToCanvas). The two
+ /// spaces are identical on every ordinary screen (no
+ /// ), which is why every prior probe
+ /// gate passed β the character-select/chargen screens are the first
+ /// STRETCHED canvases this apparatus drove, and there the mismatch sent
+ /// every synthetic click to canvasΒ·(canvas/window), i.e. nowhere near the
+ /// target element. Element-derived pointer paths therefore convert
+ /// canvasβwindow here before touching the root.
+ ///
+ private (int x, int y) CanvasToWindow(int x, int y)
+ {
+ Vector2 scale = _root.CanvasScale;
+ return scale == Vector2.One
+ ? (x, y)
+ : ((int)MathF.Round(x * scale.X), (int)MathF.Round(y * scale.Y));
+ }
+
private void ClickAt(int x, int y)
{
+ (x, y) = CanvasToWindow(x, y);
Advance(16);
_root.OnMouseMove(x, y);
_root.OnMouseDown(UiMouseButton.Left, x, y);
@@ -294,6 +335,8 @@ public sealed class RetailUiAutomationProbe
private void DragAt(int startX, int startY, int endX, int endY)
{
+ (startX, startY) = CanvasToWindow(startX, startY);
+ (endX, endY) = CanvasToWindow(endX, endY);
Advance(16);
_root.OnMouseMove(startX, startY);
_root.OnMouseDown(UiMouseButton.Left, startX, startY);
diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
index 46d0ec37..7a0c9c7c 100644
--- a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
+++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
@@ -219,7 +219,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
private bool DoClick(ScriptCommand command)
{
var p = command.Parts;
- if (p.Length < 3) return Stop(command, "usage: click element | click item [source]");
+ if (p.Length < 3) return Stop(command, "usage: click element | click item [source] | click at ");
string target = p[1].ToLowerInvariant();
if (target == "element")
{
@@ -231,7 +231,19 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
return _probe.ClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "click item failed");
}
- return Stop(command, "usage: click element | click item [source]");
+ if (target == "at")
+ {
+ // `click at ` β raw synthetic click in canvas coordinates
+ // (see RetailUiAutomationProbe.ClickAtPoint: template-instanced
+ // controls share one dat id, so a specific row is addressed by
+ // the rect its own `dump` line reports).
+ if (p.Length < 4
+ || !TryParseInt(p[2], out int x)
+ || !TryParseInt(p[3], out int y))
+ return Stop(command, "usage: click at ");
+ return _probe.ClickAtPoint(x, y) || Stop(command, "click at failed");
+ }
+ return Stop(command, "usage: click element | click item [source] | click at ");
}
/// 2026-08-17 morning gate: `hover element <datId>` β a
diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs
index db74eaaf..6a90d87e 100644
--- a/src/AcDream.App/UI/UiMeter.cs
+++ b/src/AcDream.App/UI/UiMeter.cs
@@ -25,6 +25,23 @@ public sealed class UiMeter : UiElement, IUiDatStateful
private readonly Dictionary _stateLabels = new();
private (string Text, UiMeterLabelAlign Align)? _activeStateLabel;
+ // 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;
+
+ /// 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;
+ /// The bright fill-clipped front-container detail icon. Exposed for tests.
+ internal uint DetailFrontSprite => _detailFrontSprite;
+ /// The back overlay's authored meter-local rect. Exposed for tests.
+ internal (float X, float Y, float W, float H) DetailBackRect => _detailBackRect;
+
/// Dat element id, set by the layout importer so duplicated page copies can be scoped.
public uint ElementId { get; set; }
@@ -114,8 +131,54 @@ public sealed class UiMeter : UiElement, IUiDatStateful
_stateLabels[stateId] = (text, align);
}
+ ///
+ /// Registers the vitals "detail" icon overlays absorbed from the meter's
+ /// two slice containers (the 0x100004A9 children β health heart
+ /// 0x06007490/91, stamina sword 0x06007492/93, mana scepter 0x06007494/95).
+ /// Retail authors each icon TWICE: a dim version in the BACK container
+ /// (drawn unclipped over the empty track) and a bright version in the
+ /// FRONT container (fill-clipped with the rest of the front layer β
+ /// UIElement_Meter::DrawChildren @0x0046FBD0 clips the whole
+ /// 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).
+ ///
+ internal void ConfigureDetailOverlay(
+ uint backSprite, float backX, float backY, float backW, float backH,
+ uint frontSprite, float frontX, float frontY, float frontW, float frontH,
+ bool passToChildren)
+ {
+ _detailBackSprite = backSprite;
+ _detailBackRect = (backX, backY, backW, backH);
+ _detailFrontSprite = frontSprite;
+ _detailFrontRect = (frontX, frontY, frontW, frontH);
+ _detailConfigured = backSprite != 0 || frontSprite != 0;
+ _detailPassToChildren = passToChildren;
+ }
+
public bool TrySetRetailState(uint stateId)
{
+ // Vitals detail toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00
+ // flips the window root between HideDetail 0x10000006 and ShowDetail
+ // 0x10000007; the meter receives the state through the authored
+ // PassToChildren cascade β UIElement::SetState @0x00464E70). The
+ // meter's own HideDetail/ShowDetail StateDescs are media-less and
+ // exist purely to keep propagating (PassToChildren=true), so forward
+ // to the absorbed text child (whose per-state 0x3B hides the numbers
+ // in ShowDetail) and let OnDraw key the icon overlays off the state.
+ if (_detailConfigured
+ && stateId is RetailUiStateIds.HideDetail or RetailUiStateIds.ShowDetail)
+ {
+ ActiveRetailStateId = stateId;
+ if (_detailPassToChildren)
+ foreach (UiElement child in Children)
+ if (child is IUiDatStateful stateful)
+ stateful.TrySetRetailState(stateId);
+ return true;
+ }
+
bool hasFill = _stateFillSprites.TryGetValue(stateId, out uint spriteId);
bool hasLabel = _stateLabels.TryGetValue(stateId, out var caption);
if (!hasFill && !hasLabel)
@@ -198,9 +261,23 @@ public sealed class UiMeter : UiElement, IUiDatStateful
// drawn at FULL width too but horizontally CLIPPED to the fill fraction.
// The front carries its own right-cap (shown at 100%); clipping below 100%
// removes it and reveals the back track's right-cap β retail's scissor-fill.
+ //
+ // ShowDetail icon overlays ride their authored containers: the dim BACK
+ // icon draws over the full track (its 0x100004A9 child draws AFTER the
+ // three slice children β higher ReadOrder within the back container);
+ // the bright FRONT icon is clipped with the rest of the front layer to
+ // the fill fraction (retail clips the whole element-id-2 child).
+ bool detail = _detailConfigured
+ && ActiveRetailStateId == RetailUiStateIds.ShowDetail;
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
+ if (detail)
+ DrawDetailIcon(ctx, resolve, _detailBackSprite, _detailBackRect, 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);
+ }
}
}
else
@@ -302,6 +379,30 @@ public sealed class UiMeter : UiElement, IUiDatStateful
ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One);
}
+ ///
+ /// Draws one ShowDetail icon overlay at its authored meter-local rect,
+ /// horizontally clipped to local px (the back icon
+ /// passes the full width; the front icon passes Width * fraction,
+ /// mirroring retail's whole-front-container fill clip). The visible portion
+ /// is UV-cropped so the icon reveals left-to-right with the fill. Height is
+ /// clamped to the meter's box β retail clips children to the parent rect
+ /// (the stamina FRONT overlay authors H=28 in a 16px bar; retail shows 16).
+ ///
+ private void DrawDetailIcon(
+ UiRenderContext ctx, Func resolve,
+ uint spriteId, (float X, float Y, float W, float H) rect, float clipW)
+ {
+ if (spriteId == 0 || rect.W <= 0f || rect.H <= 0f) return;
+ var (tex, _, _) = resolve(spriteId);
+ if (tex == 0) return;
+ float visibleW = MathF.Min(rect.W, clipW - rect.X);
+ if (visibleW <= 0f) return;
+ float h = MathF.Min(rect.H, Height - rect.Y);
+ if (h <= 0f) return;
+ float u1 = visibleW / rect.W;
+ ctx.DrawSprite(tex, rect.X, rect.Y, visibleW, h, 0f, 0f, u1, 1f, Vector4.One);
+ }
+
/// Draw a slice over local [,
/// pieceX+], with the texture repeating every
/// px (UV-repeat β the UI texture is GL_REPEAT-wrapped).
diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs
index 5bd2ab0e..1a9f64e3 100644
--- a/src/AcDream.App/UI/UiText.cs
+++ b/src/AcDream.App/UI/UiText.cs
@@ -386,6 +386,22 @@ public sealed class UiText : UiElement, IUiDatStateful
&& TryColor(color, out Vector4 resolvedColor))
DefaultColor = resolvedColor;
+ // Per-state Invisible (dat property 0x3B): retail's SetState applies the
+ // committed state's properties through UIElement::OnSetAttribute, whose
+ // case 8 (@0x00462DAE, property id 0x33 + 8 = 0x3B) is
+ // `SetVisible(value == 0)`. Same NAMED-states-only scoping as
+ // UiDatElement.TrySetRetailState (a DirectState 0x3B is the
+ // construction-time "authored invisible" class β #408, separately
+ // gated). First consumer here: the vitals cur/max number labels
+ // (0x100000EB/ED/EF) author HideDetail={0x3B:false} /
+ // ShowDetail={0x3B:true} β the numbers hide when the click toggle
+ // switches the window to the graphical icon mode.
+ if (stateId != UiStateInfo.DirectStateId
+ && state is not null
+ && state.Properties.Values.TryGetValue(0x3Bu, out var invisibleProp)
+ && invisibleProp.Kind == UiPropertyKind.Bool)
+ Visible = !invisibleProp.BoolValue;
+
// Retail's state cascade also swaps the element's AUTHORED string
// when the incoming state carries its own 0x17 (the friends row's
// status cell: 'Online'/'Offline' with per-state colors). Resolved
diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs
index 0f741703..1d11d7e5 100644
--- a/src/AcDream.App/UI/WindowNames.cs
+++ b/src/AcDream.App/UI/WindowNames.cs
@@ -5,6 +5,12 @@ namespace AcDream.App.UI;
public static class WindowNames
{
public const string Vitals = "vitals";
+
+ /// The side-by-side vitals row (retail gmFloatySideVitalsUI,
+ /// LayoutDesc 0x21000075) β shown instead of when the
+ /// SideBySideVitals character option is set (see
+ /// ).
+ public const string SideVitals = "side-vitals";
public const string Toolbar = "toolbar";
public const string Character = "character";
public const string CharacterInformation = "character-information";
diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs
index cb21a238..8d8d99ff 100644
--- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs
+++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs
@@ -1825,6 +1825,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
bool teleportTimestampAdvanced)
{ }
+ public void OnLocalPlayerFirstEntryCompleted() { }
+
public void ResetSession() { }
public void ResetGenerationPresentation() { }
diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs
index 74f03454..1b5eaa34 100644
--- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs
+++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs
@@ -1059,6 +1059,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
bool teleportTimestampAdvanced)
{ }
+ public void OnLocalPlayerFirstEntryCompleted() { }
+
public void ResetSession() { }
public void ResetGenerationPresentation() { }
diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
index b1f9ae53..e9174380 100644
--- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
@@ -911,12 +911,22 @@ public sealed class LocalPlayerTeleportControllerTests
public readonly RuntimeLocalPlayerMovementState Movement;
public IPreparedCollisionSource DiagnosticCollisionSource => new UnusedCollisionSource();
+ ///
+ /// Enter-world round (2026-08-17): mutable so a login-arm test can
+ /// flip destination readiness mid-flight (the login tunnel's hold is
+ /// exactly "readiness not yet true"). Initialized from the ctor's
+ /// existing worldReady parameter, so every prior test reads
+ /// identically.
+ ///
+ public bool WorldReady;
+
public Harness(
int centerX = 0x20,
int centerY = 0x21,
bool worldReady = true,
List? order = null)
{
+ WorldReady = worldReady;
order ??= new List();
Mode = new FakeMode(order);
Streaming = new FakeStreaming(centerX, centerY, order);
@@ -927,10 +937,10 @@ public sealed class LocalPlayerTeleportControllerTests
Reveal = new WorldRevealCoordinator(
Transit,
revealWindow: () => RevealWindow,
- isRenderNeighborhoodReady: (_, _, _) => worldReady,
- isSpawnCellReady: _ => worldReady,
- isTerrainNeighborhoodReady: (_, _) => worldReady,
- areCompositeTexturesReady: () => worldReady,
+ isRenderNeighborhoodReady: (_, _, _) => WorldReady,
+ isSpawnCellReady: _ => WorldReady,
+ isTerrainNeighborhoodReady: (_, _) => WorldReady,
+ areCompositeTexturesReady: () => WorldReady,
prepareCompositeTextures: (_, _) => { },
invalidateCompositeTextures: () => { },
isSpawnClaimUnhydratable: _ => false,
@@ -1330,6 +1340,14 @@ public sealed class LocalPlayerTeleportControllerTests
return true;
}
+ public int EnterPortalForLoginCount;
+
+ public bool TryEnterPortalSpaceForLogin()
+ {
+ EnterPortalForLoginCount++;
+ return TryEnterPortalSpace();
+ }
+
public void EnterWorld()
{
_order.Add("enter-world");
@@ -1439,12 +1457,14 @@ public sealed class LocalPlayerTeleportControllerTests
private sealed class FakeNetworkSink : ILocalPlayerTeleportNetworkSink
{
public List Starts { get; } = [];
+ public int FirstEntryCompletions;
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
public void OfferDestination(
RuntimeTeleportDestination destination,
bool teleportTimestampAdvanced)
{
}
+ public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++;
public void ResetSession()
{
}
@@ -1482,6 +1502,135 @@ public sealed class LocalPlayerTeleportControllerTests
Assert.False(harness.Presentation.IsPortalViewportVisible);
}
+ // ββ Enter-world round (2026-08-17): the LOGIN portal-space arm ββββββ
+ //
+ // Retail runs the identical TAS_TUNNEL wormhole on initial login β
+ // SmartBox::teleport_in_progress @ 0x00451C20 goes high the moment the
+ // login player exists with position_update_complete == 0, and
+ // gmSmartBoxUI::UseTime @ 0x004D6EAB begins the same animation (and the
+ // same Sound_UI_EnterPortal @ 0x004D638E) it begins for an F751. These
+ // tests drive the login reveal (RuntimeWorldTransitState.BeginLoginReveal
+ // via WorldRevealCoordinator.BeginLogin β the shared edge of every entry
+ // route) through the controller's login pump.
+
+ [Fact]
+ public void LoginReveal_ArmsThePortalSpacePresentation_WithRetailCues()
+ {
+ var order = new List();
+ var harness = new Harness(worldReady: false, order: order);
+
+ harness.Reveal.BeginLogin(0x20210001u);
+ Assert.Equal(RuntimePortalKind.Login, harness.Reveal.Snapshot.Kind);
+
+ // First tick: the arm claims the reveal, enters portal space, and
+ // begins the presentation.
+ harness.Controller.Tick(0.016f);
+ Assert.Equal(1, harness.Mode.EnterPortalCount);
+ Assert.Equal(Matrix4x4.Identity, harness.Presentation.BeginProjection);
+ Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell);
+
+ // Enter cue at animation begin, tunnel viewport on its own edge β
+ // the same event contract as the F751 pump.
+ harness.Presentation.Enqueue(TeleportAnimEvent.PlayEnterSound);
+ harness.Controller.Tick(0.016f);
+ Assert.Equal(["enter"], harness.Presentation.Cues);
+
+ harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel);
+ harness.Controller.Tick(0.016f);
+ Assert.True(harness.Presentation.IsPortalViewportVisible);
+
+ // Destination not ready and first placement not yet completed: the
+ // sequencer must keep seeing worldReady == false (its Tunnel hold).
+ Assert.All(harness.Presentation.WorldReadyValues, value => Assert.False(value));
+ Assert.Equal(0, harness.Session.LoginCompleteCount);
+
+ // Readiness alone is not enough β the first-entry conductor's
+ // canonical placement is half of the latch.
+ harness.WorldReady = true;
+ harness.Controller.Tick(0.016f);
+ Assert.False(harness.Presentation.WorldReadyValues[^1]);
+
+ harness.Controller.OnLocalPlayerFirstEntryCompleted();
+ harness.Controller.Tick(0.016f);
+ Assert.True(harness.Presentation.WorldReadyValues[^1]);
+
+ // The login pump has no Place edge of its own: the conductor already
+ // placed the player (retail: SmartBox::UseTime @ 0x00455483 only
+ // flips position_update_complete).
+ harness.Presentation.Enqueue(TeleportAnimEvent.Place);
+ harness.Controller.Tick(0.016f);
+ Assert.False(harness.Placement.Called);
+
+ // Viewport swap: reservation release + exit cue + tunnel retire
+ // (gmSmartBoxUI::UseTime, Sound_UI_ExitPortal @ 0x004D7405).
+ harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound);
+ harness.Controller.Tick(0.016f);
+ Assert.Equal(["enter", "exit"], harness.Presentation.Cues);
+ Assert.False(harness.Presentation.IsPortalViewportVisible);
+ Assert.Single(harness.Streaming.ReservationEnds);
+
+ // WorldFadeIn end: EnterWorld + the ONE LoginComplete + reveal
+ // completion (gmSmartBoxUI::UseTime @ 0x004D745D).
+ harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete);
+ harness.Controller.Tick(0.016f);
+ Assert.Contains("enter-world", order);
+ Assert.Equal(1, harness.Session.LoginCompleteCount);
+ Assert.True(harness.Reveal.Snapshot.Completed);
+ Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
+
+ // The completed reveal stays completed; no re-arm, no second send.
+ harness.Controller.Tick(0.016f);
+ Assert.Equal(1, harness.Mode.EnterPortalCount);
+ Assert.Equal(1, harness.Session.LoginCompleteCount);
+ }
+
+ [Fact]
+ public void LoginPump_SendsLoginCompleteOnlyAtThePresentationEnd()
+ {
+ var harness = new Harness(worldReady: true);
+ harness.Reveal.BeginLogin(0x20210001u);
+ harness.Controller.OnLocalPlayerFirstEntryCompleted();
+
+ // Ticks before the FireLoginComplete edge never send.
+ for (int i = 0; i < 5; i++)
+ harness.Controller.Tick(0.016f);
+ Assert.Equal(0, harness.Session.LoginCompleteCount);
+
+ 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 RealTeleportStart_SupersedesTheLoginPresentation()
+ {
+ var order = new List();
+ var harness = new Harness(worldReady: true, order: order);
+ harness.Reveal.BeginLogin(0x20210001u);
+ harness.Controller.OnLocalPlayerFirstEntryCompleted();
+ harness.Controller.Tick(0.016f);
+ harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel);
+ harness.Controller.Tick(0.016f);
+ Assert.True(harness.Presentation.IsPortalViewportVisible);
+
+ // An F751 mid-login-tunnel (ACE can relocate a broken spawn at
+ // login): the portal pump takes the presentation over and owns the
+ // single LoginComplete, mirroring retail's one teleportInProgress
+ // flag spanning both.
+ harness.Controller.OnTeleportStarted(3);
+ Assert.Contains("presentation-reset", order);
+ Assert.True(harness.Controller.IsActive);
+ Assert.Equal(0, harness.Session.LoginCompleteCount);
+
+ // The login claim is gone: ticking the portal-active controller
+ // never re-enters the login pump (no second presentation-begin from
+ // the login arm beyond the teleport activation's own).
+ int loginCompletes = harness.Session.LoginCompleteCount;
+ harness.Controller.Tick(0.016f);
+ Assert.Equal(loginCompletes, harness.Session.LoginCompleteCount);
+ }
+
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
{
private readonly List _order;
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs
index 10645d1f..b8a0c79e 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs
@@ -698,7 +698,7 @@ public sealed class CharacterOptionsPageControllerTests
// ββ AD-78 caption dimming (user-directed, 2026-08-11, gate 2) βββββββββββ
///
- /// The exact 34 values this slice dims
+ /// The exact 33 values currently dimmed
/// ( == true), transcribed independently
/// of CharacterOptionsPageController.Groups from the derivation
/// table in that class's own doc comment. Wiring a future consumer
@@ -720,15 +720,23 @@ public sealed class CharacterOptionsPageControllerTests
/// fellowship Create flow reads it as the sent shareXP bit).
/// Net: 35 (pre-FA4) β 34 (post-fix-round), one net un-dim.
///
+ ///
+ ///
+ /// The vitals retail-modes round (2026-08-17) un-dims
+ /// SideBySideVitals: VitalsSideBySideController genuinely
+ /// READS the bit every frame and swaps the stacked (0x2100006C) /
+ /// side-by-side (0x21000075) vitals windows β retail's
+ /// gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0.
+ /// Net: 34 β 33 dimmed / 17 live.
+ ///
///
private static readonly HashSet ExpectedStoreOnlyIds =
[
// Group 1 (UI Behavior) β SalvageMultiple (D), MainPackPreferred (B, unbound)
CharacterOptionId.SalvageMultiple,
CharacterOptionId.MainPackPreferred,
- // Group 2 (UI Display) β 11 of 15
+ // Group 2 (UI Display) β 10 of 15 (vitals round: SideBySideVitals is live)
CharacterOptionId.ShowTooltips,
- CharacterOptionId.SideBySideVitals,
CharacterOptionId.SpellDuration,
CharacterOptionId.DisableMostWeatherEffects,
CharacterOptionId.PersistentAtDay,
@@ -774,8 +782,8 @@ public sealed class CharacterOptionsPageControllerTests
.ToHashSet();
Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly);
- Assert.Equal(34, actualStoreOnly.Count);
- Assert.Equal(16, 50 - actualStoreOnly.Count); // the 16 live rows (fix round: net +1 from pre-FA4 baseline)
+ Assert.Equal(33, actualStoreOnly.Count);
+ Assert.Equal(17, 50 - actualStoreOnly.Count); // the 17 live rows (vitals round: SideBySideVitals un-dimmed)
}
[Fact]
diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs
new file mode 100644
index 00000000..33bc7009
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs
@@ -0,0 +1,251 @@
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// The retail vitals numeric/graphical click toggle, against the committed
+/// vitals fixture (vitals_2100006C.json) β no dats, no GL.
+///
+/// Retail mechanism (fully derived 2026-08-17):
+///
+/// - gmVitalsUI::ListenToElementMessage @0x004BFC00 β
+/// mouse press (msg 0x1C) with dwParam1 7 (left) or 0xA (right) flips
+/// SetState(m_state == HideDetail ? ShowDetail : HideDetail).
+/// - UIElement::SetState @0x00464E70 β the state
+/// cascades through the authored PassToChildren chain (root and meters
+/// author media-less HideDetail/ShowDetail with PassToChildren=true).
+/// - The cur/max labels author per-state 0x3B (invisible β
+/// UIElement::OnSetAttribute case 8 @0x00462DAE is
+/// SetVisible(value == 0)): HideDetail={0x3B:false} (numbers shown),
+/// ShowDetail={0x3B:true} (numbers hidden).
+/// - The 0x100004A9 overlay children author icon media only
+/// for ShowDetail (heart 0x06007490/91, sword 0x06007492/93, scepter
+/// 0x06007494/95) β dim back icon unclipped, bright front icon fill-clipped.
+/// - UIElement_Dragbar @0x0046C850 /
+/// UIElement_Resizebar @0x0046B930 consume presses (return 2) β a
+/// window move/resize press never toggles.
+/// - Not persisted: gmGamePlayUI::SaveScreenLayout
+/// @0x004EAD50 writes window rects only; each window resets to the
+/// authored Undef default per session.
+///
+///
+[Trait("Category", "Conformance")]
+public class VitalsDetailToggleTests
+{
+ private const uint DragBarTop = 0x1000063Cu; // Type 2 β WindowMoveHandle
+ private const uint GripTopLeft = 0x1000063Bu; // Type 9 β UiResizeGrip
+
+ // ββ Import shape βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void VitalsTree_RootIsVitalsRootWidget()
+ {
+ var layout = FixtureLoader.LoadVitals();
+ Assert.IsType(layout.Root);
+ }
+
+ [Fact]
+ public void VitalsTree_MetersAbsorbDetailIconOverlays()
+ {
+ var layout = FixtureLoader.LoadVitals();
+
+ // MeterId β (dim back icon, bright front icon) from the authored
+ // 0x100004A9 ShowDetail media (format doc Β§11 + installed-DAT probe).
+ (uint MeterId, uint Back, uint Front)[] cases =
+ [
+ (VitalsController.Health, 0x06007490u, 0x06007491u), // heart
+ (VitalsController.Stamina, 0x06007492u, 0x06007493u), // sword
+ (VitalsController.Mana, 0x06007494u, 0x06007495u), // scepter
+ ];
+
+ foreach (var (meterId, back, front) in cases)
+ {
+ var m = Assert.IsType(layout.FindElement(meterId));
+ Assert.True(m.HasDetailOverlay);
+ Assert.Equal(back, m.DetailBackSprite);
+ Assert.Equal(front, m.DetailFrontSprite);
+ }
+
+ // Health's authored overlay rect: 18x16 at x=66 (the heart).
+ var health = Assert.IsType(layout.FindElement(VitalsController.Health));
+ Assert.Equal((66f, 0f, 18f, 16f), health.DetailBackRect);
+ }
+
+ // ββ The press toggle βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void Press_TogglesUndefThenHideDetailThenShowDetail()
+ {
+ var layout = FixtureLoader.LoadVitals();
+ var root = Assert.IsType(layout.Root);
+ var stamina = Assert.IsType(layout.FindElement(VitalsController.Stamina));
+ var staminaText = Assert.IsType(layout.FindElement(VitalsController.StaminaText));
+
+ // Login default: authored DefaultState is Undef and nothing calls
+ // SetState β numbers visible, no icons.
+ Assert.True(staminaText.Visible);
+
+ // Press 1 (from Undef): retail lands on HideDetail β visually identical
+ // (labels author {0x3B:false} there), never back to Undef afterwards.
+ Press(root, root);
+ Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId);
+ Assert.Equal(RetailUiStateIds.HideDetail, stamina.ActiveRetailStateId);
+ Assert.True(staminaText.Visible);
+
+ // Press 2: ShowDetail β the graphical mode. Numbers hidden, icons on.
+ Press(root, root);
+ Assert.Equal(RetailUiStateIds.ShowDetail, root.ActiveRetailStateId);
+ Assert.Equal(RetailUiStateIds.ShowDetail, stamina.ActiveRetailStateId);
+ Assert.False(staminaText.Visible);
+
+ // Press 3: back to numeric.
+ Press(root, root);
+ Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId);
+ Assert.True(staminaText.Visible);
+ }
+
+ [Fact]
+ public void RightPress_TogglesLikeLeftPress()
+ {
+ // Retail accepts dwParam1 7 (left) OR 0xA (right) β @0x004BFC19.
+ var layout = FixtureLoader.LoadVitals();
+ var root = Assert.IsType(layout.Root);
+
+ Press(root, root, UiEventType.RightDown);
+ Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId);
+ Press(root, root, UiEventType.RightDown);
+ Assert.Equal(RetailUiStateIds.ShowDetail, root.ActiveRetailStateId);
+ }
+
+ [Fact]
+ public void Press_CascadesToAllThreeLabels()
+ {
+ var layout = FixtureLoader.LoadVitals();
+ var root = Assert.IsType(layout.Root);
+
+ Press(root, root); // Undef β HideDetail
+ Press(root, root); // HideDetail β ShowDetail
+ foreach (uint textId in new[]
+ {
+ VitalsController.HealthText,
+ VitalsController.StaminaText,
+ VitalsController.ManaText,
+ })
+ {
+ var text = Assert.IsType(layout.FindElement(textId));
+ Assert.False(text.Visible);
+ }
+ }
+
+ // ββ Chrome exclusions ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ [Fact]
+ public void Press_OnDragBar_DoesNotToggle()
+ {
+ // Retail UIElement_Dragbar::ListenToElementMessage returns 2 (consumed)
+ // for every message β the press never reaches gmVitalsUI.
+ var layout = FixtureLoader.LoadVitals();
+ var root = Assert.IsType(layout.Root);
+ var dragBar = layout.FindElement(DragBarTop);
+ Assert.NotNull(dragBar);
+ Assert.True(dragBar!.WindowMoveHandle);
+
+ uint before = root.ActiveRetailStateId;
+ Press(root, dragBar);
+ Assert.Equal(before, root.ActiveRetailStateId);
+ }
+
+ [Fact]
+ public void Press_OnResizeGrip_DoesNotToggle()
+ {
+ // Retail UIElement_Resizebar::ListenToElementMessage returns 2 likewise.
+ var layout = FixtureLoader.LoadVitals();
+ var root = Assert.IsType(layout.Root);
+ var grip = layout.FindElement(GripTopLeft);
+ Assert.NotNull(grip);
+ Assert.IsType(grip);
+
+ uint before = root.ActiveRetailStateId;
+ Press(root, grip!);
+ Assert.Equal(before, root.ActiveRetailStateId);
+ }
+
+ // ββ Independence: each window keeps its own state βββββββββββββββββββββββββ
+
+ [Fact]
+ public void TwoWindows_ToggleIndependently()
+ {
+ // Retail: gmFloatyVitalsUI and gmFloatySideVitalsUI each carry their
+ // own m_state; toggling one never touches the other.
+ var a = FixtureLoader.LoadVitals();
+ var b = FixtureLoader.LoadVitals();
+ var rootA = Assert.IsType(a.Root);
+ var rootB = Assert.IsType(b.Root);
+
+ Press(rootA, rootA);
+ Press(rootA, rootA);
+ Assert.Equal(RetailUiStateIds.ShowDetail, rootA.ActiveRetailStateId);
+ Assert.NotEqual(RetailUiStateIds.ShowDetail, rootB.ActiveRetailStateId);
+ }
+
+ // ββ End-to-end: the REAL mouse path (hit test + bubble) ββββββββββββββββββ
+
+ [Fact]
+ public void MouseDown_OnWindowBody_TogglesThroughTheRealHitPath()
+ {
+ var (root, vitalsRoot) = MountedWindow();
+
+ // Body press: the stamina meter's center (meters are ClickThrough, so
+ // the hit lands on the window content and bubbles to the vitals root).
+ root.OnMouseDown(UiMouseButton.Left, 90, 59);
+ root.OnMouseUp(UiMouseButton.Left, 90, 59);
+ Assert.Equal(RetailUiStateIds.HideDetail, vitalsRoot.ActiveRetailStateId);
+
+ // Right press toggles too (retail dwParam1 0xA).
+ root.OnMouseDown(UiMouseButton.Right, 90, 59);
+ root.OnMouseUp(UiMouseButton.Right, 90, 59);
+ Assert.Equal(RetailUiStateIds.ShowDetail, vitalsRoot.ActiveRetailStateId);
+ }
+
+ [Fact]
+ public void MouseDown_OnTopDragBar_MovesArmWithoutToggling()
+ {
+ var (root, vitalsRoot) = MountedWindow();
+
+ // The authored top drag bar (0x1000063C) spans content-local
+ // (5,0)-(155,5) β canvas y just below the window top. Retail's
+ // Dragbar consumes the press; the toggle must not fire.
+ root.OnMouseDown(UiMouseButton.Left, 90, 32);
+ root.OnMouseUp(UiMouseButton.Left, 90, 32);
+ Assert.NotEqual(RetailUiStateIds.HideDetail, vitalsRoot.ActiveRetailStateId);
+ Assert.NotEqual(RetailUiStateIds.ShowDetail, vitalsRoot.ActiveRetailStateId);
+ }
+
+ private static (UiRoot Root, UiVitalsRoot VitalsRoot) MountedWindow()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var layout = FixtureLoader.LoadVitals();
+ RetailWindowFrame.Mount(root, layout.Root, static _ => (0u, 0, 0),
+ new RetailWindowFrame.Options
+ {
+ WindowName = WindowNames.Vitals,
+ Chrome = RetailWindowChrome.Imported,
+ Left = 10f,
+ Top = 30f,
+ ResizeX = true,
+ ResizeY = false,
+ MinWidth = 40f,
+ ContentClickThrough = false,
+ });
+ return (root, Assert.IsType(layout.Root));
+ }
+
+ // ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /// Delivers a press to as UiRoot's bubble
+ /// walk would: the hit target rides in .
+ private static void Press(UiVitalsRoot root, UiElement target,
+ int type = UiEventType.MouseDown)
+ => root.OnEvent(new UiEvent(target.EventId, target, type));
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsSideBySideControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsSideBySideControllerTests.cs
new file mode 100644
index 00000000..5106ac5d
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/VitalsSideBySideControllerTests.cs
@@ -0,0 +1,95 @@
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// Retail's "Side By Side Vitals" character option swaps which of the TWO
+/// complete vitals windows is visible β nothing is rearranged in place:
+/// gmFloatyVitalsUI::UpdateFromPlayerModule @0x004CF140 (stacked
+/// visible iff the bit is CLEAR), gmFloatySideVitalsUI::
+/// UpdateFromPlayerModule @0x004D0810 (side row visible iff SET), and the
+/// live flip in gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0.
+/// The bit is PlayerModule::SideBySideVitals @0x005D3070 =
+/// CharacterOptions1 0x00200000 (option id 0x13) β ACE-confirmed.
+///
+public sealed class VitalsSideBySideControllerTests
+{
+ [Fact]
+ public void FirstTick_AppliesTheCurrentBit_BothDirections()
+ {
+ var (root, stacked, side) = Windows();
+ bool bit = false;
+ var controller = new VitalsSideBySideController(
+ root, () => bit, WindowNames.Vitals, WindowNames.SideVitals);
+
+ controller.Tick();
+ Assert.True(stacked.Visible);
+ Assert.False(side.Visible);
+ Assert.False(controller.Applied!.Value);
+ }
+
+ [Fact]
+ public void FirstTick_WithBitSet_ShowsTheSideRow()
+ {
+ var (root, stacked, side) = Windows();
+ var controller = new VitalsSideBySideController(
+ root, () => true, WindowNames.Vitals, WindowNames.SideVitals);
+
+ controller.Tick();
+ Assert.False(stacked.Visible);
+ Assert.True(side.Visible);
+ }
+
+ [Fact]
+ public void BitEdge_SwapsLive_BothDirections()
+ {
+ // The Character tab's checkbox (or the inbound PlayerModule blob)
+ // flips the stored bit; the next frame swaps the windows β retail's
+ // RecvNotice_PlayerOptionChanged live apply.
+ var (root, stacked, side) = Windows();
+ bool bit = false;
+ var controller = new VitalsSideBySideController(
+ root, () => bit, WindowNames.Vitals, WindowNames.SideVitals);
+ controller.Tick();
+
+ bit = true;
+ controller.Tick();
+ Assert.False(stacked.Visible);
+ Assert.True(side.Visible);
+
+ bit = false;
+ controller.Tick();
+ Assert.True(stacked.Visible);
+ Assert.False(side.Visible);
+ }
+
+ [Fact]
+ public void SteadyBit_DoesNotReassertVisibility()
+ {
+ // Edge-detected: a steady bit must not fight other visibility
+ // machinery every frame (only the option EDGE applies, mirroring
+ // retail's notice-driven apply rather than a per-frame force).
+ var (root, stacked, side) = Windows();
+ var controller = new VitalsSideBySideController(
+ root, () => false, WindowNames.Vitals, WindowNames.SideVitals);
+ controller.Tick();
+
+ // Simulate an out-of-band hide (e.g. a future toggle surface).
+ stacked.Visible = false;
+ controller.Tick();
+ Assert.False(stacked.Visible);
+ }
+
+ private static (UiRoot Root, UiPanel Stacked, UiPanel Side) Windows()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var stacked = new UiPanel { Width = 160, Height = 58 };
+ var side = new UiPanel { Width = 460, Height = 26, Visible = false };
+ root.AddChild(stacked);
+ root.AddChild(side);
+ root.RegisterWindow(WindowNames.Vitals, stacked, stacked, null);
+ root.RegisterWindow(WindowNames.SideVitals, side, side, null);
+ return (root, stacked, side);
+ }
+}