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:
parent
306a1670d3
commit
db8fa328dc
6 changed files with 300 additions and 22 deletions
|
|
@ -111,7 +111,7 @@ public static class CharacterOptionsPageController
|
|||
/// steps 13-18, written after the code landed):
|
||||
///
|
||||
/// <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 —
|
||||
/// <c>ViewCombatTarget</c>/<c>AutoTarget</c>/<c>AutoRepeatAttack</c>
|
||||
/// (combat: <c>LiveCombatAttackOperations.cs</c>,
|
||||
|
|
@ -124,15 +124,20 @@ public static class CharacterOptionsPageController
|
|||
/// <c>DragItemOnPlayerOpensSecureTrade</c> (TS-48,
|
||||
/// <c>InteractionRetainedUiComposition.cs:326</c>), all six
|
||||
/// <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
|
||||
/// — <c>FellowshipShareXP</c> (the Create-flow click genuinely reads
|
||||
/// it as the sent <c>shareXP</c> bit, <c>SocialFellowshipPageController.WireButtons</c>).</description></item>
|
||||
/// <item><description><b>Dimmed (34 rows, store-only):</b> every
|
||||
/// it as the sent <c>shareXP</c> bit, <c>SocialFellowshipPageController.WireButtons</c>),
|
||||
/// 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
|
||||
/// consumer) and every remaining Group B row the OP4 gate script's own
|
||||
/// 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>DisableMostWeatherEffects</c>, <c>PersistentAtDay</c>,
|
||||
/// <c>FilterLanguage</c>, <c>MainPackPreferred</c>), plus every Group D
|
||||
|
|
@ -181,9 +186,9 @@ public static class CharacterOptionsPageController
|
|||
/// </list>
|
||||
/// <para>
|
||||
/// Net: 35 (pre-FA4) → FA4 shipped 31 → fix round reverts three of the
|
||||
/// four un-dims (Ignore, AutoAccept, ShareLoot) → <b>34 of 50 dimmed /
|
||||
/// 16 live</b>, ONE net un-dim from pre-FA4 baseline
|
||||
/// (<c>FellowshipShareXP</c> only).
|
||||
/// four un-dims (Ignore, AutoAccept, ShareLoot) → 34 dimmed / 16 live →
|
||||
/// the vitals retail-modes round (2026-08-17) un-dims
|
||||
/// <c>SideBySideVitals</c> → <b>33 of 50 dimmed / 17 live</b>.
|
||||
/// </para></description></item>
|
||||
/// <item><description><b>Flagged ambiguity, resolved by code evidence
|
||||
/// (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.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)
|
||||
|
|
|
|||
84
src/AcDream.App/UI/Layout/VitalsSideBySideController.cs
Normal file
84
src/AcDream.App/UI/Layout/VitalsSideBySideController.cs
Normal 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_ >> 0x15) & 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ namespace AcDream.App.UI;
|
|||
public static class WindowNames
|
||||
{
|
||||
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 Character = "character";
|
||||
public const string CharacterInformation = "character-information";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue