fix(ui): OP2 rework — dormant UiDatElement subclasses, fixed Panel/CheckboxBitfield64 mechanism
OP2 (df9c7a35) was double-REJECTed: an unconditional Type-8/Type-5 factory
mapping silently re-classed 15 elements across 7 shipped panels (vendor
backdrop lost its fill, character/spellbook roots stopped passing clicks
through, combat gained a phantom import-time tab takeover, ten ListBoxes
gained a spurious hit-testable viewport) because the stale 27 pre-existing
fixtures never exercised the new fields — and the mechanism itself cited a
nonexistent "UIElement_TabControl" class, inverted UiCheckboxBitfield64's
checked-state predicate, and synthesized fake per-row geometry instead of
using the widget's own authored template.
Shape change: UiTabPanel (renamed from UiTabControl) and UiTemplateListBox
now derive from UiDatElement (unsealed) and stay DORMANT by default — an
imported Type-8/Type-5 element gets authored-media drawing, ClickThrough
generic-decoration default, and IUiDatStateful propagation identical to the
pre-OP2 UiDatElement fallback, with zero import-time side effects. The
factory's Type-8/Type-5 arms are unconditional again (no more guard whose
premise the blast-radius sweep proved false), because dormancy makes an
unactivated instance behaviorally indistinguishable from the old fallback.
UiTabPanel.ActivateTabBehavior() and UiTemplateListBox's lazy viewport
creation are the explicit, controller-driven opt-ins Campaign OP slice OP3+
will call; today nothing does, so the four pre-existing shipped Type-8
hosts (character/spellbook/vendor/combat) and ten pre-existing Type-5
ListBoxes keep their pre-OP2 behavior exactly. Filed AD-73 for this
dormant-vs-retail's-unconditional-activation adaptation.
Mechanism fixes (docs/research/2026-08-11-op2-review-mechanism.md):
- UiTabPanel cites UIElement_Panel (Type 8 is UIElement_Panel; no
UIElement_TabControl exists in the PDB), resolves buttons/pages via a
GetChildRecursive-equivalent descendant search (not direct-children-only),
performs no switch when no entry authors 0x32 (deleted the _tabs[0]
fallback), and surfaces unresolved tab-table entries via UnresolvedEntries
+ a diagnostic line instead of a silent no-op.
- ElementReader.ReadTabTable skips entries missing 0x30/0x31, matching
retail's SetupTabPageHash @0x0046C2E0 entry filter.
- UiCheckboxBitfield64 now builds every row from its OWN authored template
(property 0x64 -> {0x2100002B, 0x10000521}) via AddItemFromTemplateList,
deleting the synthesized ElementInfo + invented RowHeight=14 — matching
retail's CreateChildren @0x00485DF0, which is itself a UIElement_ListBox
call. IsSet is now retail's ANY-bit-set predicate (Refresh @0x004859C0),
not all-bits-set. TS-72 retired: the click-toggle bit math is now fully
decomp-confirmed (SetBitsOnOrOff via ListenToElementMessage @0x00485AE0).
Regenerated all 32 UI fixtures against real DAT (ACDREAM_REGENERATE_UI_FIXTURES=1)
and committed them — 27 pre-existing fixtures now carry Outline/OutlineColor/
TabTable/TemplateList/ScrollbarElementId; the 5 Options fixtures were already
current. Updated EffectsUiControllerTests' now-correct UiTemplateListBox
class-identity assertion. Added: 6 built-widget behavior pins for all five
pre-existing Type-8 elements + a representative Type-5 element the dormancy
model protects (OP2ReworkBlastRadiusConformanceTests.cs); 5 reader-level
tests driving ReadTabTable/ReadTemplateList/the 0x72 reader from raw
property bags (ElementReaderTests.cs); a multi-bit-mask UiCheckboxBitfield64
test proving the any-bit predicate (the prior single-bit test couldn't
distinguish it from all-bits); an activation-idempotency test and a
before-activation click-is-inert test for UiTabPanel.
Full Release suite: 12,868 passed / 4 skipped / 0 failed (baseline 12,853/4/0
post-OP1-fixes; +15 net new tests, zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8a05fda445
commit
b236a44279
40 changed files with 6249 additions and 1145 deletions
|
|
@ -8,11 +8,14 @@ namespace AcDream.App.Tests.UI.Layout;
|
|||
/// Dat-free conformance tests for the committed <c>options_2100002B.json</c> /
|
||||
/// <c>options_gameplay_2100002A.json</c> / <c>options_character_21000028.json</c> /
|
||||
/// <c>options_chat_2100005C.json</c> / <c>options_config_21000029.json</c> golden
|
||||
/// fixtures (Campaign OP slice OP2). Pins the tab table (dat property <c>0x2E</c>),
|
||||
/// the three ListBox row-template lists (dat property <c>0x64</c>), scrollbar
|
||||
/// linkage (dat property <c>0x72</c>), the new Type-8/5/<c>0x10000036..0x44</c>
|
||||
/// widget mappings, and <see cref="UiTabControl"/>'s switch behavior. Retail anchors:
|
||||
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> §1.3, §1.5, §10.1.
|
||||
/// fixtures (Campaign OP slice OP2, reworked 2026-08-11). Pins the tab table (dat
|
||||
/// property <c>0x2E</c>), the three ListBox row-template lists (dat property
|
||||
/// <c>0x64</c>), scrollbar linkage (dat property <c>0x72</c>), the new Type-8/5/
|
||||
/// <c>0x10000036..0x44</c> widget mappings, and <see cref="UiTabPanel"/>'s switch
|
||||
/// behavior. Retail anchors: <c>docs/research/2026-08-10-options-panel-structure.md</c>
|
||||
/// §1.3, §1.5, §10.1. See also <c>OP2ReworkBlastRadiusConformanceTests.cs</c> (built-
|
||||
/// widget pins for the pre-existing shipped panels the rework's dormancy model
|
||||
/// protects) and the reader-level tests appended to <c>ElementReaderTests.cs</c>.
|
||||
/// </summary>
|
||||
public class OptionsPanelLayoutConformanceTests
|
||||
{
|
||||
|
|
@ -51,13 +54,18 @@ public class OptionsPanelLayoutConformanceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionsPanelFixture_TabControl_BuildsAsUiTabControl_WithSameTabTable()
|
||||
public void OptionsPanelFixture_TabControl_BuildsAsUiTabPanel_WithSameTabTable()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
|
||||
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
||||
Assert.Equal(4, tabControl.Tabs.Count);
|
||||
Assert.True(tabControl.Tabs[0].IsDefault);
|
||||
Assert.Equal(0x10000212u, tabControl.Tabs[0].PageElementId);
|
||||
|
||||
// OP2 rework: dormant until a controller opts in — importing alone must not
|
||||
// have switched anything (docs/research/2026-08-11-op2-review-blast.md).
|
||||
Assert.False(tabControl.BehaviorActive);
|
||||
Assert.Equal(0u, tabControl.ActivePageElementId);
|
||||
}
|
||||
|
||||
// ── Row-template lists (property 0x64) ──────────────────────────────────
|
||||
|
|
@ -156,6 +164,12 @@ public class OptionsPanelLayoutConformanceTests
|
|||
var listBox = Assert.IsType<UiTemplateListBox>(layout.FindElement(0x100001FAu));
|
||||
Assert.Equal(3, listBox.Templates.Count);
|
||||
Assert.Equal(0x100001FBu, listBox.ScrollbarElementId);
|
||||
|
||||
// OP2 rework: dormant until a controller adds its first row — importing alone
|
||||
// must not have injected a viewport child (docs/research/2026-08-11-op2-review-
|
||||
// blast.md SHOULD-FIX 1).
|
||||
Assert.Empty(listBox.Children);
|
||||
Assert.Equal(0, listBox.ContentHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -224,10 +238,15 @@ public class OptionsPanelLayoutConformanceTests
|
|||
public void OptionsPanelFixture_Bitfield64Template_BuildsAsEmptyUiCheckboxBitfield64()
|
||||
{
|
||||
// UIOption_CheckboxBitfield64 (0x10000044), template element 0x10000520:
|
||||
// authors zero children/media in the dat — every row is added at runtime.
|
||||
// authors zero children/media of its OWN, but DOES author its own row-template
|
||||
// list (dat property 0x64 -> {0x2100002B, 0x10000521}) — every row is built
|
||||
// from THAT template at runtime (OP2 rework,
|
||||
// docs/research/2026-08-11-op2-review-mechanism.md MUST-FIX 4).
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var bitfield = Assert.IsType<UiCheckboxBitfield64>(layout.FindElement(0x10000520u));
|
||||
Assert.Empty(bitfield.Rows);
|
||||
UiTemplateListEntry template = Assert.Single(bitfield.Templates);
|
||||
Assert.Equal(new UiTemplateListEntry(0x2100002Bu, 0x10000521u), template);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -244,19 +263,48 @@ public class OptionsPanelLayoutConformanceTests
|
|||
Assert.NotNull(slider);
|
||||
}
|
||||
|
||||
// ── UiCheckboxBitfield64 behavior (structural, click semantics flagged) ──
|
||||
// ── UiCheckboxBitfield64 behavior (rows built from the authored template) ──
|
||||
|
||||
/// <summary>Builds a hermetic <see cref="UiCheckboxBitfield64"/> whose
|
||||
/// <see cref="UiCheckboxBitfield64.TemplateResolver"/> constructs a minimal subtree
|
||||
/// containing ONLY the checkbox retail's own CreateChildren looks for
|
||||
/// (<see cref="UiCheckboxBitfield64.TemplateCheckboxElementId"/>) — proves the
|
||||
/// widget resolves and stamps that exact id rather than assuming row shape.</summary>
|
||||
private static UiCheckboxBitfield64 NewBitfieldWithStubTemplate()
|
||||
{
|
||||
var templates = new[] { new UiTemplateListEntry(0x2100002Bu, 0x10000521u) };
|
||||
return new UiCheckboxBitfield64(templates)
|
||||
{
|
||||
Width = 272f,
|
||||
TemplateResolver = (layoutId, elementId) =>
|
||||
{
|
||||
Assert.Equal(0x2100002Bu, layoutId);
|
||||
Assert.Equal(0x10000521u, elementId);
|
||||
var checkboxInfo = new ElementInfo
|
||||
{
|
||||
Id = UiCheckboxBitfield64.TemplateCheckboxElementId,
|
||||
Type = 1u,
|
||||
Width = 260f,
|
||||
Height = 14f,
|
||||
};
|
||||
return LayoutImporter.Build(checkboxInfo, NoTex, null).Root;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiCheckboxBitfield64_AddChild_TracksMaskAndTogglesOnClick()
|
||||
public void UiCheckboxBitfield64_AddChild_ResolvesRowFromAuthoredTemplate()
|
||||
{
|
||||
var bitfield = new UiCheckboxBitfield64 { Width = 272f };
|
||||
var bitfield = NewBitfieldWithStubTemplate();
|
||||
bitfield.SetDefaultValue(0x00000000_FBFFFFFFul, 0ul); // retail main-window default (research §5.2)
|
||||
|
||||
// "Error" (mask 0x04000000) is the ONE bit clear in the FBFFFFFF default —
|
||||
// research §5.2's own cross-check ("Society bit opt-in") confirms every
|
||||
// other row's bits are already set in that default, so Error is the only
|
||||
// mask that starts observably OFF and round-trips cleanly through OR/AND-NOT.
|
||||
var row = bitfield.AddChild(0x00000000_04000000ul, 0ul, "Error");
|
||||
UiButton? row = bitfield.AddChild(0x00000000_04000000ul, 0ul, "Error");
|
||||
Assert.NotNull(row);
|
||||
Assert.Equal(UiCheckboxBitfield64.TemplateCheckboxElementId, row!.DatElementId);
|
||||
Assert.False(row.Selected);
|
||||
|
||||
ulong? changedLow = null;
|
||||
|
|
@ -271,6 +319,46 @@ public class OptionsPanelLayoutConformanceTests
|
|||
Assert.Equal(0x00000000_FBFFFFFFul, changedLow); // back to the seeded default
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MUST-FIX 3a (`docs/research/2026-08-11-op2-review-mechanism.md`): retail
|
||||
/// <c>UIOption_CheckboxBitfield64::Refresh @0x004859C0</c> checks a row when ANY
|
||||
/// mask bit is set, not when EVERY mask bit is set. A single-bit mask cannot
|
||||
/// distinguish the two predicates — this seeds a value with only ONE of a
|
||||
/// TWO-bit mask's bits set (Combat's real mask <c>0x00600040</c>, research §5.2)
|
||||
/// and asserts the row still reads checked.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UiCheckboxBitfield64_MultiBitMask_IsSet_UsesAnyBitNotAllBits()
|
||||
{
|
||||
var bitfield = NewBitfieldWithStubTemplate();
|
||||
const ulong combatMask = 0x00000000_00600040ul; // two bits
|
||||
const ulong onlyOneBitOfMask = 0x00000000_00000040ul; // partially overlapping
|
||||
bitfield.SetDefaultValue(onlyOneBitOfMask, 0ul);
|
||||
|
||||
UiButton? row = bitfield.AddChild(combatMask, 0ul, "Combat");
|
||||
Assert.NotNull(row);
|
||||
Assert.True(row!.Selected); // any-bit predicate: 0x40 & 0x600040 != 0
|
||||
|
||||
// Clicking now must turn the row OFF (it currently reads checked) and clear
|
||||
// BOTH mask bits, matching retail's confirmed SetBitsOnOrOff AND-NOT.
|
||||
ulong? changedLow = null;
|
||||
bitfield.ValueChanged += (low, _) => changedLow = low;
|
||||
row.OnClick!.Invoke();
|
||||
Assert.False(row.Selected);
|
||||
Assert.Equal(0ul, changedLow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiCheckboxBitfield64_AddChild_NoTemplateResolverWired_ReturnsNullAndLeavesNoRow()
|
||||
{
|
||||
var templates = new[] { new UiTemplateListEntry(0x2100002Bu, 0x10000521u) };
|
||||
var bitfield = new UiCheckboxBitfield64(templates) { Width = 272f };
|
||||
// No TemplateResolver wired — matches DatWidgetFactory's own construction
|
||||
// (OP2 ships the mechanism; a page controller supplies the resolver).
|
||||
Assert.Null(bitfield.AddChild(0x1ul, 0ul, "Unwired"));
|
||||
Assert.Empty(bitfield.Rows);
|
||||
}
|
||||
|
||||
// ── The template mechanism, exercised end-to-end ────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -293,7 +381,9 @@ public class OptionsPanelLayoutConformanceTests
|
|||
}
|
||||
|
||||
ElementInfo characterListBoxInfo = Find(FixtureLoader.LoadOptionsCharacterInfos(), 0x100001FAu)!;
|
||||
var listBox = new UiTemplateListBox(characterListBoxInfo.TemplateList, characterListBoxInfo.ScrollbarElementId)
|
||||
var listBox = new UiTemplateListBox(
|
||||
characterListBoxInfo, NoTex,
|
||||
characterListBoxInfo.TemplateList, characterListBoxInfo.ScrollbarElementId)
|
||||
{
|
||||
TemplateResolver = Resolve,
|
||||
};
|
||||
|
|
@ -333,32 +423,46 @@ public class OptionsPanelLayoutConformanceTests
|
|||
{
|
||||
ElementInfo characterListBoxInfo = Find(FixtureLoader.LoadOptionsCharacterInfos(), 0x100001FAu)!;
|
||||
var listBox = new UiTemplateListBox(
|
||||
characterListBoxInfo, NoTex,
|
||||
characterListBoxInfo.TemplateList, characterListBoxInfo.ScrollbarElementId);
|
||||
// No TemplateResolver wired — matches DatWidgetFactory's own construction
|
||||
// (OP2 ships the mechanism; a page controller supplies the resolver).
|
||||
Assert.Null(listBox.AddItemFromTemplateList(0));
|
||||
// OP2 rework: a failed/never-attempted add must not have allocated a viewport.
|
||||
Assert.Empty(listBox.Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemplateListBox_AddItemFromTemplateList_OutOfRangeIndex_ReturnsNull()
|
||||
{
|
||||
var listBox = new UiTemplateListBox(new[] { new UiTemplateListEntry(1u, 2u) }, 0u)
|
||||
var listBox = new UiTemplateListBox(
|
||||
new ElementInfo(), NoTex, new[] { new UiTemplateListEntry(1u, 2u) }, 0u)
|
||||
{
|
||||
TemplateResolver = (_, _) => new UiDatElement(new ElementInfo(), NoTex),
|
||||
};
|
||||
Assert.Null(listBox.AddItemFromTemplateList(-1));
|
||||
Assert.Null(listBox.AddItemFromTemplateList(1));
|
||||
Assert.NotNull(listBox.AddItemFromTemplateList(0));
|
||||
// The one successful add DID lazily create the viewport.
|
||||
Assert.Single(listBox.Children);
|
||||
}
|
||||
|
||||
// ── UiTabControl behavior ────────────────────────────────────────────────
|
||||
// ── UiTabPanel behavior ──────────────────────────────────────────────────
|
||||
// OP2 rework: these all explicitly call ActivateTabBehavior() first — a Type-8
|
||||
// element is dormant on import (docs/research/2026-08-11-op2-review-blast.md /
|
||||
// -mechanism.md), so a controller (here, the test itself) must opt in before any
|
||||
// switching happens. This mirrors how OP3+'s OptionsPanelController will drive it.
|
||||
|
||||
[Fact]
|
||||
public void UiTabControl_DefaultMount_ShowsOnlyTheGameplayPage()
|
||||
public void UiTabPanel_ActivateTabBehavior_ShowsOnlyTheGameplayDefaultPage()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
|
||||
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
||||
|
||||
tabControl.ActivateTabBehavior();
|
||||
|
||||
Assert.True(tabControl.BehaviorActive);
|
||||
Assert.Empty(tabControl.UnresolvedEntries);
|
||||
Assert.Equal(0x10000212u, tabControl.ActivePageElementId); // Gameplay page slot
|
||||
|
||||
UiElement gameplayPage = layout.FindElement(0x10000212u)!;
|
||||
|
|
@ -373,10 +477,24 @@ public class OptionsPanelLayoutConformanceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void UiTabControl_SwitchTo_ShowsExactlyOnePage()
|
||||
public void UiTabPanel_ActivateTabBehavior_IsIdempotent()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
|
||||
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
||||
|
||||
tabControl.ActivateTabBehavior();
|
||||
tabControl.SwitchTo(0x10000211u); // Character page slot — deliberately not the default
|
||||
tabControl.ActivateTabBehavior(); // second call must be a no-op, not re-switch to default
|
||||
|
||||
Assert.Equal(0x10000211u, tabControl.ActivePageElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiTabPanel_SwitchTo_ShowsExactlyOnePage()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
||||
tabControl.ActivateTabBehavior();
|
||||
|
||||
tabControl.SwitchTo(0x1000050Cu); // Chat page slot
|
||||
|
||||
|
|
@ -394,10 +512,11 @@ public class OptionsPanelLayoutConformanceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void UiTabControl_SwitchTo_UpdatesTabButtonOpenClosedState()
|
||||
public void UiTabPanel_SwitchTo_UpdatesTabButtonOpenClosedState()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
|
||||
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
||||
tabControl.ActivateTabBehavior();
|
||||
|
||||
var gameplayButton = Assert.IsType<UiText>(layout.FindElement(0x1000020Du));
|
||||
var characterButton = Assert.IsType<UiText>(layout.FindElement(0x1000020Eu));
|
||||
|
|
@ -412,10 +531,11 @@ public class OptionsPanelLayoutConformanceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void UiTabControl_ClickingTabButton_SwitchesActivePage()
|
||||
public void UiTabPanel_ClickingTabButton_SwitchesActivePage()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
|
||||
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
||||
tabControl.ActivateTabBehavior(); // clicks only bind once behavior is activated
|
||||
var characterButton = layout.FindElement(0x1000020Eu)!;
|
||||
|
||||
characterButton.OnEvent(new UiEvent(0, characterButton, UiEventType.Click));
|
||||
|
|
@ -426,10 +546,28 @@ public class OptionsPanelLayoutConformanceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void UiTabControl_SwitchToSameActivePage_IsANoOp()
|
||||
public void UiTabPanel_ClickingTabButton_BeforeActivation_DoesNothing()
|
||||
{
|
||||
// OP2 rework's central regression guard: a dormant instance's tab buttons are
|
||||
// NOT click-bound yet, so a click before ActivateTabBehavior() must be inert —
|
||||
// this is exactly the property that keeps the four pre-existing shipped Type-8
|
||||
// hosts (which never call ActivateTabBehavior) safe.
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
||||
var characterButton = layout.FindElement(0x1000020Eu)!;
|
||||
|
||||
characterButton.OnEvent(new UiEvent(0, characterButton, UiEventType.Click));
|
||||
|
||||
Assert.Equal(0u, tabControl.ActivePageElementId);
|
||||
Assert.False(tabControl.BehaviorActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiTabPanel_SwitchToSameActivePage_IsANoOp()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
|
||||
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
||||
tabControl.ActivateTabBehavior();
|
||||
uint before = tabControl.ActivePageElementId;
|
||||
|
||||
tabControl.SwitchTo(before);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue