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

@ -698,7 +698,7 @@ public sealed class CharacterOptionsPageControllerTests
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
/// <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
/// of <c>CharacterOptionsPageController.Groups</c> from the derivation
/// 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).
/// Net: 35 (pre-FA4) → 34 (post-fix-round), one net un-dim.
/// </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>
private static readonly HashSet<CharacterOptionId> 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]