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
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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