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