feat(ui): vitals — Side By Side Vitals swaps retail's two vitals windows

Port of retail's SideBySideVitals character option, derived end-to-end:
retail authors TWO complete vitals windows and swaps their VISIBILITY on
the option bit — nothing is rearranged in place.

- gmFloatySideVitalsUI (0x10000056, Register @0x004D0490) is a second
  full vitals window from LayoutDesc 0x21000075: 460x26, the same three
  meters (0x100000E6/EC/EE), cur/max labels (0x100000EB/ED/EF), and
  detail-icon overlays authored id-for-id with the stacked window — so
  the same VitalsController.Bind and the inherited UiVitalsRoot click
  toggle apply unchanged. Authored constraints ride the root's
  0x3C..0x3F (fixed 26 height, width 360..3000) through
  DatConstraintSource.
- Visibility ownership: gmFloatyVitalsUI::UpdateFromPlayerModule
  @0x004CF140 shows the stacked window iff PlayerModule::SideBySideVitals
  == 0; gmFloatySideVitalsUI::UpdateFromPlayerModule @0x004D0810 shows
  the side row iff set; gmGamePlayUI::RecvNotice_PlayerOptionChanged
  @0x004E9DA0 flips both live on option id 0x13.
- The bit: PlayerModule::SideBySideVitals @0x005D3070 =
  (options_ >> 0x15) & 1 — CharacterOptions1 0x00200000, ACE-confirmed;
  CharacterOptionTable already carried the exact row (PlayerModule-blob
  group, not a 0x0005 auto-save id).

acdream shape: MountSideVitals mounts the second window hidden;
VitalsSideBySideController polls the borrowed J4 option bit once per
frame from RetailUiRuntime.Tick and applies BOTH windows' visibility on
the edge — covering the mount default, the PlayerModule blob arriving
after mount, and the Character tab's live checkbox with one mechanism.
Both window names join stateManagedVisibilityWindows so the saved layout
never restores a visibility the option owns. The Character tab's
SideBySideVitals row un-dims (StoreOnly → Live) with a real reader —
33 dimmed / 17 live.

4 new controller tests (initial apply both directions, live edge swap
both directions, steady-bit non-reassertion). App suite Release live-DAT
5499 passed / 3 skips; Runtime 1744/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 10:40:49 +02:00
parent 306a1670d3
commit db8fa328dc
6 changed files with 300 additions and 22 deletions

View file

