From 5190e1691591b34d2c3e2038f381f82b948342b3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 12:27:03 +0200 Subject: [PATCH] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20round=20?= =?UTF-8?q?1=20Batch=20C=20=E2=80=94=20un-consume=20media-bearing=20childr?= =?UTF-8?q?en?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 2/3: CLIENT-WIDE blast radius — un-consume media-bearing dat children on UiText/UiField. UiText.ConsumesDatChildren (true unless a state authors PassToChildren) and UiField.ConsumesDatChildren (true, unconditional) used to drop EVERY dat child at import time, including ones that carry their own renderable media — retail's UIElement_Text/Field genuinely composites those as real chrome/controls (frame pieces, linked scrollbars), not swallowed caption/face art the way a Button's or Meter's children are. LayoutImporter.BuildWidget gains a new carve-out (mirroring the existing UiMeter one): when a UiText/UiField's ConsumesDatChildren is true, build any child whose OWN StateMedia is non-empty (it carries a real sprite/track) instead of dropping it outright. Purely structural/ property-only children (StateMedia.Count == 0) stay dropped exactly as before — this is additive, not a relaxation of the PassToChildren gate. Independently re-derived blast-radius sweep (walks every installed LayoutDesc via DatCollection.GetAllIdsOfType, new LayoutImporterMediaBearingChildSweepTests): 37 distinct (layout, element) pairs — 41 raw tree positions, since a handful of element ids recur at multiple subtree positions within the same layout — across 15 layouts. Full list: 0x21000005/0x10000011 (x5 tree positions — chat-adjacent template reused across the layout), 0x21000005/0x1000059A [MAIN GAME UI], 0x21000006/0x10000011, 0x2100000F/0x1000059A, 0x21000038/{0x100003AB,0x100003BA,0x100003C4,0x100003E0,0x100003EC, 0x100003F6,0x100003FA,0x100003FD,0x100003FF,0x10000402,0x10000404, 0x10000405,0x10000409} [character creation], 0x21000043/0x10000362, 0x21000046/0x100003C4, 0x21000047/{0x100003E0,0x100003EC}, 0x21000048/{0x100003F6,0x100003FA,0x100003FD}, 0x21000049/{0x100003AB,0x100003BA}, 0x2100004A/0x10000409, 0x2100004B/{0x100003FF,0x10000402,0x10000404,0x10000405}, 0x2100004C/{0x100002DD,0x100002E5,0x100002E6}, 0x2100005B/0x10000011, 0x21000068/0x1000059A, 0x2100006F/0x10000011 [CHAT INPUT]. (This is an independent re-derivation, not a re-statement of the investigation's earlier "42/14" estimate — the small difference is expected from measuring with this commit's own criteria.) New tests: the sweep itself (pins the two flagged landmarks — MAIN GAME UI 0x21000005/0x1000059A and CHAT INPUT 0x2100006F/ 0x10000011 — plus the three chargen boxes), a build-through regression test confirming those two landmarks' children resolve as real widgets post-fix, and a chargen-scoped test confirming the eight gold-frame pieces + linked scrollbar on all three description boxes now resolve via UiElement.FindDescendant. Full App suite (Debug and Release, live-DAT): 5304 passed / 0 failed / 3 skipped — ZERO regressions across the whole client, including every existing chat and main-UI test. Runtime suite: 1735/0, unaffected (this is an App-layer-only change). FLAG FOR THE LEAD: automated coverage cannot catch a purely VISUAL regression (a frame drawing in the wrong place, a scrollbar overlapping text). Chat and the main game UI both got new dat children rendered for the first time this commit — schedule the user's own visual check of both before considering this closed, per the campaign's oracle discipline. The scrollbar linkage (wiring the description boxes' UiScrollbar to actual text scrolling) is NOT done in this commit — the scrollbar widget now BUILDS, but CharacterCreationHeritagePage/TownPage/ ProfessionPage/SummaryPage do not yet bind its ScalarChanged to UiText.Scroll. Filed as follow-up (see report). Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/Layout/LayoutImporter.cs | 28 ++++ .../Layout/CharacterCreationLiveDatTests.cs | 32 ++++ ...youtImporterMediaBearingChildSweepTests.cs | 151 ++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs 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); + } +}