The remaining code-bearing findings from the round review, F4-F16 minus the doc-only items (batched separately): - F4: three client-wide UiButton corpus sweeps (LabelBox path — exactly the 4 Town buttons, confined to chargen; conflicting custom-selection- pair + standard Normal/Highlight media — zero found, no gate tightening needed; per-state label-color map — 209 matches beyond chargen, confirming AP-222's mechanism has always been broadly active since it shipped generically in DatWidgetFactory). - F5/F6: LayoutImporter's Batch C un-consumed-children carve-out now honors a child's own AuthoredInvisible flag (a narrow honor scoped to exactly that carve-out, not the general #408 client-wide one) — the chat transcript's new-text indicator (0x1000048C) was building as a visible phantom element retail never shows; verified both directions against the gold-frame pieces, which do not author Invisible. - F7: BoundedProcessOutputCapture.AppendLine combines the line text and its trailing newline into one buffer and one file open/write/close instead of two. - F9: corrected a stale comment in RuntimeSettingsTargets — #407 split DisplayModeCatalog's Resolutions/WindowedResolutions in two, so the fullscreen validator's own narrower list is now DELIBERATELY different from the Config dropdown's fuller offering, not the "must match" bug the comment described. - F10: documented (not changed) why the LabelBox path's default 3px inset and the face-relative +4px gap in DatWidgetFactory.BuildButton are deliberately different numbers — neither carries a retail citation, and moving either to match the other would be an unfounded guess on a button that currently works correctly. - F11: Heritage/Profession/Summary/Town description pages now compose DatRichText.Compose's result ONCE inside their already revision-gated Refresh, caching the built line list instead of re-wrapping on every draw call. - F14: documented (not changed) why PrivateEntityViewportRenderer's _animatedIds set carrying a reserved-but-never-drawn backdrop id is harmless — BuildDrawEntities already excludes a null/empty backdrop from the actual draw list, so the id is never looked up. - F16: the Summary preview now uses its own render-id pair (SummaryPreviewRenderId/SummaryPreviewBackdropRenderId, 0xDA11D035/ 0xDA11D036) instead of sharing the Appearance page's (0xDA11D032/0xDA11D034) — confirmed by tracing FixedEntityTextureOwnerLease through TextureCache to CompositeTextureArrayCache's shared owner tracker that both pages' previews share ONE process-wide TextureCache, so sharing render ids was a real cross-page texture-release collision (either page's own re-dress or disposal could release the OTHER page's still-active textures), not a theoretical one. F3's own register bookkeeping (AP-229 addendum) and F12's register/AD header-count corrections land in the docs-only commit alongside F15. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
214 lines
9.9 KiB
C#
214 lines
9.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Campaign CC gate round 1 closeout, F4: three client-wide blast-radius
|
|
/// sweeps over EVERY installed <c>LayoutDesc</c>, walking every Type-1
|
|
/// (<c>UIElement_Button</c>) element and matching the SAME structural
|
|
/// predicates <see cref="DatWidgetFactory.BuildButton"/> uses internally
|
|
/// (predicates re-derived here rather than reflected, since the source
|
|
/// methods are <c>private</c> — kept in sync by citing the exact source
|
|
/// line ranges in each sweep's own doc). Same style as
|
|
/// <see cref="LayoutImporterMediaBearingChildSweepTests"/>: logs the full
|
|
/// enumeration for the commit message, pins landmark counts rather than a
|
|
/// brittle exact global total.
|
|
/// </summary>
|
|
public sealed class UiButtonCorpusSweepTests
|
|
{
|
|
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 ButtonFinding(uint LayoutId, uint ElementId);
|
|
|
|
/// <summary>
|
|
/// Sweep (a): which buttons take the GF-11c <c>LabelBox</c> path —
|
|
/// <c>info.StateMedia.Count==0</c> (no media on the button itself) with
|
|
/// EXACTLY one stateful face child, plus a DISTINCT lifted Type-12
|
|
/// caption child (not the button's own P0x17) — see
|
|
/// <c>DatWidgetFactory.BuildButton</c>:889-914 for the exact shape this
|
|
/// mirrors.
|
|
/// </summary>
|
|
[InstalledDatFact]
|
|
public void LabelBoxPath_EnumeratesEveryMatchingButton()
|
|
{
|
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
|
|
|
var findings = new List<ButtonFinding>();
|
|
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>().OrderBy(x => x))
|
|
{
|
|
ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId);
|
|
if (tree is null) continue;
|
|
WalkButtons(layoutId, tree, findings, MatchesLabelBoxShape);
|
|
}
|
|
|
|
Console.WriteLine($"[SWEEP-A] {findings.Count} buttons take the LabelBox path across "
|
|
+ $"{findings.Select(f => f.LayoutId).Distinct().Count()} layouts.");
|
|
foreach (ButtonFinding f in findings.OrderBy(f => f.LayoutId).ThenBy(f => f.ElementId))
|
|
Console.WriteLine($"[SWEEP-A] layout=0x{f.LayoutId:X8} element=0x{f.ElementId:X8}");
|
|
|
|
// Landmark this campaign already fixed and gated (GF-11c, the Town
|
|
// page's per-marker name label) must be in the set — proves the
|
|
// sweep's predicate is right, not just non-empty.
|
|
Assert.Contains(findings, f => IsTownButton(f.ElementId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sweep (b): any button authoring BOTH the custom Unselected/Selected
|
|
/// radio-pair (<c>UiButton</c>'s <c>_hasCustomSelectionPair</c> bypass,
|
|
/// GF-1/GF-8) AND standard Normal/Highlight media — the custom-pair
|
|
/// bypass would eat the standard state machine for such a button
|
|
/// (<c>UiButton.UpdateVisualState</c>'s <c>if (_hasCustomSelectionPair)</c>
|
|
/// branch runs UNCONDITIONALLY when the pair is present, never falling
|
|
/// through to the standard <c>_availableStates</c> branch). None found
|
|
/// in the installed corpus at the ELEMENT's own media level (this sweep
|
|
/// does not additionally check face-SEGMENT media — see this method's
|
|
/// own note).
|
|
/// </summary>
|
|
[InstalledDatFact]
|
|
public void CustomSelectionPair_NeverCoexistsWithStandardNormalHighlightMedia()
|
|
{
|
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
|
|
|
var findings = new List<ButtonFinding>();
|
|
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>().OrderBy(x => x))
|
|
{
|
|
ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId);
|
|
if (tree is null) continue;
|
|
WalkButtons(layoutId, tree, findings, MatchesConflictingPairShape);
|
|
}
|
|
|
|
Console.WriteLine($"[SWEEP-B] {findings.Count} buttons author BOTH the custom "
|
|
+ "Unselected/Selected pair AND standard Normal/Highlight media.");
|
|
foreach (ButtonFinding f in findings)
|
|
Console.WriteLine($"[SWEEP-B] layout=0x{f.LayoutId:X8} element=0x{f.ElementId:X8}");
|
|
|
|
// No conflict exists in the installed corpus today — the
|
|
// `_hasCustomSelectionPair` bypass in `UiButton.UpdateVisualState`
|
|
// is safe as-is (unconditional-when-present) without needing a
|
|
// tighter gate. If a future DAT drop introduces one, this test
|
|
// fails here rather than silently regressing that button's
|
|
// Highlight/rollover feedback.
|
|
Assert.Empty(findings);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sweep (c): any button with a genuine per-state label-color/outline
|
|
/// map (AP-222's mechanism, <c>ElementReader.BuildPerStateColorMap</c>/
|
|
/// <c>BuildPerStateBoolMap</c> against dat properties <c>0x1B</c>/
|
|
/// <c>0x21</c> — non-null only when the authored dat carries MORE THAN
|
|
/// ONE distinct value across states) beyond the chargen Appearance
|
|
/// spins and Town buttons this campaign already ported and gated.
|
|
/// </summary>
|
|
[InstalledDatFact]
|
|
public void PerStateLabelColorMap_EnumeratesEveryButtonBeyondChargen()
|
|
{
|
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
|
|
|
var findings = new List<ButtonFinding>();
|
|
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>().OrderBy(x => x))
|
|
{
|
|
ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId);
|
|
if (tree is null) continue;
|
|
WalkButtons(layoutId, tree, findings, MatchesPerStateLabelStyleShape);
|
|
}
|
|
|
|
Console.WriteLine($"[SWEEP-C] {findings.Count} buttons carry a genuine per-state "
|
|
+ "label color/outline map.");
|
|
foreach (ButtonFinding f in findings.OrderBy(f => f.LayoutId).ThenBy(f => f.ElementId))
|
|
Console.WriteLine($"[SWEEP-C] layout=0x{f.LayoutId:X8} element=0x{f.ElementId:X8}");
|
|
|
|
// Landmarks this campaign already ported: the nine Appearance spins
|
|
// (Hair/Eyes/Skin/Headgear/Shirt/Trousers/Footwear/Nose/Mouth, all
|
|
// sharing one Highlight-gold-brightening state pair) and the four
|
|
// Town buttons (Normal-gold -> Selected-white caption swap).
|
|
Assert.Contains(findings, f =>
|
|
f.ElementId == CharacterCreationAppearancePage.HairSpinId);
|
|
Assert.Contains(findings, f => IsTownButton(f.ElementId));
|
|
}
|
|
|
|
/// <summary>Town page's four starting-area button ids
|
|
/// (<c>CharacterCreationTownPage</c>'s own private
|
|
/// <c>StartAreaByButtonId</c> keys — no public constants exist there,
|
|
/// so the literals are duplicated here).</summary>
|
|
private static bool IsTownButton(uint elementId) => elementId is
|
|
0x1000040Bu or 0x1000040Du or 0x1000040Eu or 0x1000040Fu;
|
|
|
|
// ── Shared predicates (re-derived from DatWidgetFactory.BuildButton) ──
|
|
|
|
private static bool MatchesLabelBoxShape(ElementInfo info)
|
|
{
|
|
if (info.StateMedia.Count != 0)
|
|
return false;
|
|
ElementInfo[] faces = FindStatefulFaceChildren(info);
|
|
if (faces.Length != 1)
|
|
return false;
|
|
|
|
// A DISTINCT lifted Type-12 caption child (not the button's own
|
|
// P0x17) — DatWidgetFactory.BuildButton's own "label is null on the
|
|
// button itself, found on a Type-12 child instead" fallback.
|
|
bool ownCaption = HasStringInfoProperty(info);
|
|
if (ownCaption)
|
|
return false;
|
|
return info.Children.Any(child => child.Type == 12u && HasStringInfoProperty(child));
|
|
}
|
|
|
|
private static bool MatchesConflictingPairShape(ElementInfo info)
|
|
{
|
|
bool hasCustomPair = info.StateMedia.ContainsKey("Unselected") && info.StateMedia.ContainsKey("Selected");
|
|
bool hasStandardPair = info.StateMedia.ContainsKey("Normal") || info.StateMedia.ContainsKey("Highlight");
|
|
return hasCustomPair && hasStandardPair;
|
|
}
|
|
|
|
private static bool MatchesPerStateLabelStyleShape(ElementInfo info)
|
|
{
|
|
// Mirror BuildButton's labelInfo resolution: the button's own P0x17
|
|
// if present, else the first Type-12 child with a resolvable one.
|
|
ElementInfo labelInfo = HasStringInfoProperty(info)
|
|
? info
|
|
: info.Children.FirstOrDefault(child => child.Type == 12u && HasStringInfoProperty(child)) ?? info;
|
|
|
|
return ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu) is not null
|
|
|| ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u) is not null;
|
|
}
|
|
|
|
private static bool HasStringInfoProperty(ElementInfo info) =>
|
|
info.TryGetEffectiveProperty(0x17u, out UiPropertyValue property)
|
|
&& property.Kind == UiPropertyKind.StringInfo;
|
|
|
|
/// <summary>Verbatim copy of <c>DatWidgetFactory.FindStatefulFaceChildren</c>
|
|
/// (private there) — a child whose media state names intersect the
|
|
/// PARENT's own declared state names.</summary>
|
|
private static ElementInfo[] FindStatefulFaceChildren(ElementInfo info) =>
|
|
[.. info.Children
|
|
.Where(child =>
|
|
child.StateMedia.Count != 0
|
|
&& child.StateMedia.Keys.Any(childState =>
|
|
info.States.Values.Any(parentState =>
|
|
string.Equals(parentState.Name, childState, StringComparison.Ordinal))))
|
|
.OrderBy(child => child.ReadOrder)];
|
|
|
|
private static void WalkButtons(
|
|
uint layoutId,
|
|
ElementInfo node,
|
|
List<ButtonFinding> findings,
|
|
Func<ElementInfo, bool> predicate)
|
|
{
|
|
if (node.Type == 1u && predicate(node))
|
|
findings.Add(new ButtonFinding(layoutId, node.Id));
|
|
|
|
foreach (ElementInfo child in node.Children)
|
|
WalkButtons(layoutId, child, findings, predicate);
|
|
}
|
|
}
|