diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 06d78aa9..215f3093 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -164,6 +164,34 @@ public static class LayoutImporter if (cw is not null) w.AddChild(cw); } } + else if (w is UiText or UiField) + { + // Campaign CC gate round 1 Batch C, Commit 2: UiText/UiField's + // coarse ConsumesDatChildren=true (UiText outside its + // PassToChildren carve-out; UiField unconditionally) used to + // drop EVERY dat child, including ones that carry their own + // renderable media — retail's UIElement_Text/Field genuinely + // composites those as real chrome/controls, not swallowed + // caption/face art the way a Button's or Meter's children are. + // Live-DAT-measured (chargen's three shared description boxes, + // 0x100003e0/0x10000409/0x10000404): the eight gold-frame + // pieces (0x100002DE-E3, 0x100000E8/EA, Type 3, one DirectState + // sprite each) and the linked scrollbar (0x100002E7, Type 11, + // its own DirectState track sprite plus three Button + // sub-children BuildScrollbar resolves internally) all carry + // non-empty StateMedia on THEMSELVES. Purely structural/ + // property-only children (StateMedia.Count == 0 — e.g. a + // lifted-caption-only child some OTHER element type might + // still want swallowed) stay dropped exactly as before; this + // is additive, not a relaxation of the PassToChildren gate + // itself. + foreach (var child in info.Children) + { + if (child.StateMedia.Count == 0) continue; + var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId); + if (cw is not null) w.AddChild(cw); + } + } // UIElement::SetState @ 0x00464E70 propagates a state's id only after the // child tree exists. Re-applying the imported default here gives retained diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index a9664b8a..e8fc405e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -1207,6 +1207,38 @@ public sealed class CharacterCreationLiveDatTests Assert.Contains(summaryHowTo.Children, c => c.Id == 0x100002E7u); } + /// + /// Campaign CC gate round 1 Batch C, Commit 2: the eight gold-frame + /// pieces and the linked scrollbar — previously dropped outright by + /// UiText.ConsumesDatChildren — now build as REAL widgets + /// reachable via , on all three + /// chargen description boxes. + /// + [InstalledDatFact] + public void DescriptionTextboxes_FramesAndScrollbarBuildAsRealWidgets() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + uint[] frameChildIds = + [ + 0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u, + 0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu, + ]; + foreach (uint boxId in new[] { 0x100003E0u, 0x10000409u, 0x10000404u }) + { + UiText box = Assert.IsType(screen.FindElement(boxId)); + foreach (uint frameChildId in frameChildIds) + Assert.NotNull(UiElement.FindDescendant(box, frameChildId)); + } + + UiText summaryHowTo = Assert.IsType(screen.FindElement(0x10000404u)); + Assert.IsType(UiElement.FindDescendant(summaryHowTo, 0x100002E7u)); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs new file mode 100644 index 00000000..97be363d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs @@ -0,0 +1,151 @@ +using System.IO; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CC gate round 1 Batch C, Commit 2: client-wide blast-radius +/// sweep for the UiText/UiField media-bearing-child +/// un-consume fix ('s new UiText or +/// UiField carve-out). Walks EVERY installed LayoutDesc +/// (DatCollection.GetAllIdsOfType<LayoutDesc>) and reports +/// every Type-12 (UIElement_Text) element that does NOT author +/// PassToChildren on any state (the pre-fix "consumes everything" shape) +/// but HAS at least one direct child carrying its own state media — the +/// exact set the fix now builds instead of silently dropping. Logged via +/// Console.WriteLine so the full enumeration is visible in test +/// output for the commit message; the assertions pin only landmark counts/ +/// elements (not a brittle exact global total) so the gate survives a +/// future DAT revision without going red on an unrelated content change. +/// +public sealed class LayoutImporterMediaBearingChildSweepTests +{ + private static string DatDirectory => + System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + + private readonly record struct Finding(uint LayoutId, uint ElementId, uint[] MediaBearingChildIds); + + [InstalledDatFact] + public void MediaBearingChildSweep_EnumeratesEveryAffectedType12Element() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + var findings = new List(); + foreach (uint layoutId in dats.GetAllIdsOfType().OrderBy(x => x)) + { + ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId); + if (tree is null) continue; + Walk(layoutId, tree, findings); + } + + Console.WriteLine($"[SWEEP] {findings.Count} affected Type-12 elements across " + + $"{findings.Select(f => f.LayoutId).Distinct().Count()} layouts."); + foreach (Finding f in findings.OrderBy(f => f.LayoutId).ThenBy(f => f.ElementId)) + { + Console.WriteLine( + $"[SWEEP] layout=0x{f.LayoutId:X8} element=0x{f.ElementId:X8} " + + $"mediaBearingChildren=[{string.Join(",", f.MediaBearingChildIds.Select(id => $"0x{id:X8}"))}]"); + } + + // Landmarks the investigation specifically flagged for the user's + // visual check — assert they are genuinely in the affected set + // (not asserting a brittle exact global count). + Assert.Contains(findings, f => f.LayoutId == 0x21000005u && f.ElementId == 0x1000059Au); + Assert.Contains(findings, f => f.LayoutId == 0x2100006Fu && f.ElementId == 0x10000011u); + + // The three chargen description boxes this campaign already fixed. + Assert.Contains(findings, f => f.ElementId == 0x100003E0u); // Profession + Assert.Contains(findings, f => f.ElementId == 0x10000409u); // Town + Assert.Contains(findings, f => f.ElementId == 0x10000404u); // Summary how-to + + Assert.True(findings.Count > 0, "the sweep must find at least the known chargen landmarks."); + } + + /// + /// Regression pin (Commit 2): the two landmarks the investigation + /// flagged for the user's own visual check actually BUILD their + /// media-bearing children as real widgets now, on a NON-chargen + /// layout — proving the fix is not accidentally chargen-only. FLAG: + /// this is a structural/widget-level pin only; the user's own visual + /// check of chat + the main game UI is still owed (the lead schedules + /// it) — a passing test here does not stand in for that. + /// + [InstalledDatFact] + public void MainGameUiAndChatInput_MediaBearingChildrenNowBuildAsRealWidgets() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + // MAIN GAME UI (0x21000005/0x1000059A): the same eight gold-frame + // pieces the chargen boxes carry. + AssertChildrenBuild( + dats, layoutId: 0x21000005u, elementId: 0x1000059Au, + expectedChildIds: + [ + 0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u, + 0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu, + ]); + + // CHAT INPUT (0x2100006F/0x10000011): a single media-bearing child. + AssertChildrenBuild( + dats, layoutId: 0x2100006Fu, elementId: 0x10000011u, + expectedChildIds: [0x1000048Cu]); + } + + private static void AssertChildrenBuild( + IDatReaderWriter dats, uint layoutId, uint elementId, uint[] expectedChildIds) + { + ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId); + Assert.NotNull(tree); + ElementInfo? target = FindInfo(tree!, elementId); + Assert.NotNull(target); + + UiElement built = LayoutImporter.Build( + target!, _ => (0u, 0, 0), null).Root; + Assert.IsType(built); + foreach (uint childId in expectedChildIds) + { + Assert.NotNull(UiElement.FindDescendant(built, childId)); + } + } + + private static ElementInfo? FindInfo(ElementInfo node, uint id) + { + if (node.Id == id) return node; + foreach (ElementInfo child in node.Children) + { + ElementInfo? found = FindInfo(child, id); + if (found is not null) return found; + } + return null; + } + + private static void Walk(uint layoutId, ElementInfo node, List findings) + { + if (node.Type == 12u) + { + bool passToChildren = node.States.Values.Any(static s => s.PassToChildren); + if (!passToChildren) + { + uint[] mediaBearingChildren = node.Children + .Where(static c => c.StateMedia.Count > 0) + .Select(static c => c.Id) + .ToArray(); + if (mediaBearingChildren.Length > 0) + findings.Add(new Finding(layoutId, node.Id, mediaBearingChildren)); + } + } + + foreach (ElementInfo child in node.Children) + Walk(layoutId, child, findings); + } +}