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

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