diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2e26a360..1944137d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1794,6 +1794,36 @@ public sealed class GameWindow : IDisposable _uiHost.Root.AddChild(panel); Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup."); + // Phase D.2b — LayoutDesc importer A/B harness. When ACDREAM_RETAIL_UI_IMPORTER=1, + // build the SAME vitals window (0x2100006C) data-driven from the dat and place it beside + // the hand-authored one so the two can be compared pixel-for-pixel before the importer + // becomes the default. The hand-authored path above is untouched. + if (_options.RetailUiImporter) + { + AcDream.App.UI.Layout.ImportedLayout? imported; + lock (_datLock) + imported = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats!, 0x2100006Cu, ResolveChrome, vitalsDatFont); + if (imported is not null) + { + AcDream.App.UI.Layout.VitalsController.Bind(imported, + healthPct: () => _vitalsVm!.HealthPercent, + staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f, + manaPct: () => _vitalsVm!.ManaPercent ?? 0f, + healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", + staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", + manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); + // Offset beside the hand-authored window (at x=10) for an A/B visual comparison. + imported.Root.Left = 200; imported.Root.Top = 30; + _uiHost.Root.AddChild(imported.Root); + Console.WriteLine("[D.2b] importer vitals window active (A/B vs hand-authored)."); + } + else + { + Console.WriteLine("[D.2b] importer vitals: LayoutDesc 0x2100006C not found."); + } + } + // Retail chat window — a draggable/resizable nine-slice frame hosting a // scrollable transcript (UiChatView). Read-only + wheel-scroll for now; // drag-select + Ctrl+C copy land in the next D.2b sub-step. A dedicated diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 9be7601d..bff1f885 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -41,6 +41,7 @@ public sealed record RuntimeOptions( bool DumpLiveSpawns, int? LegacyStreamRadius, bool RetailUi, + bool RetailUiImporter, string? AcDir) { /// @@ -85,6 +86,7 @@ public sealed record RuntimeOptions( // top of the quality preset's radii. Null when unset or invalid. LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), + RetailUiImporter: IsExactlyOne(env("ACDREAM_RETAIL_UI_IMPORTER")), AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); } diff --git a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs index b18590ae..9c6b88a2 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs @@ -25,4 +25,29 @@ public class RuntimeOptionsRetailUiTests Assert.False(opts.RetailUi); Assert.Null(opts.AcDir); } + + [Fact] + public void Parse_ReadsRetailUiImporter_WhenSetToOne() + { + var env = new Dictionary + { + ["ACDREAM_RETAIL_UI_IMPORTER"] = "1", + }; + var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); + Assert.True(opts.RetailUiImporter); + } + + [Fact] + public void Parse_DefaultsRetailUiImporterOff_WhenUnsetOrOtherValue() + { + // Unset → false. + Assert.False(RuntimeOptions.Parse("dats", _ => null).RetailUiImporter); + + // Non-"1" values → false (mirrors RetailUi / other IsExactlyOne flags). + var envOther = new Dictionary + { + ["ACDREAM_RETAIL_UI_IMPORTER"] = "true", + }; + Assert.False(RuntimeOptions.Parse("dats", k => envOther.GetValueOrDefault(k)).RetailUiImporter); + } }