@ -111,7 +111,7 @@ public static class CharacterOptionsPageController
/// steps 13-18, written after the code landed): /// steps 13-18, written after the code landed):
/// ///
/// <list type="bullet"> /// <list type="bullet">
/// <item><description><b>NOT dimmed (16 rows, real acdream-side /// <item><description><b>NOT dimmed (17 rows, real acdream-side
/// consumer):</b> Group B rows actually bound — /// consumer):</b> Group B rows actually bound —
/// <c>ViewCombatTarget</c>/<c>AutoTarget</c>/<c>AutoRepeatAttack</c> /// <c>ViewCombatTarget</c>/<c>AutoTarget</c>/<c>AutoRepeatAttack</c>
/// (combat: <c>LiveCombatAttackOperations.cs</c>, /// (combat: <c>LiveCombatAttackOperations.cs</c>,
@ -124,15 +124,20 @@ public static class CharacterOptionsPageController
/// <c>DragItemOnPlayerOpensSecureTrade</c> (TS-48, /// <c>DragItemOnPlayerOpensSecureTrade</c> (TS-48,
/// <c>InteractionRetainedUiComposition.cs:326</c>), all six /// <c>InteractionRetainedUiComposition.cs:326</c>), all six
/// <c>ListenTo*Chat</c> ids (<c>TurbineChatMembershipGate.cs:105-136</c> /// <c>ListenTo*Chat</c> ids (<c>TurbineChatMembershipGate.cs:105-136</c>
/// 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 /// Campaign FA slice FA4, D7, SURVIVING the fix-round correction below
/// — <c>FellowshipShareXP</c> (the Create-flow click genuinely reads /// — <c>FellowshipShareXP</c> (the Create-flow click genuinely reads
/// it as the sent <c>shareXP</c> bit, <c>SocialFellowshipPageController.WireButtons</c>).</description></item> /// it as the sent <c>shareXP</c> bit, <c>SocialFellowshipPageController.WireButtons</c>),
/// <item><description><b>Dimmed (34 rows, store-only):</b> every /// and — the vitals retail-modes round, 2026-08-17 —
/// <c>SideBySideVitals</c> (<c>VitalsSideBySideController</c> swaps the
/// stacked 0x2100006C / side-by-side 0x21000075 vitals windows on the
/// bit, retail's <c>gmGamePlayUI::RecvNotice_PlayerOptionChanged
/// @0x004E9DA0</c>).</description></item>
/// <item><description><b>Dimmed (33 rows, store-only):</b> every
/// remaining Group A row (wire+store only — ACE, not acdream, is the /// 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 /// consumer) and every remaining Group B row the OP4 gate script's own
/// step 16 lists as "no consumer surface" (<c>ShowTooltips</c>, /// step 16 lists as "no consumer surface" (<c>ShowTooltips</c>,
/// <c>SideBySideVitals</c>, <c>SpellDuration</c>, /// <c>SpellDuration</c>,
/// <c>AdvancedCombatUI</c>, <c>StayInChatMode</c>, /// <c>AdvancedCombatUI</c>, <c>StayInChatMode</c>,
/// <c>DisableMostWeatherEffects</c>, <c>PersistentAtDay</c>, /// <c>DisableMostWeatherEffects</c>, <c>PersistentAtDay</c>,
/// <c>FilterLanguage</c>, <c>MainPackPreferred</c>), plus every Group D /// <c>FilterLanguage</c>, <c>MainPackPreferred</c>), plus every Group D
@ -181,9 +186,9 @@ public static class CharacterOptionsPageController
/// </list> /// </list>
/// <para> /// <para>
/// Net: 35 (pre-FA4) → FA4 shipped 31 → fix round reverts three of the /// Net: 35 (pre-FA4) → FA4 shipped 31 → fix round reverts three of the
/// four un-dims (Ignore, AutoAccept, ShareLoot) → <b>34 of 50 dimmed / /// four un-dims (Ignore, AutoAccept, ShareLoot) → 34 dimmed / 16 live →
/// 16 live</b>, ONE net un-dim from pre-FA4 baseline /// the vitals retail-modes round (2026-08-17) un-dims
/// (<c>FellowshipShareXP</c> only). /// <c>SideBySideVitals</c> → <b>33 of 50 dimmed / 17 live</b>.
/// </para></description></item> /// </para></description></item>
/// <item><description><b>Flagged ambiguity, resolved by code evidence /// <item><description><b>Flagged ambiguity, resolved by code evidence
/// (see final report, not re-litigated here):</b> the research doc's /// (see final report, not re-litigated here):</b> the research doc's
@ -226,7 +231,7 @@ public static class CharacterOptionsPageController
new(CharacterOptionId.VividTargetingIndicator, "VividTargetingIndicator", Live), // Group C new(CharacterOptionId.VividTargetingIndicator, "VividTargetingIndicator", Live), // Group C
new(CharacterOptionId.ShowTooltips, "ShowTooltips", StoreOnly), // Group B, unbound new(CharacterOptionId.ShowTooltips, "ShowTooltips", StoreOnly), // Group B, unbound
new(CharacterOptionId.CoordinatesOnRadar, "CoordinatesOnRadar", Live), // Group C 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.SpellDuration, "SpellDuration", StoreOnly), // Group B, unbound
new(CharacterOptionId.DisableMostWeatherEffects, "DisableMostWeatherEffects", StoreOnly), // Group B, unbound new(CharacterOptionId.DisableMostWeatherEffects, "DisableMostWeatherEffects", StoreOnly), // Group B, unbound
new(CharacterOptionId.DisableDistanceFog, "DisableDistanceFog", Live), // Group B, bound (GameWindow.cs:657) new(CharacterOptionId.DisableDistanceFog, "DisableDistanceFog", Live), // Group B, bound (GameWindow.cs:657)

View file

@ -0,0 +1,84 @@
using System;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Owns which of the two vitals windows is visible — retail's
/// "Side By Side Vitals" character option (Options → Character tab).
///
/// <para>
/// Retail authors TWO complete vitals windows and swaps their visibility on
/// the option bit; nothing is rearranged in place:
/// </para>
/// <list type="bullet">
/// <item><description><c>gmFloatyVitalsUI</c> (LayoutDesc 0x2100006C, the
/// stacked 160x58 window): <c>UpdateFromPlayerModule @0x004CF140</c> —
/// visible iff <c>PlayerModule::SideBySideVitals() == 0</c>.</description></item>
/// <item><description><c>gmFloatySideVitalsUI</c> (LayoutDesc 0x21000075, the
/// 460x26 single-row window): <c>UpdateFromPlayerModule @0x004D0810</c> —
/// visible iff the bit is set.</description></item>
/// <item><description>Live apply: <c>gmGamePlayUI::RecvNotice_PlayerOptionChanged
/// @0x004E9DA0</c> flips both windows the moment the
/// <c>SideBySideVitals_PlayerOption</c> (0x13) changes; both
/// <c>UpdateFromPlayerModule</c> bodies re-apply on PlayerDescReceived.</description></item>
/// <item><description>The bit is <c>PlayerModule::SideBySideVitals @0x005D3070</c>
/// = <c>(options_ &gt;&gt; 0x15) &amp; 1</c> — CharacterOptions1 0x00200000,
/// option id 0x13, riding the 0x01A1 PlayerModule blob (not a 0x0005
/// auto-save id) — <c>CharacterOptionTable</c> row confirmed against ACE.</description></item>
/// </list>
///
/// <para>
/// acdream shape: the option lives in the J4 Runtime owner
/// (<c>RuntimeCharacterState.Options</c>); this controller polls the borrowed
/// bit once per frame from <c>RetailUiRuntime.Tick</c> (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 <c>stateManagedVisibilityWindows</c> so the saved
/// window layout never restores a visibility this option owns.
/// </para>
/// </summary>
public sealed class VitalsSideBySideController
{
private readonly UiRoot _root;
private readonly Func<bool> _sideBySideVitals;
private readonly string _stackedWindow;
private readonly string _sideWindow;
private bool? _applied;
public VitalsSideBySideController(
UiRoot root,
Func<bool> sideBySideVitals,
string stackedWindow,
string sideWindow)
{
_root = root ?? throw new ArgumentNullException(nameof(root));
_sideBySideVitals = sideBySideVitals
?? throw new ArgumentNullException(nameof(sideBySideVitals));
_stackedWindow = stackedWindow;
_sideWindow = sideWindow;
}
/// <summary>The last applied bit (null before the first apply). Exposed for tests.</summary>
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);
}
}
}

View file

@ -495,6 +495,7 @@ public sealed class RetailUiRuntime : IDisposable
private UiShortcutDigitGraphics? _shortcutDigitGraphics; private UiShortcutDigitGraphics? _shortcutDigitGraphics;
private ItemCooldownUiController? _itemCooldownController; private ItemCooldownUiController? _itemCooldownController;
private VividTargetIndicatorController? _vividTargetIndicator; private VividTargetIndicatorController? _vividTargetIndicator;
private Layout.VitalsSideBySideController? _vitalsSideBySide;
private CharacterManagementUiMountCoordinator? _characterManagementMount; private CharacterManagementUiMountCoordinator? _characterManagementMount;
private CharacterCreationUiMountCoordinator? _characterCreationMount; private CharacterCreationUiMountCoordinator? _characterCreationMount;
private IDisposable? _characterSheetSubscription; private IDisposable? _characterSheetSubscription;
@ -586,6 +587,14 @@ public sealed class RetailUiRuntime : IDisposable
WindowNames.JumpPowerbar, WindowNames.JumpPowerbar,
WindowNames.ExternalContainer, WindowNames.ExternalContainer,
WindowNames.Vendor, 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 // Trade gate round 2 (2026-08-14): the trade window is
// transient — visibility belongs to RuntimeTradeState's // transient — visibility belongs to RuntimeTradeState's
// open/closed lifecycle, never to the layout file (a // open/closed lifecycle, never to the layout file (a
@ -827,6 +836,7 @@ public sealed class RetailUiRuntime : IDisposable
{ {
FpsController?.Tick(); FpsController?.Tick();
_vividTargetIndicator?.Tick(); _vividTargetIndicator?.Tick();
_vitalsSideBySide?.Tick();
SpellbookWindowController?.Tick(); SpellbookWindowController?.Tick();
AppraisalController?.Tick(deltaSeconds); AppraisalController?.Tick(deltaSeconds);
SpellcastingUiController?.Tick(); SpellcastingUiController?.Tick();
@ -1312,14 +1322,7 @@ public sealed class RetailUiRuntime : IDisposable
return; return;
} }
VitalsVM vm = _bindings.Vitals.ViewModel; BindVitalsLayout(layout);
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}" : "");
RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite, RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options new RetailWindowFrame.Options
{ {
@ -1333,6 +1336,83 @@ public sealed class RetailUiRuntime : IDisposable
ContentClickThrough = false, ContentClickThrough = false,
}); });
Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C)."); Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C).");
MountSideVitals();
}
/// <summary>
/// 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
/// <see cref="Layout.VitalsSideBySideController"/>, which owns both
/// windows' visibility from the per-frame option poll. Mounted hidden;
/// the controller's first Tick applies the real bit.
///
/// <para>Element ids (meters 0x100000E6/EC/EE, labels 0x100000EB/ED/EF)
/// and the detail-icon overlays are authored identically to 0x2100006C,
/// so the same <see cref="VitalsController.Bind"/> and the inherited
/// <see cref="UiVitalsRoot"/> click toggle apply as-is. Width constraints
/// ride the root's authored 0x3C..0x3F (min 360 / max 3000, fixed 26
/// height) through <see cref="RetailWindowFrame.Options.DatConstraintSource"/>.</para>
/// </summary>
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).");
}
/// <summary>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.</summary>
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() private void MountRadar()

View file

