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):
|
/// 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)
|
||||||
|
|
|
||||||
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 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()
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue