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/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/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/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/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);
+ }
+}