@ -5,6 +5,12 @@ namespace AcDream.App.UI;
public static class WindowNames public static class WindowNames
{ {
public const string Vitals = "vitals"; public const string Vitals = "vitals";
/// <summary>The side-by-side vitals row (retail gmFloatySideVitalsUI,
/// LayoutDesc 0x21000075) — shown instead of <see cref="Vitals"/> when the
/// SideBySideVitals character option is set (see
/// <see cref="Layout.VitalsSideBySideController"/>).</summary>
public const string SideVitals = "side-vitals";
public const string Toolbar = "toolbar"; public const string Toolbar = "toolbar";
public const string Character = "character"; public const string Character = "character";
public const string CharacterInformation = "character-information"; public const string CharacterInformation = "character-information";

View file

@ -698,7 +698,7 @@ public sealed class CharacterOptionsPageControllerTests
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ─────────── // ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
/// <summary> /// <summary>
/// The exact 34 <see cref="CharacterOptionId"/> values this slice dims /// The exact 33 <see cref="CharacterOptionId"/> values currently dimmed
/// (<see cref="RowSpec.StoreOnly"/> == true), transcribed independently /// (<see cref="RowSpec.StoreOnly"/> == true), transcribed independently
/// of <c>CharacterOptionsPageController.Groups</c> from the derivation /// of <c>CharacterOptionsPageController.Groups</c> from the derivation
/// table in that class's own doc comment. <b>Wiring a future consumer /// table in that class's own doc comment. <b>Wiring a future consumer
@ -720,15 +720,23 @@ public sealed class CharacterOptionsPageControllerTests
/// fellowship Create flow reads it as the sent <c>shareXP</c> bit). /// fellowship Create flow reads it as the sent <c>shareXP</c> bit).
/// Net: 35 (pre-FA4) → 34 (post-fix-round), one net un-dim. /// Net: 35 (pre-FA4) → 34 (post-fix-round), one net un-dim.
/// </para> /// </para>
///
/// <para>
/// The vitals retail-modes round (2026-08-17) un-dims
/// <c>SideBySideVitals</c>: <c>VitalsSideBySideController</c> genuinely
/// READS the bit every frame and swaps the stacked (0x2100006C) /
/// side-by-side (0x21000075) vitals windows — retail's
/// <c>gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0</c>.
/// Net: 34 → 33 dimmed / 17 live.
/// </para>
/// </summary> /// </summary>
private static readonly HashSet<CharacterOptionId> ExpectedStoreOnlyIds = private static readonly HashSet<CharacterOptionId> ExpectedStoreOnlyIds =
[ [
// Group 1 (UI Behavior) — SalvageMultiple (D), MainPackPreferred (B, unbound) // Group 1 (UI Behavior) — SalvageMultiple (D), MainPackPreferred (B, unbound)
CharacterOptionId.SalvageMultiple, CharacterOptionId.SalvageMultiple,
CharacterOptionId.MainPackPreferred, CharacterOptionId.MainPackPreferred,
// Group 2 (UI Display) — 11 of 15 // Group 2 (UI Display) — 10 of 15 (vitals round: SideBySideVitals is live)
CharacterOptionId.ShowTooltips, CharacterOptionId.ShowTooltips,
CharacterOptionId.SideBySideVitals,
CharacterOptionId.SpellDuration, CharacterOptionId.SpellDuration,
CharacterOptionId.DisableMostWeatherEffects, CharacterOptionId.DisableMostWeatherEffects,
CharacterOptionId.PersistentAtDay, CharacterOptionId.PersistentAtDay,
@ -774,8 +782,8 @@ public sealed class CharacterOptionsPageControllerTests
.ToHashSet(); .ToHashSet();
Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly); Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly);
Assert.Equal(34, actualStoreOnly.Count); Assert.Equal(33, actualStoreOnly.Count);
Assert.Equal(16, 50 - actualStoreOnly.Count); // the 16 live rows (fix round: net +1 from pre-FA4 baseline) Assert.Equal(17, 50 - actualStoreOnly.Count); // the 17 live rows (vitals round: SideBySideVitals un-dimmed)
} }
[Fact] [Fact]

View file

@ -0,0 +1,95 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Retail's "Side By Side Vitals" character option swaps which of the TWO
/// complete vitals windows is visible — nothing is rearranged in place:
/// <c>gmFloatyVitalsUI::UpdateFromPlayerModule @0x004CF140</c> (stacked
/// visible iff the bit is CLEAR), <c>gmFloatySideVitalsUI::
/// UpdateFromPlayerModule @0x004D0810</c> (side row visible iff SET), and the
/// live flip in <c>gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0</c>.
/// The bit is <c>PlayerModule::SideBySideVitals @0x005D3070</c> =
/// CharacterOptions1 0x00200000 (option id 0x13) — ACE-confirmed.
/// </summary>
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);
}
}