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>
841 lines
36 KiB
C#
841 lines
36 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Campaign OP slice OP4 (2026-08-11) conformance + behavior tests for
|
|
/// <see cref="CharacterOptionsPageController"/> — the CH4 registry-
|
|
/// conformance pattern: every one of the 50 authored rows pinned against
|
|
/// <see cref="CharacterOptionTable"/> in BOTH directions (an invented row
|
|
/// or a dropped row fails the build), the authored group/order pinned
|
|
/// against the committed fixture, and the row-building/Apply-Reset-
|
|
/// Defaults/wire-publish behavior exercised end-to-end against the
|
|
/// committed <c>options_panel_2100006E_1000018D.json</c> fixture — no
|
|
/// live DAT access, following the same hermetic pattern
|
|
/// <c>OptionsPanelControllerTests</c>/<c>OptionsPanelLayoutConformanceTests</c>
|
|
/// already established.
|
|
/// </summary>
|
|
public sealed class CharacterOptionsPageControllerTests
|
|
{
|
|
// The three PlayerOption ids that exist but carry NO Character-tab row
|
|
// (research doc §2.7): AppearOffline, UseMouseTurning, LockUI.
|
|
private static readonly CharacterOptionId[] NotOnCharacterTab =
|
|
[
|
|
CharacterOptionId.AppearOffline,
|
|
CharacterOptionId.UseMouseTurning,
|
|
CharacterOptionId.LockUI,
|
|
];
|
|
|
|
private static IEnumerable<CharacterOptionsPageController.RowSpec> AllRows() =>
|
|
CharacterOptionsPageController.Groups.SelectMany(static g => g.Rows);
|
|
|
|
// ── Pure data conformance (no fixtures, no widgets) ─────────────────────
|
|
|
|
[Fact]
|
|
public void Groups_HasSixGroups_InAuthoredHeaderOrder()
|
|
{
|
|
string[] expectedHeaders =
|
|
[
|
|
"ID_CharacterOption_UIBehavior_Section",
|
|
"ID_CharacterOption_UIDisplay_Section",
|
|
"ID_CharacterOption_Grouping_Section",
|
|
"ID_CharacterOption_OtherPlayers_Section",
|
|
"ID_CharacterOption_CharacterBehavior_Section",
|
|
"ID_CharacterOption_Chat_Section",
|
|
];
|
|
|
|
Assert.Equal(6, CharacterOptionsPageController.Groups.Length);
|
|
Assert.Equal(
|
|
expectedHeaders,
|
|
CharacterOptionsPageController.Groups.Select(static g => g.HeaderKey));
|
|
}
|
|
|
|
[Fact]
|
|
public void Groups_RowCountsPerGroup_Match3_15_6_11_7_8()
|
|
{
|
|
// research doc §2 / §7: 3/15/6/11/7/7, +1 for D3's HearPKDeaths
|
|
// appended to the Chat group -> 8.
|
|
int[] expected = { 3, 15, 6, 11, 7, 8 };
|
|
|
|
Assert.Equal(
|
|
expected,
|
|
CharacterOptionsPageController.Groups.Select(static g => g.Rows.Length));
|
|
}
|
|
|
|
[Fact]
|
|
public void TotalRowCount_Is50()
|
|
{
|
|
Assert.Equal(50, CharacterOptionsPageController.TotalRowCount);
|
|
Assert.Equal(50, AllRows().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryRow_ResolvesInCharacterOptionTable()
|
|
{
|
|
// "An invented row fails the build" — direction 1.
|
|
foreach (CharacterOptionsPageController.RowSpec row in AllRows())
|
|
{
|
|
Assert.True(
|
|
CharacterOptionTable.TryGet(row.Id, out _),
|
|
$"{row.RetailName} (0x{(uint)row.Id:X2}) is authored on the Character tab "
|
|
+ "but missing from CharacterOptionTable.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryRow_IsPairwiseDistinct()
|
|
{
|
|
var ids = AllRows().Select(static r => r.Id).ToList();
|
|
Assert.Equal(ids.Count, ids.Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryCharacterOptionTableId_ExceptTheThreeExcluded_HasExactlyOneRow()
|
|
{
|
|
// "A dropped row fails the build" — direction 2. CharacterOptionTable
|
|
// has 53 ids; the Character tab authors exactly 53 - 3 = 50 of them
|
|
// (research doc §2.7's three exclusions).
|
|
var rowIds = AllRows().Select(static r => r.Id).ToHashSet();
|
|
|
|
foreach (CharacterOptionTableEntry entry in CharacterOptionTable.All)
|
|
{
|
|
bool expectedOnTab = !NotOnCharacterTab.Contains(entry.Id);
|
|
Assert.True(
|
|
rowIds.Contains(entry.Id) == expectedOnTab,
|
|
$"{entry.Id} (0x{(uint)entry.Id:X2}): expected authored-on-tab="
|
|
+ $"{expectedOnTab} but rowIds.Contains={rowIds.Contains(entry.Id)}.");
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(CharacterOptionId.AppearOffline)]
|
|
[InlineData(CharacterOptionId.UseMouseTurning)]
|
|
[InlineData(CharacterOptionId.LockUI)]
|
|
public void ExcludedIds_HaveNoRow(CharacterOptionId excludedId)
|
|
{
|
|
Assert.DoesNotContain(AllRows(), r => r.Id == excludedId);
|
|
}
|
|
|
|
[Fact]
|
|
public void HearPkDeathMessages_D3Row_IsLastInTheChatGroup()
|
|
{
|
|
CharacterOptionsPageController.RowSpec[] chatRows =
|
|
CharacterOptionsPageController.Groups[5].Rows;
|
|
Assert.Equal(
|
|
CharacterOptionId.HearPkDeathMessages, chatRows[^1].Id);
|
|
Assert.Equal("HearPKDeaths", chatRows[^1].RetailName);
|
|
}
|
|
|
|
[Fact]
|
|
public void HearPkDeathMessages_RetailNameHash_MatchesByteVerifiedStringId()
|
|
{
|
|
// structure doc §7 build-version divergence note:
|
|
// compute_str_hash("ID_PlayerOption_HearPKDeaths") == 0x0D16E9A3
|
|
// exactly, matching the DAT string "Listen to PK death messages."
|
|
Assert.Equal(
|
|
0x0D16E9A3u,
|
|
DatStringResolver.ComputeHash("ID_PlayerOption_HearPKDeaths"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ID_CharacterOption_UIBehavior_Section", 0x06489B6Eu)]
|
|
[InlineData("ID_CharacterOption_UIDisplay_Section", 0x0A9BC99Eu)]
|
|
[InlineData("ID_CharacterOption_Grouping_Section", 0x0CBAAFAEu)]
|
|
[InlineData("ID_CharacterOption_OtherPlayers_Section", 0x0872DFFEu)]
|
|
[InlineData("ID_CharacterOption_CharacterBehavior_Section", 0x08674D5Eu)]
|
|
[InlineData("ID_CharacterOption_Chat_Section", 0x0987FE8Eu)]
|
|
public void HeaderKey_RetailNameHash_MatchesByteVerifiedStringId(
|
|
string headerKey, uint expectedHash)
|
|
{
|
|
// SF-2 (OP4 review-fix round, 2026-08-11): structure doc §7's six
|
|
// byte-verified header string ids — previously verified only by
|
|
// hand in the mechanism review, not pinned by a test. A typo in a
|
|
// HeaderKey literal would otherwise produce a silently blank
|
|
// header that only the user's eye catches.
|
|
Assert.Equal(expectedHash, DatStringResolver.ComputeHash(headerKey));
|
|
Assert.Contains(headerKey, CharacterOptionsPageController.Groups.Select(g => g.HeaderKey));
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(RetailEnumNameCases))]
|
|
public void RetailName_MatchesVerbatimAcclientEnumSpelling(
|
|
CharacterOptionId id, string expectedRetailName)
|
|
{
|
|
// acclient.h:4162-4218's OWN PlayerOption enumerator names — the six
|
|
// Hear*Chat ids (+ D3's HearPKDeaths) differ from acdream's own
|
|
// ListenTo*Chat CharacterOptionId spelling; every other id matches
|
|
// 1:1. Only a representative spot-check here — the FULL authored
|
|
// list is pinned row-by-row in AuthoredOrder_MatchesResearchDocRowByRow.
|
|
CharacterOptionsPageController.RowSpec row =
|
|
AllRows().Single(r => r.Id == id);
|
|
Assert.Equal(expectedRetailName, row.RetailName);
|
|
}
|
|
|
|
public static IEnumerable<object[]> RetailEnumNameCases()
|
|
{
|
|
yield return [CharacterOptionId.ListenToAllegianceChat, "HearAllegianceChat"];
|
|
yield return [CharacterOptionId.ListenToGeneralChat, "HearGeneralChat"];
|
|
yield return [CharacterOptionId.ListenToTradeChat, "HearTradeChat"];
|
|
yield return [CharacterOptionId.ListenToLFGChat, "HearLFGChat"];
|
|
yield return [CharacterOptionId.ListenToRoleplayChat, "HearRoleplayChat"];
|
|
yield return [CharacterOptionId.ListenToSocietyChat, "HearSocietyChat"];
|
|
yield return [CharacterOptionId.HearPkDeathMessages, "HearPKDeaths"];
|
|
// Spot-check a few that DON'T differ from CharacterOptionId's own name.
|
|
yield return [CharacterOptionId.ViewCombatTarget, "ViewCombatTarget"];
|
|
yield return [CharacterOptionId.MainPackPreferred, "MainPackPreferred"];
|
|
yield return [CharacterOptionId.AutoRepeatAttack, "AutoRepeatAttack"];
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoredOrder_MatchesResearchDocRowByRow()
|
|
{
|
|
// docs/research/2026-08-10-options-panel-structure.md §7's exact
|
|
// authored PlayerOption enumerator sequence, transcribed
|
|
// independently of CharacterOptionsPageController.cs.
|
|
CharacterOptionId[][] expectedGroups =
|
|
[
|
|
[
|
|
CharacterOptionId.ViewCombatTarget,
|
|
CharacterOptionId.SalvageMultiple,
|
|
CharacterOptionId.MainPackPreferred,
|
|
],
|
|
[
|
|
CharacterOptionId.VividTargetingIndicator,
|
|
CharacterOptionId.ShowTooltips,
|
|
CharacterOptionId.CoordinatesOnRadar,
|
|
CharacterOptionId.SideBySideVitals,
|
|
CharacterOptionId.SpellDuration,
|
|
CharacterOptionId.DisableMostWeatherEffects,
|
|
CharacterOptionId.DisableDistanceFog,
|
|
CharacterOptionId.PersistentAtDay,
|
|
CharacterOptionId.DisableHouseRestrictionEffects,
|
|
CharacterOptionId.UseCraftSuccessDialog,
|
|
CharacterOptionId.ConfirmVolatileRareUse,
|
|
CharacterOptionId.DisplayTimeStamps,
|
|
CharacterOptionId.FilterLanguage,
|
|
CharacterOptionId.ShowHelm,
|
|
CharacterOptionId.ShowCloak,
|
|
],
|
|
[
|
|
CharacterOptionId.IgnoreAllegianceRequests,
|
|
CharacterOptionId.IgnoreFellowshipRequests,
|
|
CharacterOptionId.DisplayAllegianceLogonNotifications,
|
|
CharacterOptionId.FellowshipShareXP,
|
|
CharacterOptionId.FellowshipShareLoot,
|
|
CharacterOptionId.FellowshipAutoAcceptRequests,
|
|
],
|
|
[
|
|
CharacterOptionId.AcceptLootPermits,
|
|
CharacterOptionId.UseDeception,
|
|
CharacterOptionId.AllowGive,
|
|
CharacterOptionId.IgnoreTradeRequests,
|
|
CharacterOptionId.DragItemOnPlayerOpensSecureTrade,
|
|
CharacterOptionId.DisplayDateOfBirth,
|
|
CharacterOptionId.DisplayAge,
|
|
CharacterOptionId.DisplayChessRank,
|
|
CharacterOptionId.DisplayFishingSkill,
|
|
CharacterOptionId.DisplayNumberDeaths,
|
|
CharacterOptionId.DisplayNumberCharacterTitles,
|
|
],
|
|
[
|
|
CharacterOptionId.ToggleRun,
|
|
CharacterOptionId.AdvancedCombatUI,
|
|
CharacterOptionId.AutoTarget,
|
|
CharacterOptionId.AutoRepeatAttack,
|
|
CharacterOptionId.UseChargeAttack,
|
|
CharacterOptionId.LeadMissileTargets,
|
|
CharacterOptionId.UseFastMissiles,
|
|
],
|
|
[
|
|
CharacterOptionId.StayInChatMode,
|
|
CharacterOptionId.ListenToAllegianceChat,
|
|
CharacterOptionId.ListenToGeneralChat,
|
|
CharacterOptionId.ListenToTradeChat,
|
|
CharacterOptionId.ListenToLFGChat,
|
|
CharacterOptionId.ListenToRoleplayChat,
|
|
CharacterOptionId.ListenToSocietyChat,
|
|
CharacterOptionId.HearPkDeathMessages,
|
|
],
|
|
];
|
|
|
|
for (int g = 0; g < expectedGroups.Length; g++)
|
|
{
|
|
CharacterOptionId[] actual = CharacterOptionsPageController.Groups[g].Rows
|
|
.Select(static r => r.Id).ToArray();
|
|
Assert.Equal(expectedGroups[g], actual);
|
|
}
|
|
}
|
|
|
|
// ── Behavioral: built against the committed fixture ─────────────────────
|
|
|
|
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
|
|
|
private static ElementInfo? Find(ElementInfo n, uint id)
|
|
{
|
|
if (n.Id == id) return n;
|
|
foreach (ElementInfo c in n.Children)
|
|
{
|
|
ElementInfo? f = Find(c, id);
|
|
if (f is not null) return f;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>The SAME "resolve a row template from the standalone
|
|
/// 0x2100002B fixture" resolver <c>OptionsPanelLayoutConformanceTests</c>
|
|
/// uses for its own end-to-end template-mechanism test.</summary>
|
|
private static Func<uint, uint, UiElement?> MakeTemplateResolver()
|
|
{
|
|
ElementInfo panelRoot = FixtureLoader.LoadOptionsPanelInfos();
|
|
return (layoutId, elementId) =>
|
|
{
|
|
if (layoutId != 0x2100002Bu) return null;
|
|
ElementInfo? templateInfo = Find(panelRoot, elementId);
|
|
return templateInfo is null ? null : LayoutImporter.Build(templateInfo, NoTex, null).Root;
|
|
};
|
|
}
|
|
|
|
private sealed class FakeBindings
|
|
{
|
|
public Dictionary<CharacterOptionId, bool> Values { get; } = new();
|
|
public List<(CharacterOptionId Id, bool Value)> Sets { get; } = new();
|
|
|
|
public CharacterOptionsPageController.Bindings ToBindings() => new(
|
|
CurrentValue: id => Values.TryGetValue(id, out bool v) && v,
|
|
SetOption: (id, value) =>
|
|
{
|
|
Values[id] = value;
|
|
Sets.Add((id, value));
|
|
});
|
|
}
|
|
|
|
private static (OptionsPanelController Panel, FakeBindings Bindings, bool Bound) BindReal(
|
|
Func<uint, uint, string?>? resolveString = null)
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController controller = OptionsPanelController.Bind(
|
|
layout,
|
|
new OptionsPanelController.Callbacks(
|
|
Toggle: () => calls.Add("toggle"),
|
|
RequestExitToCharacterSelection: () => { },
|
|
ExitGame: () => { },
|
|
UseMouseTurningSettings: () => { },
|
|
DisplaySystemMessage: _ => { }))!;
|
|
|
|
var fakeBindings = new FakeBindings();
|
|
bool bound = CharacterOptionsPageController.Bind(
|
|
layout,
|
|
controller.CharacterPage,
|
|
MakeTemplateResolver(),
|
|
resolveString ?? ((_, _) => null),
|
|
fakeBindings.ToBindings());
|
|
|
|
return (controller, fakeBindings, bound);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_Succeeds_AndRegistersExactly50Rows()
|
|
{
|
|
(OptionsPanelController controller, _, bool bound) = BindReal();
|
|
|
|
Assert.True(bound);
|
|
Assert.Equal(50, controller.CharacterPage.Rows.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_SeedsEveryRowFromCurrentValue()
|
|
{
|
|
var fakeBindings = new FakeBindings();
|
|
// Seed a handful of ids ON; everything else defaults to off in the
|
|
// fake's dictionary lookup.
|
|
fakeBindings.Values[CharacterOptionId.ViewCombatTarget] = true;
|
|
fakeBindings.Values[CharacterOptionId.IgnoreAllegianceRequests] = true;
|
|
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
OptionsPanelController controller = OptionsPanelController.Bind(
|
|
layout,
|
|
new OptionsPanelController.Callbacks(
|
|
Toggle: () => { },
|
|
RequestExitToCharacterSelection: () => { },
|
|
ExitGame: () => { },
|
|
UseMouseTurningSettings: () => { },
|
|
DisplaySystemMessage: _ => { }))!;
|
|
bool bound = CharacterOptionsPageController.Bind(
|
|
layout,
|
|
controller.CharacterPage,
|
|
MakeTemplateResolver(),
|
|
(_, _) => null,
|
|
fakeBindings.ToBindings());
|
|
Assert.True(bound);
|
|
|
|
// No SetOption calls yet — seeding reads CurrentValue, it never
|
|
// writes back through the bindings.
|
|
Assert.Empty(fakeBindings.Sets);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClickingARow_PublishesSetOption_WithTheAuthoredId()
|
|
{
|
|
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
|
|
|
// AutoRepeatAttack (0x00) is the FIRST row's checkbox
|
|
// (0x10000219) instantiated for the Character page — resolve it by
|
|
// walking the built tree rather than layout.FindElement (which
|
|
// collides across the Chat/Config pages' own same-id copies, per
|
|
// OptionsPanelController's Apply/Reset/Defaults comment).
|
|
IOptionRow row = Assert.Single(
|
|
controller.CharacterPage.Rows.Skip(0).Take(1));
|
|
var boolRow = Assert.IsType<BoolOptionRow>(row);
|
|
|
|
boolRow.SetCurrentValue(true);
|
|
|
|
Assert.Single(bindings.Sets);
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_CommitsBaseline_AndReset_NoLongerReverts()
|
|
{
|
|
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
|
var row = Assert.IsType<BoolOptionRow>(controller.CharacterPage.Rows[0]);
|
|
bool initial = row.Current;
|
|
|
|
row.SetCurrentValue(!initial);
|
|
Assert.True(controller.CharacterPage.Changed);
|
|
|
|
controller.CharacterPage.Apply();
|
|
Assert.False(controller.CharacterPage.Changed);
|
|
Assert.Equal(!initial, row.Saved);
|
|
|
|
controller.CharacterPage.Reset(); // nothing changed since Apply -> no-op
|
|
Assert.Equal(!initial, row.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reset_RevertsToSavedBaseline_AndRePublishesTheRevertedValue()
|
|
{
|
|
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
|
var row = Assert.IsType<BoolOptionRow>(controller.CharacterPage.Rows[0]);
|
|
bool initial = row.Current;
|
|
row.SetCurrentValue(!initial);
|
|
bindings.Sets.Clear();
|
|
|
|
controller.CharacterPage.Reset();
|
|
|
|
Assert.Equal(initial, row.Current);
|
|
Assert.Contains(bindings.Sets, s => s.Value == initial);
|
|
}
|
|
|
|
[Fact]
|
|
public void Defaults_AppliesClientDefault_ForEveryRow_WithoutCommitting()
|
|
{
|
|
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
|
// Drive every row to the OPPOSITE of its own default first.
|
|
foreach (IOptionRow r in controller.CharacterPage.Rows)
|
|
{
|
|
var b = (BoolOptionRow)r;
|
|
b.SetCurrentValue(!b.DefaultValue);
|
|
}
|
|
bindings.Sets.Clear();
|
|
|
|
controller.CharacterPage.Defaults();
|
|
|
|
foreach (IOptionRow r in controller.CharacterPage.Rows)
|
|
{
|
|
var b = (BoolOptionRow)r;
|
|
Assert.Equal(b.DefaultValue, b.Current);
|
|
}
|
|
// Defaults applies live but does NOT commit — Changed stays true
|
|
// for every row that isn't already equal to its own saved value.
|
|
Assert.True(controller.CharacterPage.Changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryRow_DefaultValue_MatchesCharacterOptionTableClientDefault()
|
|
{
|
|
// U1: the Defaults button restores PlayerModule::GetDefaultOptionValue
|
|
// (CharacterOptionTable.ClientDefault), NOT a DAT DBPropertyCollection
|
|
// read — see CharacterOptionsPageController's own type doc for the
|
|
// full trace. This is the direct pin.
|
|
(OptionsPanelController controller, _, _) = BindReal();
|
|
|
|
var rowsById = new Dictionary<CharacterOptionId, BoolOptionRow>();
|
|
int i = 0;
|
|
foreach (CharacterOptionsPageController.RowSpec spec in AllRows())
|
|
rowsById[spec.Id] = (BoolOptionRow)controller.CharacterPage.Rows[i++];
|
|
|
|
foreach ((CharacterOptionId id, BoolOptionRow row) in rowsById)
|
|
{
|
|
Assert.True(CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry));
|
|
Assert.Equal(entry.ClientDefault, row.DefaultValue);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TabHide_RevertsUncommittedCharacterEdits_ViaOnVisibilityChanged()
|
|
{
|
|
// Structure doc §3.6: switching tabs away reverts uncommitted edits
|
|
// on the page you left — exercised through the SAME
|
|
// OnActivePageChanged wiring OptionsPanelControllerTests already
|
|
// covers for the (then-empty) Character page; this proves it still
|
|
// holds once the page has REAL rows.
|
|
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
|
controller.ActivateTabs();
|
|
controller.TabPanel.SwitchTo(0x10000211u); // Character page slot
|
|
var row = Assert.IsType<BoolOptionRow>(controller.CharacterPage.Rows[0]);
|
|
bool initial = row.Current;
|
|
row.SetCurrentValue(!initial);
|
|
Assert.True(controller.CharacterPage.Changed);
|
|
|
|
controller.TabPanel.SwitchTo(0x10000212u); // Gameplay page slot
|
|
|
|
Assert.Equal(initial, row.Current);
|
|
Assert.False(controller.CharacterPage.Changed);
|
|
}
|
|
|
|
// ── MUST-FIX 1 (OP4 review-fix round, 2026-08-11): the panel re-seeds
|
|
// from the live binding on OnShown instead of the pre-login
|
|
// constructor-default word it was constructed with. ─────────────────
|
|
|
|
[Fact]
|
|
public void OnShown_ReSeedsRow_FromLiveBindingValue_ChangedBehindItsBack()
|
|
{
|
|
// Simulates a fresh PlayerDescription landing (or simply "the
|
|
// character's real server value differs from the word this row
|
|
// was constructed with") without ever calling SetCurrentValue.
|
|
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
|
CharacterOptionId id = AllRows().First().Id;
|
|
var row = Assert.IsType<BoolOptionRow>(controller.CharacterPage.Rows[0]);
|
|
bool initial = row.Current;
|
|
|
|
bindings.Values[id] = !initial;
|
|
bindings.Sets.Clear();
|
|
|
|
controller.CharacterPage.OnShown();
|
|
|
|
Assert.Equal(!initial, row.Current);
|
|
Assert.Equal(!initial, row.Saved);
|
|
Assert.False(controller.CharacterPage.Changed);
|
|
// The re-read must never round-trip through SetOption — that would
|
|
// send the re-seeded value back out over the wire (retail's own
|
|
// GetValue()-into-SaveCurrentValue never calls SetPlayerOption).
|
|
Assert.Empty(bindings.Sets);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnShown_AllFiftyRows_ConvergeToTheLiveBindingSnapshot()
|
|
{
|
|
// The pre-login case: bind against a constructor-default word (the
|
|
// fake's dictionary starts empty -> every row seeds false), THEN
|
|
// the "server" state is populated (a PlayerDescription landing),
|
|
// THEN the page is shown — every one of the 50 rows must converge,
|
|
// matching retail's own InitOptions()+PostInit() / first tab-
|
|
// activation schedule (SaveCurrentValue re-reads GetValue() live).
|
|
var fakeBindings = new FakeBindings();
|
|
var random = new Random(20260811);
|
|
foreach (CharacterOptionsPageController.RowSpec spec in AllRows())
|
|
fakeBindings.Values[spec.Id] = random.Next(2) == 0;
|
|
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
OptionsPanelController controller = OptionsPanelController.Bind(
|
|
layout,
|
|
new OptionsPanelController.Callbacks(
|
|
Toggle: () => { },
|
|
RequestExitToCharacterSelection: () => { },
|
|
ExitGame: () => { },
|
|
UseMouseTurningSettings: () => { },
|
|
DisplaySystemMessage: _ => { }))!;
|
|
bool bound = CharacterOptionsPageController.Bind(
|
|
layout,
|
|
controller.CharacterPage,
|
|
MakeTemplateResolver(),
|
|
(_, _) => null,
|
|
fakeBindings.ToBindings());
|
|
Assert.True(bound);
|
|
|
|
controller.CharacterPage.OnShown();
|
|
|
|
int i = 0;
|
|
foreach (CharacterOptionsPageController.RowSpec spec in AllRows())
|
|
{
|
|
var row = (BoolOptionRow)controller.CharacterPage.Rows[i++];
|
|
Assert.Equal(fakeBindings.Values[spec.Id], row.Current);
|
|
Assert.Equal(fakeBindings.Values[spec.Id], row.Saved);
|
|
}
|
|
Assert.False(controller.CharacterPage.Changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reset_AfterReseed_RestoresTheLiveValue_NotTheStaleConstructionDefault()
|
|
{
|
|
// The historical bug MF-1 closes: before the fix, Reset/tab-switch
|
|
// could only ever restore whatever the row was seeded with AT BIND
|
|
// TIME (the pre-login constructor word) — visually-idempotent
|
|
// "toggle then cancel" could silently mutate the server bit in the
|
|
// wrong direction. After the fix, OnShown re-seeds _saved from the
|
|
// live bit first, so Reset can only revert to what was ACTUALLY
|
|
// live at the last show.
|
|
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
|
CharacterOptionId id = AllRows().First().Id;
|
|
var row = Assert.IsType<BoolOptionRow>(controller.CharacterPage.Rows[0]);
|
|
|
|
bindings.Values[id] = true;
|
|
controller.CharacterPage.OnShown(); // re-seed: current == saved == true
|
|
bindings.Sets.Clear();
|
|
|
|
row.SetCurrentValue(false); // user toggles it off, never clicks Apply
|
|
controller.CharacterPage.Reset();
|
|
|
|
Assert.True(row.Current);
|
|
Assert.Contains(bindings.Sets, s => s.Id == id && s.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClickingTheRealCheckboxWidget_PublishesSetOption_ViaMouseDownUpClick()
|
|
{
|
|
// SF-2/S6 (OP4 review-fix round, 2026-08-11): every other test in
|
|
// this suite drives BoolOptionRow.SetCurrentValue directly, which
|
|
// would stay green even if the toggle template's checkbox
|
|
// (0x10000219) ever lost its authored DAT property 0x0B
|
|
// (UiButton.ToggleBehavior) — the mechanism the whole LED click
|
|
// interaction rests on (mechanism review §1.5). This drives the
|
|
// REAL MouseDown/MouseUp/Click sequence: MouseUp flips
|
|
// UiButton.Selected FIRST (ToggleBehavior), then Click invokes
|
|
// checkbox.OnClick, which reads the NEW Selected value.
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
OptionsPanelController controller = OptionsPanelController.Bind(
|
|
layout,
|
|
new OptionsPanelController.Callbacks(
|
|
Toggle: () => { },
|
|
RequestExitToCharacterSelection: () => { },
|
|
ExitGame: () => { },
|
|
UseMouseTurningSettings: () => { },
|
|
DisplaySystemMessage: _ => { }))!;
|
|
var fakeBindings = new FakeBindings();
|
|
bool bound = CharacterOptionsPageController.Bind(
|
|
layout, controller.CharacterPage, MakeTemplateResolver(), (_, _) => null,
|
|
fakeBindings.ToBindings());
|
|
Assert.True(bound);
|
|
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
layout.FindElement(CharacterOptionsPageController.ListBoxElementId));
|
|
var checkbox = Assert.IsType<UiButton>(
|
|
UiElement.FindDescendant(listBox, 0x10000219u));
|
|
CharacterOptionId id = AllRows().First().Id;
|
|
Assert.False(checkbox.Selected);
|
|
|
|
checkbox.OnEvent(new UiEvent(0, checkbox, UiEventType.MouseDown, Data1: 0, Data2: 0));
|
|
checkbox.OnEvent(new UiEvent(0, checkbox, UiEventType.MouseUp, Data1: 0, Data2: 0));
|
|
checkbox.OnEvent(new UiEvent(0, checkbox, UiEventType.Click));
|
|
|
|
Assert.True(checkbox.Selected);
|
|
var set = Assert.Single(fakeBindings.Sets);
|
|
Assert.Equal(id, set.Id);
|
|
Assert.True(set.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void ScrollbarLinkage_ModelPointsAtTheListBoxScroll()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
OptionsPanelController controller = OptionsPanelController.Bind(
|
|
layout,
|
|
new OptionsPanelController.Callbacks(
|
|
Toggle: () => { },
|
|
RequestExitToCharacterSelection: () => { },
|
|
ExitGame: () => { },
|
|
UseMouseTurningSettings: () => { },
|
|
DisplaySystemMessage: _ => { }))!;
|
|
var fakeBindings = new FakeBindings();
|
|
CharacterOptionsPageController.Bind(
|
|
layout, controller.CharacterPage, MakeTemplateResolver(), (_, _) => null,
|
|
fakeBindings.ToBindings());
|
|
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
layout.FindElement(CharacterOptionsPageController.ListBoxElementId));
|
|
var scrollbar = Assert.IsType<UiScrollbar>(
|
|
layout.FindElement(CharacterOptionsPageController.ScrollbarElementId));
|
|
|
|
Assert.Same(listBox.Scroll, scrollbar.Model);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_MissingListBox_ReturnsFalse_AndDoesNotThrow()
|
|
{
|
|
// A layout whose root did not build the Character ListBox at all —
|
|
// exercises the degrade-gracefully path.
|
|
var emptyRoot = new ElementInfo { Id = 0, Type = 3 };
|
|
ImportedLayout emptyLayout = LayoutImporter.Build(emptyRoot, NoTex, null);
|
|
var page = new OptionPage();
|
|
|
|
bool bound = CharacterOptionsPageController.Bind(
|
|
emptyLayout, page, MakeTemplateResolver(), (_, _) => null,
|
|
new CharacterOptionsPageController.Bindings(
|
|
CurrentValue: _ => false, SetOption: (_, _) => { }));
|
|
|
|
Assert.False(bound);
|
|
Assert.Empty(page.Rows);
|
|
}
|
|
|
|
[Fact]
|
|
public void LabelResolutionFailure_LeavesCheckboxLabelNull_NeverInventsEnglish()
|
|
{
|
|
(OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => null);
|
|
|
|
Assert.True(bound);
|
|
// Every row still registers (structural build succeeds) even
|
|
// though every string lookup returns null — "no invented text"
|
|
// degrades to "no text", never a fabricated label.
|
|
Assert.Equal(50, controller.CharacterPage.Rows.Count);
|
|
}
|
|
|
|
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// for any of these means removing it from this literal set AND
|
|
/// flipping its <c>Groups</c> table entry from <c>StoreOnly</c> to
|
|
/// <c>Live</c> consciously — leaving either one stale fails this
|
|
/// test.</b>
|
|
///
|
|
/// <para>
|
|
/// Campaign FA slice FA4, D7 originally removed four ids from this set.
|
|
/// The FA4 FIX ROUND (2026-08-12) put THREE of them back:
|
|
/// <c>IgnoreFellowshipRequests</c>/<c>FellowshipAutoAcceptRequests</c>
|
|
/// (D6's correction — retail's client reads neither bit on the invite
|
|
/// path; the client-side auto-respond interceptor that was their
|
|
/// claimed consumer is deleted) and <c>FellowshipShareLoot</c>
|
|
/// (mechanism SF-8 — its claimed "second checkbox surface" consumer
|
|
/// never actually READS the stored value back). Only
|
|
/// <c>FellowshipShareXP</c> survives as a genuine live row (the
|
|
/// 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) — 10 of 15 (vitals round: SideBySideVitals is live)
|
|
CharacterOptionId.ShowTooltips,
|
|
CharacterOptionId.SpellDuration,
|
|
CharacterOptionId.DisableMostWeatherEffects,
|
|
CharacterOptionId.PersistentAtDay,
|
|
CharacterOptionId.DisableHouseRestrictionEffects,
|
|
CharacterOptionId.UseCraftSuccessDialog,
|
|
CharacterOptionId.ConfirmVolatileRareUse,
|
|
CharacterOptionId.FilterLanguage,
|
|
CharacterOptionId.ShowHelm,
|
|
CharacterOptionId.ShowCloak,
|
|
// Group 3 (Grouping) — 5 of 6 (fix round: only FellowshipShareXP stays live — see class doc)
|
|
CharacterOptionId.IgnoreAllegianceRequests,
|
|
CharacterOptionId.IgnoreFellowshipRequests,
|
|
CharacterOptionId.DisplayAllegianceLogonNotifications,
|
|
CharacterOptionId.FellowshipShareLoot,
|
|
CharacterOptionId.FellowshipAutoAcceptRequests,
|
|
// Group 4 (Other Players) — 10 of 11
|
|
CharacterOptionId.AcceptLootPermits,
|
|
CharacterOptionId.UseDeception,
|
|
CharacterOptionId.AllowGive,
|
|
CharacterOptionId.IgnoreTradeRequests,
|
|
CharacterOptionId.DisplayDateOfBirth,
|
|
CharacterOptionId.DisplayAge,
|
|
CharacterOptionId.DisplayChessRank,
|
|
CharacterOptionId.DisplayFishingSkill,
|
|
CharacterOptionId.DisplayNumberDeaths,
|
|
CharacterOptionId.DisplayNumberCharacterTitles,
|
|
// Group 5 (Character Behavior) — 4 of 7
|
|
CharacterOptionId.AdvancedCombatUI,
|
|
CharacterOptionId.UseChargeAttack,
|
|
CharacterOptionId.LeadMissileTargets,
|
|
CharacterOptionId.UseFastMissiles,
|
|
// Group 6 (Chat) — 2 of 8
|
|
CharacterOptionId.StayInChatMode,
|
|
CharacterOptionId.HearPkDeathMessages,
|
|
];
|
|
|
|
[Fact]
|
|
public void StoreOnlyRows_MatchTheDerivationTableExactly()
|
|
{
|
|
HashSet<CharacterOptionId> actualStoreOnly = AllRows()
|
|
.Where(static r => r.StoreOnly)
|
|
.Select(static r => r.Id)
|
|
.ToHashSet();
|
|
|
|
Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly);
|
|
Assert.Equal(33, actualStoreOnly.Count);
|
|
Assert.Equal(17, 50 - actualStoreOnly.Count); // the 17 live rows (vitals round: SideBySideVitals un-dimmed)
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows()
|
|
{
|
|
// A non-null constant resolver (unlike this file's usual BindReal()
|
|
// default) so every checkbox actually gets a Label -- LabelColor
|
|
// itself is set unconditionally either way, but this keeps the
|
|
// built tree representative of a real DAT string table.
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
OptionsPanelController controller = OptionsPanelController.Bind(
|
|
layout,
|
|
new OptionsPanelController.Callbacks(
|
|
Toggle: () => { },
|
|
RequestExitToCharacterSelection: () => { },
|
|
ExitGame: () => { },
|
|
UseMouseTurningSettings: () => { },
|
|
DisplaySystemMessage: _ => { }))!;
|
|
var fakeBindings = new FakeBindings();
|
|
bool bound = CharacterOptionsPageController.Bind(
|
|
layout, controller.CharacterPage, MakeTemplateResolver(), (_, _) => "x",
|
|
fakeBindings.ToBindings());
|
|
Assert.True(bound);
|
|
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
layout.FindElement(CharacterOptionsPageController.ListBoxElementId));
|
|
UiElement viewport = Assert.Single(listBox.Children);
|
|
|
|
// Filter for "contains a checkbox leaf" rather than asserting a
|
|
// concrete non-checkbox widget type for headers/separators -- robust
|
|
// to either template's underlying DatWidgetFactory class, exactly
|
|
// like the controller's own FindCheckbox degrade-gracefully pattern.
|
|
const uint ToggleCheckboxElementId = 0x10000219u;
|
|
List<UiButton> checkboxesInOrder = viewport.Children
|
|
.Select(item => UiElement.FindDescendant(item, ToggleCheckboxElementId) as UiButton)
|
|
.Where(static cb => cb is not null)
|
|
.Select(static cb => cb!)
|
|
.ToList();
|
|
Assert.Equal(50, checkboxesInOrder.Count);
|
|
|
|
List<CharacterOptionsPageController.RowSpec> specsInOrder = AllRows().ToList();
|
|
Assert.Equal(50, specsInOrder.Count);
|
|
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
CharacterOptionsPageController.RowSpec spec = specsInOrder[i];
|
|
Vector4 expected = spec.StoreOnly ? UiRenderContext.StoreOnlyCaptionColor : Vector4.One;
|
|
Assert.True(
|
|
expected == checkboxesInOrder[i].LabelColor,
|
|
$"row {i} ({spec.RetailName}, 0x{(uint)spec.Id:X2}): expected "
|
|
+ $"{(spec.StoreOnly ? "DIMMED" : "LIVE")} caption color {expected} but the "
|
|
+ $"built checkbox rendered {checkboxesInOrder[i].LabelColor}.");
|
|
}
|
|
}
|
|
}
|