feat(ui): Campaign OP slice OP2 — tab control, template ListBox, UIOption widget mappings
Ships the two new widget primitives the retail Options panel needs plus the
four remaining UIOption_* factory mappings, so every tab page (OP3-OP6) has
somewhere to mount.
- ElementReader/ElementInfo gain three new dat-property readers, following
the existing effective-state-resolution pattern (never a per-state
first-wins scan, per the round-5 N1 lesson): the Type-8 tab table
(property 0x2E -> TabTable), a ListBox's row-template list (property
0x64 -> TemplateList), and scrollbar linkage (property 0x72 ->
ScrollbarElementId). LayoutImporter gains one hook
(IUiChildrenAttachedListener) so a widget can resolve cross-references
its own dat properties name by id once its subtree actually exists.
- UiTabControl (Type 8): switches exactly one page-slot child visible,
syncs each tab button's Open/Closed state via the existing
RetailTabBinding helper, and honors the authored default tab on mount.
- UiTemplateListBox (Type 5 with an authored template list): wraps a
UiScrollablePanel viewport (sealed, so composition not inheritance) and
ports AddItemFromTemplateList(index) — the resolver seam a page
controller wires with real DAT access via the SAME
LayoutImporter.ImportInfos(dats, layoutId, elementId) overload
RetailDialogFactory already uses for its catalog LayoutDesc.
- DatWidgetFactory maps the four remaining UIOption_* widgets, each
verified against the regenerated options_2100002B.json fixture before
writing any code: 0x10000037 (Slider) is structurally an ordinary
horizontal UIElement_Scrollbar, so it reuses BuildScrollbar directly;
0x10000038 (Menu) is structurally identical to the vendor category
dropdown UiMenu already models, so it reuses `new UiMenu()` like the
Type-6 case; 0x10000036 (CheckboxSlider) composes an existing
UIOption_Checkbox child + UIOption_Slider child via the new
UiOptionToggleSlider wrapper; 0x10000044 (CheckboxBitfield64) authors
zero children in the dat (every row is added at runtime via retail's own
AddChild(lowMask, highMask, label, tooltip) call shape), so it's a new
UiCheckboxBitfield64 composing UiButton per row. No new drawing code
anywhere in this set.
- Five new committed fixtures (options_2100002B/2100002A/21000028/
2100005C/21000029) plus 25 new conformance tests pinning the tab table
(4 entries, Gameplay default), all three template arrays, scrollbar
linkage, every new widget-type mapping, and a UiTabControl behavioral
test (switch -> exactly one page visible, click-through the tab
button). The Character ListBox's authored 6-header/49-toggle shape
(lane B section counts) is proven reachable end-to-end through
AddItemFromTemplateList against the committed fixture.
- Regenerating fixtures also touched 27 PRE-EXISTING, unrelated fixtures
(an Outline/OutlineColor field pair added by an earlier commit,
bcc34ee3, that predates when those fixtures were last regenerated).
Per the slice contract, that drift was NOT committed — reverted back to
HEAD, only the five new Options-panel fixtures are new files here.
- Filed TS-72: UiCheckboxBitfield64's click-toggle bit math (AND/OR
set/clear semantics) is a documented approximation — the decompiled
excerpt this campaign pulled covers UIOption_CheckboxBitfield64::Apply's
WRITE side, not its own click-handler's bit math. Flagged for OP5 (the
Chat tab controller, the first consumer that reaches the wire) to
verify against the real decomp before any live transaction depends on
it; nothing user-reachable can observe this yet.
Full Release suite: 12,770 passed / 4 skipped / 0 failed (was 12,745/4/0
post-OP1 — 25 net new tests, zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0df0a60424
commit
df9c7a35eb
17 changed files with 51896 additions and 1 deletions
File diff suppressed because one or more lines are too long
19
src/AcDream.App/UI/IUiChildrenAttachedListener.cs
Normal file
19
src/AcDream.App/UI/IUiChildrenAttachedListener.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Notifies a widget that its full imported dat child subtree has just been built and
|
||||
/// attached (via <see cref="UiElement.AddChild"/>). A widget that must resolve OTHER
|
||||
/// children by dat element id to wire cross-references authored on itself — e.g. the
|
||||
/// Type-8 tab control's tab table (dat property <c>0x2E</c>) naming a tab-button child
|
||||
/// and a page-slot child by id — cannot do that resolution inside
|
||||
/// <see cref="AcDream.App.UI.Layout.DatWidgetFactory.Create"/>, because at that point
|
||||
/// the element under construction has no children yet (they are built and attached by
|
||||
/// <c>LayoutImporter.BuildWidget</c> immediately afterward). Implementing this interface
|
||||
/// gets a callback once the subtree actually exists — mirrors the existing
|
||||
/// <see cref="IUiDatStateful"/> post-attach hook (retail
|
||||
/// <c>UIElement::SetState @ 0x00464E70</c> propagates only after the child tree exists).
|
||||
/// </summary>
|
||||
public interface IUiChildrenAttachedListener
|
||||
{
|
||||
void OnChildrenAttached();
|
||||
}
|
||||
|
|
@ -93,8 +93,21 @@ public static class DatWidgetFactory
|
|||
or IndicatorBarController.VitaeClassId => BuildButton(
|
||||
info, resolve, elementFont, fontResolve, stringResolve),
|
||||
// gmUIElement_*Indicator custom button classes
|
||||
// UIElement_ListBox (Type 5) with an authored row-template list (dat
|
||||
// property 0x64) — the Options panel's Character/Chat/Config ListBoxes
|
||||
// (docs/research/2026-08-10-options-panel-structure.md §1.5). Gated on
|
||||
// info.TemplateList.Count > 0 so an ORDINARY Type-5 ListBox that authors
|
||||
// no template array (none currently reach this factory — vendor's category
|
||||
// dropdown is drawn procedurally by UiMenu.Scrollable, not imported as a
|
||||
// live Type-5 widget) keeps falling to the generic UiDatElement fallback
|
||||
// unchanged (OP2 acceptance: no behavior change to any existing widget).
|
||||
5 when info.TemplateList.Count > 0 => new UiTemplateListBox(
|
||||
info.TemplateList, info.ScrollbarElementId),
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
||||
// UIElement_TabControl (Type 8) — the Options panel's tab strip (dat
|
||||
// property 0x2E; same research doc §1.3/§10.1).
|
||||
8 => new UiTabControl(info.TabTable),
|
||||
9 => BuildResizeGrip(info, resolve), // UIElement_Resizebar (reg 0x0046B920)
|
||||
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
||||
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
||||
|
|
@ -103,6 +116,37 @@ public static class DatWidgetFactory
|
|||
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
||||
0x10000035u => BuildCheckbox(
|
||||
info, resolve, elementFont, fontResolve, stringResolve), // UIOption_Checkbox
|
||||
// UIOption_CheckboxSlider (Type 0x10000036): a composite row whose class id
|
||||
// lands on the row root itself, but whose content is two NESTED option
|
||||
// widgets (a UIOption_Checkbox child + a UIOption_Slider child — verified
|
||||
// against options_2100002B.json's templates 0x10000220/0x10000221). It does
|
||||
// not consume its dat children, so those build normally through the two
|
||||
// mappings immediately below; UiOptionToggleSlider just grabs references to
|
||||
// them once attached (see docs/research/2026-08-10-options-panel-structure.md
|
||||
// §1.1/§1.5).
|
||||
0x10000036u => new UiOptionToggleSlider(),
|
||||
// UIOption_Slider (Type 0x10000037): structurally an ordinary HORIZONTAL
|
||||
// UIElement_Scrollbar — its own DirectState carries the track sprite and its
|
||||
// child id 1 is the drag thumb, the exact convention BuildScrollbar's
|
||||
// horizontal branch already implements (verified against
|
||||
// options_2100002B.json's slider control 0x1000021C: W=120 > H=12, one
|
||||
// Type-1 child at id 1). No new drawing code — same "compose existing
|
||||
// primitives" directive as UiOptionToggleSlider above.
|
||||
0x10000037u => BuildScrollbar(info, resolve),
|
||||
// UIOption_Menu (Type 0x10000038): a label + arrow-cap dropdown button,
|
||||
// structurally identical to the vendor category dropdown UiMenu already
|
||||
// models (verified against options_2100002B.json's menu control 0x10000224:
|
||||
// a Text label child + a 17x19 image child, matching UiMenu's own
|
||||
// ArrowCapClosedSprite doc comment). Built blank, exactly like the Type-6
|
||||
// case above — a page controller wires its sprites/items the same way
|
||||
// ChatWindowController wires the channel menu.
|
||||
0x10000038u => new UiMenu(),
|
||||
// UIOption_CheckboxBitfield64 (Type 0x10000044): the Chat tab's per-window
|
||||
// text-filter block. Its authored template (0x10000520) carries no children
|
||||
// or media at all — every row is added at runtime via AddChild(lowMask,
|
||||
// highMask, label, tooltip), matching retail's own
|
||||
// gmChatOptionsUI::AddCheckboxBitfield64Option call pattern (research doc §5.2).
|
||||
0x10000044u => BuildCheckboxBitfield64(resolve, elementFont),
|
||||
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
||||
};
|
||||
|
||||
|
|
@ -756,6 +800,21 @@ public static class DatWidgetFactory
|
|||
return button;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a blank <see cref="UiCheckboxBitfield64"/> seeded with the sprite
|
||||
/// resolver and dat font a page controller's later
|
||||
/// <see cref="UiCheckboxBitfield64.AddChild(ulong, ulong, string, string?)"/>
|
||||
/// calls need — the template authors no rows, so there is nothing else to read
|
||||
/// from <c>ElementInfo</c> at import time.
|
||||
/// </summary>
|
||||
private static UiCheckboxBitfield64 BuildCheckboxBitfield64(
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? elementFont)
|
||||
=> new()
|
||||
{
|
||||
SpriteResolve = resolve,
|
||||
LabelFont = elementFont,
|
||||
};
|
||||
|
||||
private static ElementInfo? FindStatefulFaceChild(ElementInfo info)
|
||||
=> FindStatefulFaceChildren(info).FirstOrDefault();
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,27 @@ public enum HJustify : byte { Left = 0, Center = 1, Right = 2 }
|
|||
/// </summary>
|
||||
public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 }
|
||||
|
||||
/// <summary>
|
||||
/// One row of a Type-8 tab control's authored tab table (dat property <c>0x2E</c>, an
|
||||
/// array of <c>StructBaseProperty</c> with <c>MasterPropertyId 0x2F</c>). Retail
|
||||
/// <c>0x2100002B</c> §1.3: <see cref="ButtonElementId"/> is the clickable tab button
|
||||
/// (struct member <c>0x30</c>), <see cref="PageElementId"/> is the page-slot child it
|
||||
/// activates (<c>0x31</c>), and <see cref="IsDefault"/> marks the one entry
|
||||
/// (<c>0x32</c>) whose page is shown when the tab control first mounts.
|
||||
/// </summary>
|
||||
public readonly record struct UiTabTableEntry(uint ButtonElementId, uint PageElementId, bool IsDefault);
|
||||
|
||||
/// <summary>
|
||||
/// One row of a Type-5 ListBox's authored row-template list (dat property <c>0x64</c>,
|
||||
/// an array of <c>StructBaseProperty</c>). Retail <c>0x2100002B</c> §1.5:
|
||||
/// <see cref="TemplateLayoutId"/> is the LayoutDesc DID carrying the template element
|
||||
/// (struct member <c>0x63</c>), <see cref="TemplateElementId"/> is the template root
|
||||
/// within that layout (<c>0x62</c>). Retail
|
||||
/// <c>UIElement_ListBox::AddItemFromTemplateList(index)</c> instantiates row
|
||||
/// <c>index</c>'s subtree through this pair.
|
||||
/// </summary>
|
||||
public readonly record struct UiTemplateListEntry(uint TemplateLayoutId, uint TemplateElementId);
|
||||
|
||||
/// <summary>
|
||||
/// GL-free, dat-free snapshot of a resolved layout element.
|
||||
/// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c>
|
||||
|
|
@ -158,6 +179,33 @@ public sealed class ElementInfo
|
|||
/// </summary>
|
||||
public List<ElementInfo> Children = new();
|
||||
|
||||
/// <summary>
|
||||
/// Tab table read from dat property <c>0x2E</c> (Type-8 <c>UIElement_TabControl</c>
|
||||
/// only — empty on every other element). Populated once via
|
||||
/// <see cref="ElementReader.ApplyCanonicalLegacyProjection"/> using the canonical
|
||||
/// effective-state resolution, exactly like <see cref="Outline"/>/<see cref="OutlineColor"/>
|
||||
/// above — never scanned per-state as each <c>StateDesc</c> is read (round-5 N1's
|
||||
/// "first wins on any state can pick a non-effective override" trap applies equally
|
||||
/// here; see the comment on the skipped per-state 0x21 read in
|
||||
/// <c>LayoutImporter.ReadState</c>).
|
||||
/// </summary>
|
||||
public List<UiTabTableEntry> TabTable = new();
|
||||
|
||||
/// <summary>
|
||||
/// ListBox row-template list read from dat property <c>0x64</c> (Type-5
|
||||
/// <c>UIElement_ListBox</c> elements that author one — empty otherwise). Populated
|
||||
/// the same way as <see cref="TabTable"/>.
|
||||
/// </summary>
|
||||
public List<UiTemplateListEntry> TemplateList = new();
|
||||
|
||||
/// <summary>
|
||||
/// Element id of this ListBox's linked scrollbar, read from dat property
|
||||
/// <c>0x72</c> (e.g. the Character page's ListBox <c>0x100001FA</c> names
|
||||
/// scrollbar <c>0x100001FB</c>). 0 when the element authors no scrollbar
|
||||
/// reference. Populated the same way as <see cref="TabTable"/>.
|
||||
/// </summary>
|
||||
public uint ScrollbarElementId;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a property for a state using retail's DirectState-as-base rule. A
|
||||
/// named state's key overrides DirectState by presence, including false/zero.
|
||||
|
|
@ -438,6 +486,96 @@ public static class ElementReader
|
|||
info.OutlineColor = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
|
||||
}
|
||||
}
|
||||
|
||||
// Tab table (0x2E): array of StructBaseProperty (MasterPropertyId 0x2F) — the
|
||||
// Type-8 tab control's authored {button element, page element, isDefault} rows
|
||||
// (docs/research/2026-08-10-options-panel-structure.md §1.3). Recomputed fresh
|
||||
// from the current effective state every call, so Merge's base+derived state
|
||||
// combination is picked up automatically without a separate scalar-merge rule.
|
||||
info.TabTable = ReadTabTable(info);
|
||||
|
||||
// Row-template list (0x64): a ListBox's authored {template layout DID (0x63),
|
||||
// template element id (0x62)} rows consumed by AddItemFromTemplateList
|
||||
// (same doc §1.5).
|
||||
info.TemplateList = ReadTemplateList(info);
|
||||
|
||||
// Scrollbar linkage (0x72): the element id of the ListBox's paired scrollbar
|
||||
// (same doc §10.1 — e.g. Character ListBox 0x100001FA names scrollbar
|
||||
// 0x100001FB).
|
||||
info.ScrollbarElementId = ReadReferencedElementId(info, 0x72u);
|
||||
}
|
||||
|
||||
private static List<UiTabTableEntry> ReadTabTable(ElementInfo info)
|
||||
{
|
||||
var entries = new List<UiTabTableEntry>();
|
||||
if (!info.TryGetEffectiveProperty(0x2Eu, out var property)
|
||||
|| property.Kind != UiPropertyKind.Array)
|
||||
return entries;
|
||||
|
||||
foreach (UiPropertyValue item in property.ArrayValue)
|
||||
{
|
||||
if (item.Kind != UiPropertyKind.Struct) continue;
|
||||
uint buttonId = ReadStructMemberId(item.StructValue, 0x30u);
|
||||
uint pageId = ReadStructMemberId(item.StructValue, 0x31u);
|
||||
bool isDefault = item.StructValue.TryGetValue(0x32u, out var flag)
|
||||
&& flag.Kind == UiPropertyKind.Bool
|
||||
&& flag.BoolValue;
|
||||
entries.Add(new UiTabTableEntry(buttonId, pageId, isDefault));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static List<UiTemplateListEntry> ReadTemplateList(ElementInfo info)
|
||||
{
|
||||
var entries = new List<UiTemplateListEntry>();
|
||||
if (!info.TryGetEffectiveProperty(0x64u, out var property)
|
||||
|| property.Kind != UiPropertyKind.Array)
|
||||
return entries;
|
||||
|
||||
foreach (UiPropertyValue item in property.ArrayValue)
|
||||
{
|
||||
if (item.Kind != UiPropertyKind.Struct) continue;
|
||||
uint layoutDid = ReadStructMemberId(item.StructValue, 0x63u);
|
||||
uint elementId = ReadStructMemberId(item.StructValue, 0x62u);
|
||||
entries.Add(new UiTemplateListEntry(layoutDid, elementId));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coerces a struct member (a nested <see cref="UiPropertyValue"/> keyed by its own
|
||||
/// master-property id) into an element/DID-shaped uint. Mirrors
|
||||
/// <c>DatWidgetFactory.ReferencedElementId</c>'s Enum/DataId/Integer kind handling
|
||||
/// exactly — element and DID references are authored inconsistently across dat
|
||||
/// property kinds, so every reader that resolves one needs the same tolerance.
|
||||
/// </summary>
|
||||
private static uint ReadStructMemberId(IReadOnlyDictionary<uint, UiPropertyValue> members, uint key)
|
||||
{
|
||||
if (!members.TryGetValue(key, out var value))
|
||||
return 0u;
|
||||
return value.Kind switch
|
||||
{
|
||||
UiPropertyKind.Enum or UiPropertyKind.DataId => (uint)value.UnsignedValue,
|
||||
UiPropertyKind.Integer when value.IntegerValue >= 0 => (uint)value.IntegerValue,
|
||||
_ => 0u,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Same Enum/DataId/Integer coercion as <see cref="ReadStructMemberId"/>,
|
||||
/// applied to a top-level effective property instead of a struct member — used for
|
||||
/// scalar element-id references like property 0x72.</summary>
|
||||
private static uint ReadReferencedElementId(ElementInfo info, uint propertyId)
|
||||
{
|
||||
if (!info.TryGetEffectiveProperty(propertyId, out var property))
|
||||
return 0u;
|
||||
return property.Kind switch
|
||||
{
|
||||
UiPropertyKind.Enum or UiPropertyKind.DataId => (uint)property.UnsignedValue,
|
||||
UiPropertyKind.Integer when property.IntegerValue >= 0 => (uint)property.IntegerValue,
|
||||
_ => 0u,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -168,6 +168,12 @@ public static class LayoutImporter
|
|||
if (w is IUiDatStateful stateful)
|
||||
stateful.TrySetRetailState(stateful.ActiveRetailStateId);
|
||||
|
||||
// See IUiChildrenAttachedListener: a widget that must resolve OTHER children by
|
||||
// dat element id (e.g. UiTabControl's tab table) can only do so once its subtree
|
||||
// is actually attached, which just happened above.
|
||||
if (w is IUiChildrenAttachedListener childrenAttached)
|
||||
childrenAttached.OnChildrenAttached();
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
|
|
|
|||
162
src/AcDream.App/UI/UiCheckboxBitfield64.cs
Normal file
162
src/AcDream.App/UI/UiCheckboxBitfield64.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIOption_CheckboxBitfield64</c> (Type <c>0x10000044</c>) — a block of
|
||||
/// individually toggleable mask checkboxes, built ENTIRELY at runtime. Its authored
|
||||
/// dat template (<c>0x10000520</c> in <c>0x2100002B</c>, 272x100) carries NO children
|
||||
/// and NO media — verified against the regenerated <c>options_2100002B.json</c>
|
||||
/// fixture. Retail C++ populates it by calling <c>AddChild(lowMask, highMask,
|
||||
/// labelId, tooltipId)</c> once per checkbox (research doc §5.2:
|
||||
/// <c>gmChatOptionsUI::InitOptions</c> / <c>AddCheckboxBitfield64Option</c>), N times
|
||||
/// per window, then <c>CreateChildren()</c>. This widget ports that shape:
|
||||
/// <see cref="AddChild(ulong, ulong, string, string?)"/> appends one row, reusing
|
||||
/// <see cref="UiButton"/> (with retail's toggle-behavior property <c>0x0B</c> seeded)
|
||||
/// for the clickable/labelled row itself rather than drawing anything new —
|
||||
/// consistent with the campaign contract's "compose existing primitives" directive.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Click semantics: retail's exact bit-toggle algorithm (what
|
||||
/// <c>UIOption_CheckboxBitfield64</c>'s own click handler does to <c>m_current</c>, as
|
||||
/// opposed to <c>UIOption_CheckboxBitfield64::Apply</c>'s documented WRITE side in
|
||||
/// research doc §3.7) is not in the decompiled excerpt this campaign pulled. This
|
||||
/// widget applies the conservative, retail-consistent reading — a row is "on" when
|
||||
/// ALL its mask bits are set, and a click SETS (turns on) or CLEARS (turns off)
|
||||
/// exactly those bits — and raises <see cref="ValueChanged"/> so a page controller can
|
||||
/// verify/replace the exact algorithm against the real decomp before wiring the
|
||||
/// authoritative <c>PlayerModule::SetChatWindowOption</c> transaction (Campaign OP
|
||||
/// slice OP5).
|
||||
/// </remarks>
|
||||
public sealed class UiCheckboxBitfield64 : UiPanel
|
||||
{
|
||||
/// <summary>One added checkbox row.</summary>
|
||||
public readonly record struct Row(
|
||||
ulong LowMask, ulong HighMask, string Label, string? Tooltip, UiButton Toggle);
|
||||
|
||||
private readonly List<Row> _rows = new();
|
||||
|
||||
/// <summary>The rows added so far, in <see cref="AddChild(ulong, ulong, string, string?)"/> call order.</summary>
|
||||
public IReadOnlyList<Row> Rows => _rows;
|
||||
|
||||
/// <summary>Current low 64 bits of the option's bitfield value.</summary>
|
||||
public ulong CurrentLow { get; private set; }
|
||||
|
||||
/// <summary>Current high 64 bits of the option's bitfield value.</summary>
|
||||
public ulong CurrentHigh { get; private set; }
|
||||
|
||||
private ulong _defaultLow, _defaultHigh;
|
||||
|
||||
/// <summary>Row height in px for the stacked layout. Rows lay out top-to-bottom in
|
||||
/// call order, matching retail's authored call sequence. No authored row height
|
||||
/// exists for this block (it is unauthored in the dat) — a page controller may
|
||||
/// override before the first <see cref="AddChild(ulong, ulong, string, string?)"/> call.</summary>
|
||||
public float RowHeight { get; set; } = 14f;
|
||||
|
||||
/// <summary>Dat font for row labels.</summary>
|
||||
public UiDatFont? LabelFont { get; set; }
|
||||
|
||||
// SpriteResolve (forwarded to each row's UiButton) is inherited from UiPanel —
|
||||
// same resolver shape, no need to redeclare it.
|
||||
|
||||
/// <summary>Fired with the new (low, high) value after any row toggles.</summary>
|
||||
public Action<ulong, ulong>? ValueChanged { get; set; }
|
||||
|
||||
public UiCheckboxBitfield64()
|
||||
{
|
||||
BackgroundColor = Vector4.Zero;
|
||||
BorderColor = Vector4.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIOption::SetDefaultValue(low, high)</c>: sets the restore-to-defaults
|
||||
/// target. Also seeds the current value while no rows exist yet (construction
|
||||
/// order mirrors retail: <c>SetDefaultValue</c> then N <c>AddChild</c> calls, so
|
||||
/// each row's initial checked state reads the seeded default).
|
||||
/// </summary>
|
||||
public void SetDefaultValue(ulong low, ulong high)
|
||||
{
|
||||
_defaultLow = low;
|
||||
_defaultHigh = high;
|
||||
if (_rows.Count == 0)
|
||||
{
|
||||
CurrentLow = low;
|
||||
CurrentHigh = high;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reverts to the last <see cref="SetDefaultValue"/> target and refreshes
|
||||
/// every row's checked state — the Defaults-button verb (research doc §3.3).</summary>
|
||||
public void RestoreDefaultValue()
|
||||
{
|
||||
CurrentLow = _defaultLow;
|
||||
CurrentHigh = _defaultHigh;
|
||||
RefreshRowVisuals();
|
||||
ValueChanged?.Invoke(CurrentLow, CurrentHigh);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIOption_CheckboxBitfield64::AddChild(lowMask, highMask, labelId,
|
||||
/// tooltipId)</c>: appends one toggleable row for the given mask, stacked below
|
||||
/// the previous row. <paramref name="label"/>/<paramref name="tooltip"/> are
|
||||
/// already-resolved strings — string-table lookup is the caller's job (matches
|
||||
/// <see cref="UiMenu.MenuItem"/>'s own already-resolved-Label convention).
|
||||
/// </summary>
|
||||
public UiButton AddChild(ulong lowMask, ulong highMask, string label, string? tooltip = null)
|
||||
{
|
||||
var rowInfo = new ElementInfo { Type = 1u, Width = Width, Height = RowHeight };
|
||||
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId, Name = "" };
|
||||
direct.Properties.Values[0x0Bu] = new UiPropertyValue
|
||||
{
|
||||
Kind = UiPropertyKind.Bool,
|
||||
BoolValue = true, // ToggleBehavior (property 0x0B) — UiButton reads this in its ctor.
|
||||
};
|
||||
rowInfo.States[UiStateInfo.DirectStateId] = direct;
|
||||
|
||||
var toggle = new UiButton(rowInfo, SpriteResolve ?? (_ => (0u, 0, 0)))
|
||||
{
|
||||
Label = label,
|
||||
LabelFont = LabelFont,
|
||||
LabelAlign = UiButton.LabelAlignment.Left,
|
||||
Left = 0f,
|
||||
Top = _rows.Count * RowHeight,
|
||||
Width = Width,
|
||||
Height = RowHeight,
|
||||
Selected = IsSet(lowMask, highMask),
|
||||
};
|
||||
toggle.OnClick = () => ToggleRow(lowMask, highMask, toggle);
|
||||
|
||||
_rows.Add(new Row(lowMask, highMask, label, tooltip, toggle));
|
||||
AddChild(toggle);
|
||||
return toggle;
|
||||
}
|
||||
|
||||
private bool IsSet(ulong lowMask, ulong highMask)
|
||||
=> (CurrentLow & lowMask) == lowMask && (CurrentHigh & highMask) == highMask;
|
||||
|
||||
private void ToggleRow(ulong lowMask, ulong highMask, UiButton toggle)
|
||||
{
|
||||
bool turnOn = !IsSet(lowMask, highMask);
|
||||
if (turnOn)
|
||||
{
|
||||
CurrentLow |= lowMask;
|
||||
CurrentHigh |= highMask;
|
||||
}
|
||||
else
|
||||
{
|
||||
CurrentLow &= ~lowMask;
|
||||
CurrentHigh &= ~highMask;
|
||||
}
|
||||
toggle.Selected = turnOn;
|
||||
ValueChanged?.Invoke(CurrentLow, CurrentHigh);
|
||||
}
|
||||
|
||||
private void RefreshRowVisuals()
|
||||
{
|
||||
foreach (Row row in _rows)
|
||||
row.Toggle.Selected = IsSet(row.LowMask, row.HighMask);
|
||||
}
|
||||
}
|
||||
43
src/AcDream.App/UI/UiOptionToggleSlider.cs
Normal file
43
src/AcDream.App/UI/UiOptionToggleSlider.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIOption_CheckboxSlider</c> (Type <c>0x10000036</c>) — a combined
|
||||
/// toggle + slider option row (e.g. the Config tab's "Sound / Volume" pair: an
|
||||
/// on/off LED plus a volume slider in one row). The dat class id lands on the
|
||||
/// composite ROW'S OWN element (research doc §1.1: "row child element looked up —
|
||||
/// the row root itself"), and the row authors NO media of its own; its content is
|
||||
/// two NESTED option widgets — a <c>UIOption_Checkbox</c> child (dat id
|
||||
/// <c>0x10000219</c> in every observed template) and a <c>UIOption_Slider</c> child
|
||||
/// (<c>0x1000021C</c>) — verified against the regenerated <c>options_2100002B.json</c>
|
||||
/// fixture's template elements <c>0x10000220</c>/<c>0x10000221</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// This widget does <b>not</b> consume its dat children: they build the ordinary
|
||||
/// recursive way through <c>DatWidgetFactory</c>'s EXISTING checkbox (<c>0x10000035</c>
|
||||
/// → <see cref="UiButton"/> via <c>BuildCheckbox</c>) and slider (<c>0x10000037</c> →
|
||||
/// <see cref="UiScrollbar"/> via <c>BuildScrollbar</c>) mappings — no new drawing code,
|
||||
/// per the campaign contract's "compose existing primitives" directive. Once the
|
||||
/// subtree is attached (<see cref="IUiChildrenAttachedListener"/>), this widget just
|
||||
/// grabs references to the two composed children for a page controller to bind
|
||||
/// against directly instead of re-deriving them from child order/type.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiOptionToggleSlider : UiElement, IUiChildrenAttachedListener
|
||||
{
|
||||
/// <summary>The composed checkbox (retail's LED half of the row).</summary>
|
||||
public UiButton? Toggle { get; private set; }
|
||||
|
||||
/// <summary>The composed slider (retail's scalar half of the row).</summary>
|
||||
public UiScrollbar? Slider { get; private set; }
|
||||
|
||||
public void OnChildrenAttached()
|
||||
{
|
||||
foreach (UiElement child in Children)
|
||||
{
|
||||
if (Toggle is null && child is UiButton button)
|
||||
Toggle = button;
|
||||
else if (Slider is null && child is UiScrollbar scrollbar)
|
||||
Slider = scrollbar;
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/AcDream.App/UI/UiTabControl.cs
Normal file
112
src/AcDream.App/UI/UiTabControl.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIElement_TabControl</c> (Type 8) — the Options panel's tab strip +
|
||||
/// mounted-page switcher. Owns the authored tab table (dat property <c>0x2E</c>,
|
||||
/// see <see cref="UiTabTableEntry"/>): activating a tab shows exactly ONE page-slot
|
||||
/// child and hides the rest, and updates the corresponding tab button's Open/Closed
|
||||
/// visual state.
|
||||
///
|
||||
/// <para>
|
||||
/// The tab buttons and page-slot children are ORDINARY imported dat elements — this
|
||||
/// widget does <b>not</b> set <see cref="UiElement.ConsumesDatChildren"/>, so
|
||||
/// <c>LayoutImporter</c> builds them the normal recursive way (buttons build as
|
||||
/// <see cref="UiText"/> with Open/Closed states — retail's tab buttons are Type 0xC
|
||||
/// text elements, not Type-1 buttons; page slots build as generic containers whose
|
||||
/// OWN children are the mounted page's content via <c>BaseLayoutId</c>/
|
||||
/// <c>BaseElement</c> inheritance). This widget only wires cross-references the tab
|
||||
/// table names by id, which it can only resolve once that subtree exists — see
|
||||
/// <see cref="IUiChildrenAttachedListener"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail anchors: <c>docs/research/2026-08-10-options-panel-structure.md</c> §1.3
|
||||
/// (the tab table property <c>0x2E</c> struct shape), §10.1 (structural inventory —
|
||||
/// tab buttons `0x1000020D..0x1000020F`/`0x1000050B`, page slots
|
||||
/// `0x10000211..0x10000213`/`0x1000050C`, Gameplay the authored default). Verified
|
||||
/// byte-for-byte against the regenerated <c>options_2100002B.json</c> fixture: four
|
||||
/// entries, Gameplay (`0x1000020D`/`0x10000212`) is the sole <c>IsDefault</c> row.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's <c>OnVisibilityChanged</c> auto-apply/auto-revert semantics (research doc
|
||||
/// §3.6 — hiding a page reverts uncommitted edits, showing one applies + commits) are
|
||||
/// OUT of this widget's scope: they belong to the <c>OptionPage</c>/
|
||||
/// <c>PlayerOptionPage</c> model a page controller owns (Campaign OP slice OP3+).
|
||||
/// This widget only switches which page slot is <see cref="UiElement.Visible"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiTabControl : UiElement, IUiChildrenAttachedListener
|
||||
{
|
||||
private readonly IReadOnlyList<UiTabTableEntry> _tabs;
|
||||
|
||||
public UiTabControl(IReadOnlyList<UiTabTableEntry> tabs)
|
||||
{
|
||||
_tabs = tabs;
|
||||
}
|
||||
|
||||
/// <summary>The authored tab table this control was built from, in authored array order.</summary>
|
||||
public IReadOnlyList<UiTabTableEntry> Tabs => _tabs;
|
||||
|
||||
/// <summary>Dat element id of the currently active page slot. 0 before the first mount.</summary>
|
||||
public uint ActivePageElementId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Retail wires the tab table's cross-references (button ↔ page ↔ default) once
|
||||
/// the imported subtree exists — see <see cref="IUiChildrenAttachedListener"/>'s
|
||||
/// doc for why this can't happen during <c>DatWidgetFactory.Create</c>. Every tab
|
||||
/// button's click is bound to switch to its paired page, then the authored
|
||||
/// default tab (the entry with <see cref="UiTabTableEntry.IsDefault"/> true, or
|
||||
/// the first entry if the dat authored none) is activated.
|
||||
/// </summary>
|
||||
public void OnChildrenAttached()
|
||||
{
|
||||
UiTabTableEntry? defaultEntry = null;
|
||||
foreach (UiTabTableEntry entry in _tabs)
|
||||
{
|
||||
UiElement? button = FindDirectChild(entry.ButtonElementId);
|
||||
uint pageId = entry.PageElementId;
|
||||
RetailTabBinding.SetClick(button, () => SwitchTo(pageId));
|
||||
|
||||
if (entry.IsDefault)
|
||||
defaultEntry = entry;
|
||||
}
|
||||
|
||||
defaultEntry ??= _tabs.Count > 0 ? _tabs[0] : null;
|
||||
if (defaultEntry is { } def)
|
||||
SwitchTo(def.PageElementId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates the page slot named <paramref name="pageElementId"/>: shows it, hides
|
||||
/// every other authored page slot, and sets each tab button's Open/Closed state to
|
||||
/// match (<see cref="RetailTabBinding.SetOpen"/>). No-op if the page is already active.
|
||||
/// </summary>
|
||||
public void SwitchTo(uint pageElementId)
|
||||
{
|
||||
if (ActivePageElementId == pageElementId) return;
|
||||
|
||||
foreach (UiTabTableEntry entry in _tabs)
|
||||
{
|
||||
bool active = entry.PageElementId == pageElementId;
|
||||
UiElement? page = FindDirectChild(entry.PageElementId);
|
||||
if (page is not null) page.Visible = active;
|
||||
|
||||
UiElement? button = FindDirectChild(entry.ButtonElementId);
|
||||
RetailTabBinding.SetOpen(button, active);
|
||||
}
|
||||
|
||||
ActivePageElementId = pageElementId;
|
||||
}
|
||||
|
||||
private UiElement? FindDirectChild(uint datElementId)
|
||||
{
|
||||
foreach (UiElement child in Children)
|
||||
if (child.DatElementId == datElementId)
|
||||
return child;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
119
src/AcDream.App/UI/UiTemplateListBox.cs
Normal file
119
src/AcDream.App/UI/UiTemplateListBox.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIElement_ListBox</c> (Type 5) with an authored row-template list (dat
|
||||
/// property <c>0x64</c>). Port of <c>AddItemFromTemplateList(index)</c>: instantiates
|
||||
/// row <paramref name="index"/>'s template — a <see cref="UiTemplateListEntry"/>
|
||||
/// naming a cross-layout <c>{LayoutDesc DID, element id}</c> pair, per
|
||||
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> §1.5 — through the SAME
|
||||
/// import machinery every other retained window uses, and appends it as one
|
||||
/// scrollable row.
|
||||
///
|
||||
/// <para>
|
||||
/// Wraps a <see cref="UiScrollablePanel"/> as its single child rather than
|
||||
/// duplicating its scroll logic — <see cref="UiScrollablePanel"/> is sealed, and this
|
||||
/// is the SAME "controller-built row list" viewport pattern
|
||||
/// <c>CharacterStatController.RebuildActiveList</c> already uses for the skill list
|
||||
/// (a <see cref="UiScrollablePanel"/> child sized to its host, rows added through it).
|
||||
/// The wrapped panel is anchored to fill this box, so scrolling, per-row visibility
|
||||
/// clipping, and the pixel scroll model (<see cref="Scroll"/>) all come from the
|
||||
/// SAME code CH6/the character sheet already exercise — no new scroll model. Rows
|
||||
/// stack in call order: each new row's <see cref="UiElement.Top"/> is set to the
|
||||
/// viewport's current <see cref="UiScrollablePanel.ContentHeight"/> before it is
|
||||
/// added, exactly retail's own ListBox layout (each row is authored at its own
|
||||
/// template-local Y=0; the box stacks instances).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="TemplateResolver"/> is the seam a page controller wires with real DAT
|
||||
/// access — e.g. <c>(layoutId, elementId) => LayoutImporter.Build(
|
||||
/// LayoutImporter.ImportInfos(dats, layoutId, elementId), resolve, datFont,
|
||||
/// fontResolve, stringResolve)?.Root</c> (the SAME "one selected root from a
|
||||
/// catalog-style LayoutDesc" overload <c>RetailDialogFactory</c> already uses for the
|
||||
/// shared dialog catalog — the Options panel's row templates are top-level siblings
|
||||
/// of the tab control in <c>0x2100002B</c>, structurally identical to that catalog
|
||||
/// shape). Left null by <c>DatWidgetFactory</c> itself: OP2 ships the mechanism, a
|
||||
/// page controller (Campaign OP slice OP4+) supplies the resolver once it has a live
|
||||
/// <c>IDatReaderWriter</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiTemplateListBox : UiPanel
|
||||
{
|
||||
private readonly UiScrollablePanel _viewport = new()
|
||||
{
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
};
|
||||
|
||||
/// <summary>The authored row-template list (dat property 0x64), in authored array order.</summary>
|
||||
public IReadOnlyList<UiTemplateListEntry> Templates { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Element id of this ListBox's linked scrollbar (dat property 0x72; e.g. the
|
||||
/// Character page's ListBox 0x100001FA names scrollbar 0x100001FB). 0 when the
|
||||
/// dat authors no scrollbar reference. A page controller resolves this id against
|
||||
/// the imported tree and sets the found <see cref="UiScrollbar"/>'s
|
||||
/// <see cref="UiScrollbar.Model"/> to <see cref="Scroll"/> — the same linkage
|
||||
/// <c>ChatWindowController</c> wires for the chat transcript.
|
||||
/// </summary>
|
||||
public uint ScrollbarElementId { get; }
|
||||
|
||||
/// <summary>The wrapped viewport's pixel scroll model — link a page controller's
|
||||
/// resolved scrollbar (<see cref="ScrollbarElementId"/>) to this.</summary>
|
||||
public UiScrollable Scroll => _viewport.Scroll;
|
||||
|
||||
/// <summary>Total stacked row height in px — the same value <see cref="Scroll"/>'s
|
||||
/// content extent uses.</summary>
|
||||
public int ContentHeight => _viewport.ContentHeight;
|
||||
|
||||
/// <summary>Row height for the scroll model's line-scroll quantum. Set once template
|
||||
/// heights are known; defaults to the viewport's own default (16px).</summary>
|
||||
public int LineHeight
|
||||
{
|
||||
get => _viewport.LineHeight;
|
||||
set => _viewport.LineHeight = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates a row template's subtree (its LayoutDesc DID + element id) into a
|
||||
/// built <see cref="UiElement"/>. Null (the default) means
|
||||
/// <see cref="AddItemFromTemplateList"/> cannot build rows yet.
|
||||
/// </summary>
|
||||
public Func<uint, uint, UiElement?>? TemplateResolver { get; set; }
|
||||
|
||||
public UiTemplateListBox(IReadOnlyList<UiTemplateListEntry> templates, uint scrollbarElementId)
|
||||
{
|
||||
Templates = templates;
|
||||
ScrollbarElementId = scrollbarElementId;
|
||||
BackgroundColor = Vector4.Zero;
|
||||
BorderColor = Vector4.Zero;
|
||||
base.AddChild(_viewport);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIElement_ListBox::AddItemFromTemplateList(m_pOptionBox, index,
|
||||
/// nullptr)</c>: resolves <c>Templates[index]</c> through <see cref="TemplateResolver"/>
|
||||
/// and appends the built subtree as the next row, stacked below the previous one.
|
||||
/// Returns the built row widget, or null when <paramref name="index"/> is out of
|
||||
/// range, no resolver is wired, or the resolver produced nothing.
|
||||
/// </summary>
|
||||
public UiElement? AddItemFromTemplateList(int index)
|
||||
{
|
||||
if (index < 0 || index >= Templates.Count) return null;
|
||||
Func<uint, uint, UiElement?>? resolver = TemplateResolver;
|
||||
if (resolver is null) return null;
|
||||
|
||||
UiTemplateListEntry entry = Templates[index];
|
||||
UiElement? row = resolver(entry.TemplateLayoutId, entry.TemplateElementId);
|
||||
if (row is null) return null;
|
||||
|
||||
row.Left = 0f;
|
||||
row.Top = _viewport.ContentHeight;
|
||||
_viewport.AddChild(row);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
|
@ -211,6 +211,43 @@ public static class FixtureLoader
|
|||
public static ElementInfo LoadVendorInfos()
|
||||
=> LoadInfos("vendor_21000012_100000B7.json");
|
||||
|
||||
/// <summary>Options panel LayoutDesc <c>0x2100002B</c> — the tab control + all four
|
||||
/// mounted pages (via BaseLayoutId/BaseElement) + the seven row-template elements,
|
||||
/// as one combined tree (Campaign OP slice OP2).</summary>
|
||||
public static ImportedLayout LoadOptionsPanel()
|
||||
=> LayoutImporter.Build(LoadOptionsPanelInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static ElementInfo LoadOptionsPanelInfos()
|
||||
=> LoadInfos("options_2100002B.json");
|
||||
|
||||
/// <summary>Gameplay Options page LayoutDesc <c>0x2100002A</c> (standalone import).</summary>
|
||||
public static ImportedLayout LoadOptionsGameplay()
|
||||
=> LayoutImporter.Build(LoadOptionsGameplayInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static ElementInfo LoadOptionsGameplayInfos()
|
||||
=> LoadInfos("options_gameplay_2100002A.json");
|
||||
|
||||
/// <summary>Character page LayoutDesc <c>0x21000028</c> (standalone import).</summary>
|
||||
public static ImportedLayout LoadOptionsCharacter()
|
||||
=> LayoutImporter.Build(LoadOptionsCharacterInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static ElementInfo LoadOptionsCharacterInfos()
|
||||
=> LoadInfos("options_character_21000028.json");
|
||||
|
||||
/// <summary>Chat page LayoutDesc <c>0x2100005C</c> (standalone import).</summary>
|
||||
public static ImportedLayout LoadOptionsChat()
|
||||
=> LayoutImporter.Build(LoadOptionsChatInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static ElementInfo LoadOptionsChatInfos()
|
||||
=> LoadInfos("options_chat_2100005C.json");
|
||||
|
||||
/// <summary>Config page LayoutDesc <c>0x21000029</c> (standalone import).</summary>
|
||||
public static ImportedLayout LoadOptionsConfig()
|
||||
=> LayoutImporter.Build(LoadOptionsConfigInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static ElementInfo LoadOptionsConfigInfos()
|
||||
=> LoadInfos("options_config_21000029.json");
|
||||
|
||||
// ── Shared loader ────────────────────────────────────────────────────────
|
||||
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,440 @@
|
|||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class OptionsPanelLayoutConformanceTests
|
||||
{
|
||||
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 (var c in n.Children)
|
||||
{
|
||||
var f = Find(c, id);
|
||||
if (f is not null) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Tab table (property 0x2E) ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OptionsPanelFixture_TabControl_HasFourEntriesInAuthoredOrder_GameplayDefault()
|
||||
{
|
||||
var root = FixtureLoader.LoadOptionsPanelInfos();
|
||||
var tabControl = Find(root, 0x10000208u);
|
||||
Assert.NotNull(tabControl);
|
||||
Assert.Equal(8u, tabControl!.Type);
|
||||
|
||||
var tabs = tabControl.TabTable;
|
||||
Assert.Equal(4, tabs.Count);
|
||||
|
||||
Assert.Equal(new UiTabTableEntry(0x1000020Du, 0x10000212u, true), tabs[0]); // Gameplay Options (default)
|
||||
Assert.Equal(new UiTabTableEntry(0x1000020Eu, 0x10000211u, false), tabs[1]); // Character
|
||||
Assert.Equal(new UiTabTableEntry(0x1000050Bu, 0x1000050Cu, false), tabs[2]); // Chat
|
||||
Assert.Equal(new UiTabTableEntry(0x1000020Fu, 0x10000213u, false), tabs[3]); // Config
|
||||
|
||||
Assert.Single(tabs, t => t.IsDefault);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionsPanelFixture_TabControl_BuildsAsUiTabControl_WithSameTabTable()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
|
||||
Assert.Equal(4, tabControl.Tabs.Count);
|
||||
Assert.True(tabControl.Tabs[0].IsDefault);
|
||||
Assert.Equal(0x10000212u, tabControl.Tabs[0].PageElementId);
|
||||
}
|
||||
|
||||
// ── Row-template lists (property 0x64) ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CharacterListBoxFixture_TemplateList_MatchesThreeRowTemplates()
|
||||
{
|
||||
var root = FixtureLoader.LoadOptionsCharacterInfos();
|
||||
var listBox = Find(root, 0x100001FAu);
|
||||
Assert.NotNull(listBox);
|
||||
Assert.Equal(5u, listBox!.Type);
|
||||
|
||||
var templates = listBox.TemplateList;
|
||||
Assert.Equal(3, templates.Count);
|
||||
Assert.Equal(new UiTemplateListEntry(0x2100002Bu, 0x10000216u), templates[0]); // header
|
||||
Assert.Equal(new UiTemplateListEntry(0x2100002Bu, 0x10000217u), templates[1]); // separator
|
||||
Assert.Equal(new UiTemplateListEntry(0x2100002Bu, 0x10000218u), templates[2]); // toggle
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigListBoxFixture_TemplateList_MatchesEightRowTemplates()
|
||||
{
|
||||
var root = FixtureLoader.LoadOptionsConfigInfos();
|
||||
var listBox = Find(root, 0x10000200u);
|
||||
Assert.NotNull(listBox);
|
||||
|
||||
var templates = listBox!.TemplateList;
|
||||
Assert.Equal(8, templates.Count);
|
||||
uint[] expectedElementIds =
|
||||
{
|
||||
0x10000216u, 0x10000217u, 0x10000218u, 0x1000021Au,
|
||||
0x10000222u, 0x10000220u, 0x1000021Du, 0x10000221u,
|
||||
};
|
||||
for (int i = 0; i < expectedElementIds.Length; i++)
|
||||
{
|
||||
Assert.Equal(0x2100002Bu, templates[i].TemplateLayoutId);
|
||||
Assert.Equal(expectedElementIds[i], templates[i].TemplateElementId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatListBoxFixture_TemplateList_MatchesNineRowTemplatesInclBitfield64()
|
||||
{
|
||||
var root = FixtureLoader.LoadOptionsChatInfos();
|
||||
var listBox = Find(root, 0x1000050Du);
|
||||
Assert.NotNull(listBox);
|
||||
|
||||
var templates = listBox!.TemplateList;
|
||||
Assert.Equal(9, templates.Count);
|
||||
uint[] expectedElementIds =
|
||||
{
|
||||
0x10000216u, 0x10000217u, 0x10000218u, 0x1000021Au, 0x10000222u,
|
||||
0x10000220u, 0x1000021Du, 0x10000221u, 0x10000520u, // bitfield64
|
||||
};
|
||||
for (int i = 0; i < expectedElementIds.Length; i++)
|
||||
{
|
||||
Assert.Equal(0x2100002Bu, templates[i].TemplateLayoutId);
|
||||
Assert.Equal(expectedElementIds[i], templates[i].TemplateElementId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scrollbar linkage (property 0x72) ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CharacterListBoxFixture_ScrollbarLinkage_Names0x100001FB()
|
||||
{
|
||||
var root = FixtureLoader.LoadOptionsCharacterInfos();
|
||||
var listBox = Find(root, 0x100001FAu);
|
||||
Assert.Equal(0x100001FBu, listBox!.ScrollbarElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigListBoxFixture_ScrollbarLinkage_Names0x10000201()
|
||||
{
|
||||
var root = FixtureLoader.LoadOptionsConfigInfos();
|
||||
var listBox = Find(root, 0x10000200u);
|
||||
Assert.Equal(0x10000201u, listBox!.ScrollbarElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatListBoxFixture_ScrollbarLinkage_SharesElement0x10000201WithConfig()
|
||||
{
|
||||
// Research doc §10.1: Config's and Chat's ListBoxes both name the SAME
|
||||
// scrollbar element id — the two tabs share one gutter widget authored twice.
|
||||
var root = FixtureLoader.LoadOptionsChatInfos();
|
||||
var listBox = Find(root, 0x1000050Du);
|
||||
Assert.Equal(0x10000201u, listBox!.ScrollbarElementId);
|
||||
}
|
||||
|
||||
// ── Built-widget mapping: ListBox → UiTemplateListBox ───────────────────
|
||||
|
||||
[Fact]
|
||||
public void OptionsPanelFixture_CharacterListBox_BuildsAsUiTemplateListBoxWithSameData()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var listBox = Assert.IsType<UiTemplateListBox>(layout.FindElement(0x100001FAu));
|
||||
Assert.Equal(3, listBox.Templates.Count);
|
||||
Assert.Equal(0x100001FBu, listBox.ScrollbarElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionsPanelFixture_CharacterScrollbar_StillBuildsAsUiScrollbar()
|
||||
{
|
||||
// Regression guard: a real scrollbar sibling (Type 11) must keep building
|
||||
// through the EXISTING BuildScrollbar mapping — OP2 must not disturb it.
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
Assert.IsType<UiScrollbar>(layout.FindElement(0x100001FBu));
|
||||
}
|
||||
|
||||
// ── The four remaining UIOption_* widget mappings ───────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OptionsPanelFixture_SliderControl_BuildsAsHorizontalUiScrollbar()
|
||||
{
|
||||
// UIOption_Slider (0x10000037), template element 0x1000021C: 120x12,
|
||||
// W>H so BuildScrollbar's existing horizontal branch applies, with the
|
||||
// child-id-1 thumb convention it already implements.
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var slider = Assert.IsType<UiScrollbar>(layout.FindElement(0x1000021Cu));
|
||||
Assert.True(slider.Horizontal);
|
||||
Assert.NotEqual(0u, slider.ThumbSprite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionsPanelFixture_MenuControl_BuildsAsUiMenu()
|
||||
{
|
||||
// UIOption_Menu (0x10000038), template element 0x10000224.
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
Assert.IsType<UiMenu>(layout.FindElement(0x10000224u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionsPanelFixture_ToggleSliderRow_ComposesCheckboxAndSlider()
|
||||
{
|
||||
// UIOption_CheckboxSlider (0x10000036), unlabelled row template 0x10000220:
|
||||
// composes an existing UIOption_Checkbox (0x10000219 -> UiButton) and an
|
||||
// existing UIOption_Slider (0x1000021C -> UiScrollbar) — no new drawing code.
|
||||
// (0x10000219/0x1000021C are reused verbatim across every checkbox/slider
|
||||
// template instance, so this asserts identity against row.Children directly
|
||||
// rather than layout.FindElement — which, like CharacterStatController's own
|
||||
// documented caveat, returns only the LAST of several same-id duplicates.)
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var row = Assert.IsType<UiOptionToggleSlider>(layout.FindElement(0x10000220u));
|
||||
Assert.NotNull(row.Toggle);
|
||||
Assert.NotNull(row.Slider);
|
||||
Assert.Contains(row.Toggle, row.Children);
|
||||
Assert.Contains(row.Slider, row.Children);
|
||||
Assert.Equal(0x10000219u, row.Toggle!.DatElementId);
|
||||
Assert.Equal(0x1000021Cu, row.Slider!.DatElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionsPanelFixture_LabelledToggleSliderRow_AlsoComposesCheckboxAndSlider()
|
||||
{
|
||||
// Labelled variant 0x10000221 (four children: checkbox + slider + two range
|
||||
// labels) — the two extra Text children must not confuse the composition.
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var row = Assert.IsType<UiOptionToggleSlider>(layout.FindElement(0x10000221u));
|
||||
Assert.NotNull(row.Toggle);
|
||||
Assert.NotNull(row.Slider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionsPanelFixture_Bitfield64Template_BuildsAsEmptyUiCheckboxBitfield64()
|
||||
{
|
||||
// UIOption_CheckboxBitfield64 (0x10000044), template element 0x10000520:
|
||||
// authors zero children/media in the dat — every row is added at runtime.
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var bitfield = Assert.IsType<UiCheckboxBitfield64>(layout.FindElement(0x10000520u));
|
||||
Assert.Empty(bitfield.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionsPanelFixture_LabelledSliderRow_StillPlainContainer()
|
||||
{
|
||||
// Template idx 6 (0x1000021D) is the LABELLED slider (label + slider + two
|
||||
// range labels) but its OWN root Type is 3 (plain container), not
|
||||
// UIOption_CheckboxSlider — distinguishing this from 0x10000221 is exactly
|
||||
// what makes the mapping data-driven rather than guessed.
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var row = layout.FindElement(0x1000021Du);
|
||||
Assert.IsNotType<UiOptionToggleSlider>(row);
|
||||
var slider = Assert.IsType<UiScrollbar>(layout.FindElement(0x1000021Cu));
|
||||
Assert.NotNull(slider);
|
||||
}
|
||||
|
||||
// ── UiCheckboxBitfield64 behavior (structural, click semantics flagged) ──
|
||||
|
||||
[Fact]
|
||||
public void UiCheckboxBitfield64_AddChild_TracksMaskAndTogglesOnClick()
|
||||
{
|
||||
var bitfield = new UiCheckboxBitfield64 { Width = 272f };
|
||||
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");
|
||||
Assert.False(row.Selected);
|
||||
|
||||
ulong? changedLow = null;
|
||||
bitfield.ValueChanged += (low, _) => changedLow = low;
|
||||
row.OnClick!.Invoke();
|
||||
|
||||
Assert.True(row.Selected);
|
||||
Assert.Equal(0x00000000_FFFFFFFFul, changedLow); // FBFFFFFF | 04000000 == FFFFFFFF
|
||||
|
||||
row.OnClick!.Invoke();
|
||||
Assert.False(row.Selected);
|
||||
Assert.Equal(0x00000000_FBFFFFFFul, changedLow); // back to the seeded default
|
||||
}
|
||||
|
||||
// ── The template mechanism, exercised end-to-end ────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Proves the Character ListBox's authored structure (6 headers + 49 toggles,
|
||||
/// research doc §7 / §10.1) is REACHABLE through
|
||||
/// <see cref="UiTemplateListBox.AddItemFromTemplateList"/> — each call resolves
|
||||
/// its template through the SAME <c>LayoutImporter.Build</c> machinery a real
|
||||
/// controller would use, sourced from the committed <c>options_2100002B.json</c>
|
||||
/// fixture (the templates' one true home).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CharacterListBox_TemplateMechanism_ReachesSixHeadersAndFortyNineToggles()
|
||||
{
|
||||
ElementInfo panelRoot = FixtureLoader.LoadOptionsPanelInfos();
|
||||
UiElement? Resolve(uint layoutId, uint elementId)
|
||||
{
|
||||
if (layoutId != 0x2100002Bu) return null;
|
||||
ElementInfo? templateInfo = Find(panelRoot, elementId);
|
||||
return templateInfo is null ? null : LayoutImporter.Build(templateInfo, NoTex, null).Root;
|
||||
}
|
||||
|
||||
ElementInfo characterListBoxInfo = Find(FixtureLoader.LoadOptionsCharacterInfos(), 0x100001FAu)!;
|
||||
var listBox = new UiTemplateListBox(characterListBoxInfo.TemplateList, characterListBoxInfo.ScrollbarElementId)
|
||||
{
|
||||
TemplateResolver = Resolve,
|
||||
};
|
||||
|
||||
// Lane B §7 authored section row counts (docs/research/2026-08-10-options-panel-structure.md):
|
||||
// User Interface Behavior=3, User Interface Display=15, Grouping=6,
|
||||
// Other Players=11, Character Behavior=7, Chat=7 -> 49 toggles under 6 headers.
|
||||
int[] sectionToggleCounts = { 3, 15, 6, 11, 7, 7 };
|
||||
int headerCalls = 0, toggleCalls = 0;
|
||||
foreach (int toggleCount in sectionToggleCounts)
|
||||
{
|
||||
var header = listBox.AddItemFromTemplateList(0); // header template
|
||||
Assert.IsType<UiText>(header);
|
||||
headerCalls++;
|
||||
|
||||
for (int i = 0; i < toggleCount; i++)
|
||||
{
|
||||
var toggleRow = listBox.AddItemFromTemplateList(2); // toggle template
|
||||
Assert.NotNull(toggleRow);
|
||||
// Toggle template 0x10000218 is a plain Type-3 container wrapping ONE
|
||||
// UIOption_Checkbox child — proves the SAME BuildCheckbox mapping
|
||||
// fires through the template mechanism, not just for authored trees.
|
||||
Assert.IsType<UiButton>(Assert.Single(toggleRow!.Children));
|
||||
toggleCalls++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(6, headerCalls);
|
||||
Assert.Equal(49, toggleCalls);
|
||||
|
||||
// 6 headers @22px + 49 toggles @20px (template heights 0x10000216/0x10000218).
|
||||
Assert.Equal(6 * 22 + 49 * 20, listBox.ContentHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemplateListBox_AddItemFromTemplateList_WithoutResolver_ReturnsNull()
|
||||
{
|
||||
ElementInfo characterListBoxInfo = Find(FixtureLoader.LoadOptionsCharacterInfos(), 0x100001FAu)!;
|
||||
var listBox = new UiTemplateListBox(
|
||||
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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemplateListBox_AddItemFromTemplateList_OutOfRangeIndex_ReturnsNull()
|
||||
{
|
||||
var listBox = new UiTemplateListBox(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));
|
||||
}
|
||||
|
||||
// ── UiTabControl behavior ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UiTabControl_DefaultMount_ShowsOnlyTheGameplayPage()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
|
||||
|
||||
Assert.Equal(0x10000212u, tabControl.ActivePageElementId); // Gameplay page slot
|
||||
|
||||
UiElement gameplayPage = layout.FindElement(0x10000212u)!;
|
||||
UiElement characterPage = layout.FindElement(0x10000211u)!;
|
||||
UiElement chatPage = layout.FindElement(0x1000050Cu)!;
|
||||
UiElement configPage = layout.FindElement(0x10000213u)!;
|
||||
|
||||
Assert.True(gameplayPage.Visible);
|
||||
Assert.False(characterPage.Visible);
|
||||
Assert.False(chatPage.Visible);
|
||||
Assert.False(configPage.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiTabControl_SwitchTo_ShowsExactlyOnePage()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
|
||||
|
||||
tabControl.SwitchTo(0x1000050Cu); // Chat page slot
|
||||
|
||||
UiElement gameplayPage = layout.FindElement(0x10000212u)!;
|
||||
UiElement characterPage = layout.FindElement(0x10000211u)!;
|
||||
UiElement chatPage = layout.FindElement(0x1000050Cu)!;
|
||||
UiElement configPage = layout.FindElement(0x10000213u)!;
|
||||
|
||||
var visiblePages = new[] { gameplayPage, characterPage, chatPage, configPage }
|
||||
.Where(p => p.Visible)
|
||||
.ToList();
|
||||
Assert.Single(visiblePages);
|
||||
Assert.Same(chatPage, visiblePages[0]);
|
||||
Assert.Equal(0x1000050Cu, tabControl.ActivePageElementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiTabControl_SwitchTo_UpdatesTabButtonOpenClosedState()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
|
||||
|
||||
var gameplayButton = Assert.IsType<UiText>(layout.FindElement(0x1000020Du));
|
||||
var characterButton = Assert.IsType<UiText>(layout.FindElement(0x1000020Eu));
|
||||
|
||||
Assert.Equal(RetailUiStateIds.Open, gameplayButton.ActiveRetailStateId);
|
||||
Assert.Equal(RetailUiStateIds.Closed, characterButton.ActiveRetailStateId);
|
||||
|
||||
tabControl.SwitchTo(0x10000211u); // Character page slot
|
||||
|
||||
Assert.Equal(RetailUiStateIds.Closed, gameplayButton.ActiveRetailStateId);
|
||||
Assert.Equal(RetailUiStateIds.Open, characterButton.ActiveRetailStateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiTabControl_ClickingTabButton_SwitchesActivePage()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
|
||||
var characterButton = layout.FindElement(0x1000020Eu)!;
|
||||
|
||||
characterButton.OnEvent(new UiEvent(0, characterButton, UiEventType.Click));
|
||||
|
||||
Assert.Equal(0x10000211u, tabControl.ActivePageElementId);
|
||||
Assert.True(layout.FindElement(0x10000211u)!.Visible);
|
||||
Assert.False(layout.FindElement(0x10000212u)!.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiTabControl_SwitchToSameActivePage_IsANoOp()
|
||||
{
|
||||
var layout = FixtureLoader.LoadOptionsPanel();
|
||||
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
|
||||
uint before = tabControl.ActivePageElementId;
|
||||
|
||||
tabControl.SwitchTo(before);
|
||||
|
||||
Assert.Equal(before, tabControl.ActivePageElementId);
|
||||
Assert.True(layout.FindElement(before)!.Visible);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,11 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
(0x21000072u, "powerbar_21000072.json"),
|
||||
(0x21000073u, "combat_21000073.json"),
|
||||
(0x21000074u, "radar_21000074.json"),
|
||||
(0x2100002Bu, "options_2100002B.json"),
|
||||
(0x2100002Au, "options_gameplay_2100002A.json"),
|
||||
(0x21000028u, "options_character_21000028.json"),
|
||||
(0x2100005Cu, "options_chat_2100005C.json"),
|
||||
(0x21000029u, "options_config_21000029.json"),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
30786
tests/AcDream.App.Tests/UI/Layout/fixtures/options_2100002B.json
Normal file
30786
tests/AcDream.App.Tests/UI/Layout/fixtures/options_2100002B.json
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue