diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index a4b66ff8..6029f3f5 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -1071,6 +1071,15 @@ internal sealed class LivePresentationCompositionPhase
chargenCatalog,
d.DatLock);
interaction.RetainedUi.Runtime.ChargenPreviewControl = chargenPreviewController;
+ // Campaign CC gate round 1 closeout (Group 1, R2-5): the two
+ // Batch G STOPPED items land here — chargenCatalog already
+ // implements all three color-wheel seams (TryGetPalSet/
+ // TryGetClothingTable/TryGetColor), same instance as the
+ // preview control just above, same one-shot composition-time
+ // assignment.
+ interaction.RetainedUi.Runtime.ChargenPalSetSource = chargenCatalog;
+ interaction.RetainedUi.Runtime.ChargenClothingTableSource = chargenCatalog;
+ interaction.RetainedUi.Runtime.ChargenPaletteColorSource = chargenCatalog;
bindings.AdoptRelease(
"chargen preview control",
() =>
@@ -1142,7 +1151,17 @@ internal sealed class LivePresentationCompositionPhase
foundation.SceneLighting!,
foundation.TextureCache,
foundation.MeshAdapter!,
- camera: summaryCamera),
+ camera: summaryCamera,
+ // F16 (Campaign CC gate round 1 closeout): the Summary
+ // page's OWN render-id pair — see
+ // ChargenPreviewEntityBuilder.SummaryPreviewRenderId's
+ // own doc for why sharing the Appearance page's pair
+ // (the pre-existing default) is a real cross-page
+ // texture-release collision, not merely untidy, since
+ // both pages share the SAME foundation.TextureCache
+ // passed one line above.
+ renderId: AcDream.App.Rendering.ChargenPreviewEntityBuilder.SummaryPreviewRenderId,
+ backdropRenderId: AcDream.App.Rendering.ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId),
static value => value.Dispose());
IUiViewportRenderer? previousSummaryRenderer = summaryViewport.Renderer;
summaryViewport.Renderer = summaryPreviewLease.Resource;
@@ -1170,7 +1189,15 @@ internal sealed class LivePresentationCompositionPhase
// OUT full-body framing (gmCGSummaryPage::InitializePage @
// 0x0047bbf0), not the Appearance page's zoomed-in default —
// see ChargenPreviewController's own ctor doc comment.
- useZoomedOutEye: true);
+ useZoomedOutEye: true,
+ // F16 (Campaign CC gate round 1 closeout): MUST match the
+ // renderId/backdropRenderId pair given to summaryPreviewLease's
+ // own ChargenPreviewRenderer above — see
+ // ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own
+ // doc for why sharing the Appearance page's pair here would
+ // be a real cross-page TextureCache collision.
+ renderId: AcDream.App.Rendering.ChargenPreviewEntityBuilder.SummaryPreviewRenderId,
+ backdropRenderId: AcDream.App.Rendering.ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId);
interaction.RetainedUi.Runtime.SummaryPreviewControl = summaryPreviewController;
bindings.AdoptRelease(
"summary preview control",
diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs
index 42d4cfca..0b56adb8 100644
--- a/src/AcDream.App/Rendering/ChargenPreviewController.cs
+++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs
@@ -180,6 +180,8 @@ internal sealed class ChargenPreviewController :
private readonly IChargenClothingTableSource _clothingTables;
private readonly object _datLock;
private readonly bool _useZoomedOutEye;
+ private readonly uint _renderId;
+ private readonly uint _backdropRenderId;
private readonly Stopwatch _clock = Stopwatch.StartNew();
private ChargenPreviewAnimator? _animator;
@@ -228,7 +230,19 @@ internal sealed class ChargenPreviewController :
IChargenPalSetSource palSets,
IChargenClothingTableSource clothingTables,
object datLock,
- bool useZoomedOutEye = false)
+ bool useZoomedOutEye = false,
+ // F16 (Campaign CC gate round 1 closeout): the render-id pair this
+ // controller stamps on the entities it builds — MUST match the
+ // pair the sibling ChargenPreviewRenderer was constructed with (see
+ // that class's own renderId/backdropRenderId parameters), since
+ // both feed the SAME shared TextureCache owner-tracking key.
+ // Defaults to the Appearance page's pair; the composition root
+ // passes the Summary pair explicitly for its own instance — see
+ // ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own doc for
+ // why sharing the default here would be a real collision, not
+ // merely untidy.
+ uint renderId = ChargenPreviewEntityBuilder.PreviewRenderId,
+ uint backdropRenderId = ChargenPreviewEntityBuilder.PreviewBackdropRenderId)
{
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
@@ -239,6 +253,8 @@ internal sealed class ChargenPreviewController :
_clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_useZoomedOutEye = useZoomedOutEye;
+ _renderId = renderId;
+ _backdropRenderId = backdropRenderId;
_rotation = new ChargenPreviewRotationController();
// Seed the eye NOW, matching whatever the first Rebuild's own
// heritageOrGenderChanged branch below would otherwise defer until
@@ -298,7 +314,7 @@ internal sealed class ChargenPreviewController :
Quaternion heading = MoveToMath.SetHeading(
Quaternion.Identity, _rotation.HeadingDegrees);
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
- _dats, _animations, result, heritageId, heading, _datLock);
+ _dats, _animations, result, heritageId, heading, _datLock, _renderId);
if (build is null)
return false;
@@ -335,7 +351,7 @@ internal sealed class ChargenPreviewController :
WorldEntity? backdrop =
options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)
? ChargenPreviewEntityBuilder.TryBuildBackdrop(
- _dats, heritage!.EnvironmentSetupId, _datLock)
+ _dats, heritage!.EnvironmentSetupId, _datLock, _backdropRenderId)
: null;
_renderer.SetBackdrop(backdrop);
}
diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs
index fdf74162..b6cf6f73 100644
--- a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs
+++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs
@@ -117,6 +117,37 @@ internal static class ChargenPreviewEntityBuilder
/// member (gmCG3DView::m_pbgObject).
public const uint PreviewBackdropRenderId = 0xDA11_D034u;
+ ///
+ /// F16 (Campaign CC gate round 1 closeout, 2026-08-16): the Summary
+ /// page's OWN preview render-local id — DISTINCT from
+ /// . Both the Appearance and Summary pages
+ /// construct their own ChargenPreviewRenderer, but they share
+ /// ONE process-wide TextureCache (Wb.IEntityTextureLifetime)
+ /// via LivePresentationComposition's foundation.TextureCache
+ /// — confirmed by tracing FixedEntityTextureOwnerLease.Replace →
+ /// TextureCache.ReleaseOwner → CompositeTextureArrayCache.ReleaseOwner
+ /// → its own _owners tracker, keyed ONLY by the raw
+ /// ownerLocalId uint with no per-renderer namespace. Both pages
+ /// are mounted as PERMANENT siblings (register AP-229) and can be
+ /// simultaneously live, so two PrivateEntityViewportRenderer
+ /// instances sharing would share this
+ /// SAME owner bucket: either page re-dressing its own entity (a
+ /// FixedEntityTextureOwnerLease.Replace call) or being disposed
+ /// would call ReleaseOwner(PreviewRenderId) and release textures
+ /// the OTHER page's preview is still actively drawing with — a real
+ /// cross-page texture-corruption path, not a theoretical one. Reserved
+ /// in the SAME 0xDA11D0xx synthetic family, next free slot after the
+ /// Appearance page's own pair.
+ ///
+ public const uint SummaryPreviewRenderId = 0xDA11_D035u;
+
+ /// F16: the Summary page's own backdrop render-local id,
+ /// paired with exactly as
+ /// pairs with
+ /// — see that constant's own doc for why a
+ /// distinct id is required, not merely tidy.
+ public const uint SummaryPreviewBackdropRenderId = 0xDA11_D036u;
+
///
/// Retail's held-pose (REST) animation DID enum key, resolved through
/// master map slot 7 exactly like RetailPaperdollPoseApplicator.ResolvePoseDid
@@ -188,10 +219,11 @@ internal static class ChargenPreviewEntityBuilder
ChargenAppearanceResult appearance,
uint heritageId,
Quaternion heading,
- object datLock)
+ object datLock,
+ uint renderId = PreviewRenderId)
{
ChargenPreviewAnimatedBuild? build = TryBuildAnimated(
- dats, animations, appearance, heritageId, heading, datLock);
+ dats, animations, appearance, heritageId, heading, datLock, renderId);
if (build is null)
return null;
@@ -214,7 +246,13 @@ internal static class ChargenPreviewEntityBuilder
ChargenAppearanceResult appearance,
uint heritageId,
Quaternion heading,
- object datLock)
+ object datLock,
+ // F16 (Campaign CC gate round 1 closeout): the Appearance and
+ // Summary pages both call this method through their own
+ // ChargenPreviewController, but must NOT stamp the same Id on
+ // both entities — see SummaryPreviewRenderId's own doc for the
+ // full TextureCache collision trace this id also feeds.
+ uint renderId = PreviewRenderId)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(animations);
@@ -292,7 +330,7 @@ internal static class ChargenPreviewEntityBuilder
var entity = new WorldEntity
{
- Id = PreviewRenderId,
+ Id = renderId,
ServerGuid = PreviewServerGuid,
SourceGfxObjOrSetupId = setupId,
Position = Vector3.Zero,
@@ -363,7 +401,11 @@ internal static class ChargenPreviewEntityBuilder
public static WorldEntity? TryBuildBackdrop(
IDatReaderWriter dats,
uint environmentSetupId,
- object datLock)
+ object datLock,
+ // F16 (Campaign CC gate round 1 closeout): see TryBuildAnimated's
+ // own renderId parameter doc — same Appearance-vs-Summary
+ // distinction, applied to the backdrop entity.
+ uint renderId = PreviewBackdropRenderId)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(datLock);
@@ -389,7 +431,7 @@ internal static class ChargenPreviewEntityBuilder
return new WorldEntity
{
- Id = PreviewBackdropRenderId,
+ Id = renderId,
ServerGuid = PreviewBackdropServerGuid,
SourceGfxObjOrSetupId = environmentSetupId,
Position = Vector3.Zero,
diff --git a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs
index d643e2b1..898710d8 100644
--- a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs
+++ b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs
@@ -80,7 +80,17 @@ internal sealed class ChargenPreviewRenderer :
IEntityTextureLifetime textureLifetime,
IWbMeshAdapter meshAdapter,
uint heritageId = 0u,
- ChargenPreviewCamera? camera = null)
+ ChargenPreviewCamera? camera = null,
+ // F16 (Campaign CC gate round 1 closeout): the Appearance and
+ // Summary pages each construct their OWN ChargenPreviewRenderer but
+ // share ONE process-wide TextureCache — see
+ // ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own doc for
+ // the full collision trace. Defaulting to the Appearance page's
+ // pair keeps every pre-existing call site byte-identical; the
+ // composition root passes the Summary pair explicitly for its own
+ // instance.
+ uint renderId = ChargenPreviewEntityBuilder.PreviewRenderId,
+ uint backdropRenderId = ChargenPreviewEntityBuilder.PreviewBackdropRenderId)
{
// CC6b-MOUNT: when a caller supplies its own camera instance (the
// page-mount composition, which needs a SETTABLE camera for
@@ -98,13 +108,13 @@ internal sealed class ChargenPreviewRenderer :
lightUbo,
textureLifetime,
meshAdapter,
- ChargenPreviewEntityBuilder.PreviewRenderId,
+ renderId,
_camera,
"chargen preview",
// Batch D (GF-7/GF-14): reserves the second draw-entity slot for
// the heritage's environment Setup — see PrivateEntityViewportRenderer's
// own doc comment on backdropRenderId.
- ChargenPreviewEntityBuilder.PreviewBackdropRenderId);
+ backdropRenderId);
}
public bool TextureIsBottomUp => _renderer.TextureIsBottomUp;
diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
index 22cb8ca5..f293218b 100644
--- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
+++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
@@ -140,6 +140,21 @@ internal sealed class PrivateEntityViewportRenderer :
? new EntitySlot(_meshAdapter, textureLifetimeChecked, backdropId, _diagnosticName + " backdrop")
: null;
+ // F14 (Campaign CC gate round 1 closeout): this set is built ONCE
+ // here, from the RESERVED backdropRenderId (a renderer either has a
+ // backdrop slot or it doesn't — see _backdropSlot's own doc), not
+ // from whether a backdrop ENTITY is currently set via
+ // SetBackdrop/BuildDrawEntities. That is deliberately harmless, not
+ // an oversight: BuildDrawEntities below already degrades to
+ // [main] alone whenever the backdrop slot is null or has no
+ // meshes, so animatedEntityIds carrying a backdrop id with no
+ // matching entry in THIS frame's actual draw-entities list is a
+ // pure dead lookup (WbDrawDispatcher.Draw only ever consults this
+ // set against ids it is ACTUALLY drawing) — never a wrong-entity
+ // animation flag, never extra per-frame work beyond one inert
+ // HashSet entry. Recomputing per-frame would add real complexity
+ // (a second HashSet allocation or a mutable-set sync path) for a
+ // case that is already correct by construction.
_animatedIds = backdropRenderId is uint animatedBackdropId
? [renderId, animatedBackdropId]
: [renderId];
diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs
index c5b77d05..2f90d34d 100644
--- a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs
+++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs
@@ -87,12 +87,23 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
: this(
new SilkWindowSizeSurface(window),
new GlfwDisplayModeSwitcher(window),
- // #391's catalog is the validation source. With no catalog
- // installed, the dropdown falls back to the static preset
- // ladder — the validator must fall back to the SAME list
- // (blast M2: an asymmetric fallback made Full Screen a permanent
- // silent no-op on catalog-less hosts). The switcher's own
- // monitor-mode-list check remains the hard guard either way.
+ // F9 correction (Campaign CC gate round 1 closeout, 2026-08-16):
+ // this used to claim the validator "must fall back to the SAME
+ // list" the Config dropdown offers — true when this comment was
+ // written (#391, one catalog for both), but #407 split the
+ // catalog in two: WindowedResolutions (the dropdown's fuller
+ // union offering, since a windowed pick needs no real video
+ // mode) versus Resolutions (the narrower, fullscreen-SAFE
+ // hardware list this validator deliberately reads). Post-#407 a
+ // windowed-only entry submitted for fullscreen is EXPECTED to
+ // fail this check and refuse gracefully (log-and-stay,
+ // #388/#392's own documented behavior) — that is no longer the
+ // blast-M2 silent-no-op bug, it is the correct outcome. With no
+ // catalog installed at all (fixture/headless/UI-Studio hosts),
+ // Resolutions is null and this still falls back to the static
+ // preset ladder, matching every offering DisplayModeCatalog
+ // makes in that state. The switcher's own monitor-mode-list
+ // check remains the hard guard either way.
spec => (Rendering.DisplayModeCatalog.Resolutions
?? DisplaySettings.AvailableResolutions).Contains(spec))
{
diff --git a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs
index 0a2a03cb..a451a506 100644
--- a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs
+++ b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs
@@ -174,7 +174,13 @@ internal sealed class CharacterCreationHeritagePage : IDisposable
IReadOnlyList segments = ComposeSegments(
_description, view, snapshot.HeritageId, _bindings.ResolveText);
- _description.LinesProvider = () => DatRichText.Compose(_description, segments);
+ // F11 (Campaign CC gate round 1 closeout): compose ONCE here, inside
+ // Refresh (already revision-gated by CharacterCreationUiController.Tick
+ // — this method only runs when something in chargen state actually
+ // changed), and hand LinesProvider the already-built list instead of
+ // re-composing (escape-normalize + word-wrap) on EVERY draw call.
+ IReadOnlyList composed = DatRichText.Compose(_description, segments);
+ _description.LinesProvider = () => composed;
}
internal void Randomize(RuntimeCharacterCreationSnapshot snapshot)
diff --git a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs
index 36a69d64..ff3433a6 100644
--- a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs
+++ b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs
@@ -256,7 +256,12 @@ internal sealed class CharacterCreationProfessionPage : IDisposable
{
string? text = _bindings.ResolveText?.Invoke(key);
var segments = new[] { new DatRichText.Segment(text, _description.DefaultColor) };
- _description.LinesProvider = () => DatRichText.Compose(_description, segments);
+ // F11 (Campaign CC gate round 1 closeout): compose ONCE here
+ // (Refresh is already revision-gated) instead of re-wrapping on
+ // every draw call — see CharacterCreationHeritagePage.Refresh's
+ // own comment for the full rationale.
+ IReadOnlyList composed = DatRichText.Compose(_description, segments);
+ _description.LinesProvider = () => composed;
}
}
diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs
index 9b9cdb39..8662ffe7 100644
--- a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs
+++ b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs
@@ -286,9 +286,14 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
if (builder.Length == 0)
return;
- string composed = builder.ToString();
- var segments = new[] { new DatRichText.Segment(composed, _howToText.DefaultColor) };
- _howToText.LinesProvider = () => DatRichText.Compose(_howToText, segments);
+ string composedText = builder.ToString();
+ var segments = new[] { new DatRichText.Segment(composedText, _howToText.DefaultColor) };
+ // F11 (Campaign CC gate round 1 closeout): compose ONCE here
+ // (Refresh is already revision-gated) instead of re-wrapping on
+ // every draw call — see CharacterCreationHeritagePage.Refresh's own
+ // comment for the full rationale.
+ IReadOnlyList composedLines = DatRichText.Compose(_howToText, segments);
+ _howToText.LinesProvider = () => composedLines;
}
// ── Name field (ListenToElementMessage @ 0x0047bf40) ────────────────
diff --git a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs
index f2b1be7d..afdea3ea 100644
--- a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs
+++ b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs
@@ -118,7 +118,12 @@ internal sealed class CharacterCreationTownPage : IDisposable
// looked like the text never changed).
string composed = ComposeDescription(snapshot.StartArea, _bindings.ResolveText);
var segments = new[] { new DatRichText.Segment(composed, _description.DefaultColor) };
- _description.LinesProvider = () => DatRichText.Compose(_description, segments);
+ // F11 (Campaign CC gate round 1 closeout): compose ONCE here
+ // (Refresh is already revision-gated) instead of re-wrapping on
+ // every draw call — see CharacterCreationHeritagePage.Refresh's own
+ // comment for the full rationale.
+ IReadOnlyList composedLines = DatRichText.Compose(_description, segments);
+ _description.LinesProvider = () => composedLines;
}
internal void Randomize(IRuntimeCharacterCreationView view)
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 4773e5dd..b726df79 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -915,6 +915,22 @@ public static class DatWidgetFactory
else
{
button.LabelAlign = UiButton.LabelAlignment.Left;
+ // F10 (Campaign CC gate round 1 closeout): this +4f gap and
+ // UiButton.LabelOffsetX's own class-default 3f (used by the
+ // "no face, not lifted" branch below, AND by any caller —
+ // e.g. PaperdollController's "Slots" label — that sets
+ // LabelAlign=Left directly with no DatWidgetFactory
+ // involvement at all) are DELIBERATELY not the same number,
+ // not an unreconciled oversight: neither carries a retail
+ // decomp citation (both are acdream-synthesized small
+ // insets), and they answer different questions — this one
+ // is "gap after a REAL adjacent face element" (a geometry-
+ // derived offset), the other is "default left inset when
+ // there is no reference geometry at all" (a context-free
+ // fallback). Moving either number to match the other would
+ // be an unfounded 1px guess on whichever button currently
+ // works, not a fix — see DatWidgetFactoryTests' own
+ // `face.X(0) + face.Width(32) + 4` pin for this exact site.
button.LabelOffsetX = face.X + face.Width + 4f;
}
}
@@ -932,7 +948,10 @@ public static class DatWidgetFactory
// Type-12 child, and the built row's LabelAlign came out Center.
// labelInfo.X is only a valid inner-offset when a distinct child
// was actually lifted; for the direct (labelInfo == info) case,
- // leave UiButton's own default 3px LabelOffsetX in place.
+ // leave UiButton's own default 3px LabelOffsetX in place — see
+ // the face-relative +4f branch above (F10) for why this 3px
+ // default and that 4px gap are deliberately different numbers,
+ // not an unreconciled asymmetry.
button.LabelAlign = UiButton.LabelAlignment.Left;
if (!ReferenceEquals(labelInfo, info))
button.LabelOffsetX = labelInfo.X;
diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs
index 215f3093..bb411eb4 100644
--- a/src/AcDream.App/UI/Layout/LayoutImporter.cs
+++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs
@@ -189,7 +189,21 @@ public static class LayoutImporter
{
if (child.StateMedia.Count == 0) continue;
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId);
- if (cw is not null) w.AddChild(cw);
+ if (cw is null) continue;
+ // F5/F6 (Campaign CC gate round 1 closeout): a NARROW honor
+ // of AuthoredInvisible, scoped to children reached through
+ // THIS carve-out only — e.g. the chat new-text indicator
+ // (0x1000048C, live-DAT-confirmed Invisible=true on every
+ // layout it appears in) would otherwise render as a phantom
+ // element retail never shows, now that this carve-out
+ // builds it as a real widget instead of silently dropping
+ // it. This is NOT the general client-wide honor (#408,
+ // 1,083 elements) — every OTHER AuthoredInvisible consumer
+ // stays data-only, acted on nowhere but chargen's own
+ // HideAuthoredInvisibleElements walk (register AP-230).
+ if (cw.AuthoredInvisible)
+ cw.Visible = false;
+ w.AddChild(cw);
}
}
diff --git a/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs b/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs
index 0803a711..009a39d9 100644
--- a/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs
+++ b/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs
@@ -99,7 +99,18 @@ public sealed class BoundedProcessOutputCapture : IDisposable
/// Process.ErrorDataReceived line) followed by a newline. Never
/// throws. A line (the sentinel .NET's
/// ErrorDataReceived raises once when the stream closes) is a
- /// silent no-op.
+ /// silent no-op.
+ ///
+ ///
+ /// F7 (Campaign CC gate round 1 closeout): the text and its trailing
+ /// newline are combined into ONE buffer and written through ONE
+ /// call. The class doc's own "every write
+ /// opens the file fresh" contract means two separate calls (text, then
+ /// newline) used to open/write/flush/close the file TWICE per logical
+ /// line — needless I/O for a sink that already fires once per received
+ /// output line.
+ ///
+ ///
public void AppendLine(string? line)
{
if (line is null)
@@ -107,10 +118,14 @@ public sealed class BoundedProcessOutputCapture : IDisposable
return;
}
+ byte[] textBytes = Encoding.UTF8.GetBytes(line);
+ var buffer = new byte[textBytes.Length + Newline.Length];
+ textBytes.CopyTo(buffer, 0);
+ Newline.CopyTo(buffer, textBytes.Length);
+
lock (_gate)
{
- AppendLocked(Encoding.UTF8.GetBytes(line));
- AppendLocked(Newline);
+ AppendLocked(buffer);
}
}
diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs
index abebc743..68fb5fcb 100644
--- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs
@@ -66,6 +66,57 @@ public sealed class ChargenPreviewEntityBuilderTests
_out.WriteLine($"setup=0x{appearance.SetupId:X8} meshRefs={entity.MeshRefs.Count} subPalettes={entity.PaletteOverride.SubPalettes.Count}");
}
+ ///
+ /// F16 (Campaign CC gate round 1 closeout): the Appearance and Summary
+ /// pages must stamp DIFFERENT
+ /// values on their preview entities — both feed the SAME shared
+ /// TextureCache owner-tracking key
+ /// (ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own doc
+ /// has the full collision trace). Pins BOTH halves: the constants
+ /// themselves are distinct, AND the explicit renderId parameter
+ /// actually reaches the built entity (not silently ignored).
+ ///
+ [Fact]
+ public void TryBuild_ExplicitRenderId_StampsThatIdOnTheEntity_DistinctFromTheAppearanceDefault()
+ {
+ Assert.NotEqual(
+ ChargenPreviewEntityBuilder.PreviewRenderId,
+ ChargenPreviewEntityBuilder.SummaryPreviewRenderId);
+ Assert.NotEqual(
+ ChargenPreviewEntityBuilder.PreviewBackdropRenderId,
+ ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId);
+
+ string? datDir = CornerFloodReplayTests.ResolveDatDir();
+ if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ using var adapter = new DatCollectionAdapter(dats);
+
+ ChargenOptions options = ChargenTableReader.Load(adapter);
+ Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian)); // Aluvian.
+ Assert.True(aluvian!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male));
+
+ var catalog = new ChargenAppearanceCatalog(adapter);
+ ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with
+ {
+ HairStyle = male!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset,
+ SkinShade = 0.5,
+ };
+
+ bool composed = ChargenAppearanceFactory.TryCompose(
+ options, 1u, 1, selection, catalog, catalog, out ChargenAppearanceResult appearance);
+ Assert.True(composed);
+
+ var animations = new RetailAnimationLoader(adapter);
+ var entity = ChargenPreviewEntityBuilder.TryBuild(
+ adapter, animations, appearance, heritageId: 1u, Quaternion.Identity, new object(),
+ renderId: ChargenPreviewEntityBuilder.SummaryPreviewRenderId);
+
+ Assert.NotNull(entity);
+ Assert.Equal(ChargenPreviewEntityBuilder.SummaryPreviewRenderId, entity!.Id);
+ Assert.NotEqual(ChargenPreviewEntityBuilder.PreviewRenderId, entity.Id);
+ }
+
[Fact]
public void TryBuild_UnknownSetupId_ReturnsNull()
{
@@ -317,6 +368,36 @@ public sealed class ChargenPreviewEntityBuilderTests
_out.WriteLine($"backdropSetup=0x{aluvian.EnvironmentSetupId:X8} meshRefs={entity.MeshRefs.Count}");
}
+ /// F16 (Campaign CC gate round 1 closeout): the backdrop's own
+ /// explicit renderId parameter reaches the built entity, the
+ /// same shape as
+ /// pins for the main preview entity.
+ [Fact]
+ public void TryBuildBackdrop_ExplicitRenderId_StampsThatIdOnTheEntity()
+ {
+ string? datDir = CornerFloodReplayTests.ResolveDatDir();
+ if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ using var adapter = new DatCollectionAdapter(dats);
+
+ ChargenOptions options = ChargenTableReader.Load(adapter);
+ Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian)); // Aluvian.
+ if (aluvian!.EnvironmentSetupId == 0u)
+ {
+ _out.WriteLine("SKIP: installed dat's Aluvian heritage authors no EnvironmentSetupId.");
+ return;
+ }
+
+ var entity = ChargenPreviewEntityBuilder.TryBuildBackdrop(
+ adapter, aluvian.EnvironmentSetupId, new object(),
+ renderId: ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId);
+
+ Assert.NotNull(entity);
+ Assert.Equal(ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId, entity!.Id);
+ Assert.NotEqual(ChargenPreviewEntityBuilder.PreviewBackdropRenderId, entity.Id);
+ }
+
/// Retail's own gate at 0x004eed29 (if (eax_32 != INVALID_DID.id))
/// skips creating a backdrop object entirely when the heritage authors no
/// environment Setup — id 0/unset must return null, not an empty entity.
diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs
index 97be363d..7970fb2b 100644
--- a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs
@@ -86,23 +86,32 @@ public sealed class LayoutImporterMediaBearingChildSweepTests
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
// MAIN GAME UI (0x21000005/0x1000059A): the same eight gold-frame
- // pieces the chargen boxes carry.
+ // pieces the chargen boxes carry. F5/F6 closeout: NONE of these
+ // author Invisible, so all eight must build VISIBLE — the "other
+ // direction" the reviewer named, pinned here alongside the
+ // enumeration sweep's own DoesNotContain assertions.
AssertChildrenBuild(
dats, layoutId: 0x21000005u, elementId: 0x1000059Au,
expectedChildIds:
[
0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u,
0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu,
- ]);
+ ],
+ expectedInvisible: []);
- // CHAT INPUT (0x2100006F/0x10000011): a single media-bearing child.
+ // Chat transcript (0x2100006F/0x10000011): a single media-bearing
+ // child. F5/F6 closeout: 0x1000048C (the new-text indicator)
+ // authors Invisible=true — must build HIDDEN, not as a phantom
+ // visible element.
AssertChildrenBuild(
dats, layoutId: 0x2100006Fu, elementId: 0x10000011u,
- expectedChildIds: [0x1000048Cu]);
+ expectedChildIds: [0x1000048Cu],
+ expectedInvisible: [0x1000048Cu]);
}
private static void AssertChildrenBuild(
- IDatReaderWriter dats, uint layoutId, uint elementId, uint[] expectedChildIds)
+ IDatReaderWriter dats, uint layoutId, uint elementId, uint[] expectedChildIds,
+ uint[] expectedInvisible)
{
ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId);
Assert.NotNull(tree);
@@ -114,7 +123,10 @@ public sealed class LayoutImporterMediaBearingChildSweepTests
Assert.IsType(built);
foreach (uint childId in expectedChildIds)
{
- Assert.NotNull(UiElement.FindDescendant(built, childId));
+ UiElement? child = UiElement.FindDescendant(built, childId);
+ Assert.NotNull(child);
+ bool shouldBeInvisible = expectedInvisible.Contains(childId);
+ Assert.Equal(!shouldBeInvisible, child!.Visible);
}
}
@@ -129,6 +141,78 @@ public sealed class LayoutImporterMediaBearingChildSweepTests
return null;
}
+ private readonly record struct InvisibleChildFinding(uint LayoutId, uint ParentElementId, uint ChildElementId);
+
+ ///
+ /// Campaign CC gate round 1 closeout, F5/F6: of the media-bearing
+ /// children the Batch C carve-out now builds instead of dropping
+ /// ('s
+ /// own set), which ones author dat property 0x3B (Invisible)
+ /// THEMSELVES — retail would never show them
+ /// (UIElement::OnSetAttribute @0x00462d80 case 8), so building
+ /// them unconditionally as a visible widget is a regression the
+ /// carve-out's own commit didn't check for. Confirms the chargen
+ /// gold-frame pieces are NOT among them (the other direction the
+ /// reviewer named — Batch A's chargen-scoped hide walk must not eat
+ /// them either way, but this proves the DATA itself never marks them
+ /// invisible, independent of which honor mechanism runs).
+ ///
+ [InstalledDatFact]
+ public void MediaBearingChildSweep_EnumeratesWhichAffectedChildrenAuthorInvisible()
+ {
+ using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
+
+ var invisibleFindings = new List();
+ foreach (uint layoutId in dats.GetAllIdsOfType().OrderBy(x => x))
+ {
+ ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId);
+ if (tree is null) continue;
+ WalkForInvisibleMediaBearingChildren(layoutId, tree, invisibleFindings);
+ }
+
+ Console.WriteLine($"[SWEEP-INV] {invisibleFindings.Count} media-bearing children of the "
+ + "Batch C carve-out author Invisible=true themselves.");
+ foreach (InvisibleChildFinding f in invisibleFindings)
+ {
+ Console.WriteLine($"[SWEEP-INV] layout=0x{f.LayoutId:X8} parent=0x{f.ParentElementId:X8} "
+ + $"child=0x{f.ChildElementId:X8}");
+ }
+
+ // The chargen gold-frame pieces (GF-12) must NOT author Invisible —
+ // otherwise a narrow per-child honor would eat them, undoing that
+ // fix. Checked directly against the data, independent of whichever
+ // honor mechanism runs.
+ uint[] goldFramePieceIds =
+ [
+ 0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u,
+ 0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu,
+ ];
+ foreach (uint pieceId in goldFramePieceIds)
+ {
+ Assert.DoesNotContain(invisibleFindings, f => f.ChildElementId == pieceId);
+ }
+ }
+
+ private static void WalkForInvisibleMediaBearingChildren(
+ uint layoutId, ElementInfo node, List findings)
+ {
+ if (node.Type == 12u)
+ {
+ bool passToChildren = node.States.Values.Any(static s => s.PassToChildren);
+ if (!passToChildren)
+ {
+ foreach (ElementInfo child in node.Children)
+ {
+ if (child.StateMedia.Count > 0 && child.Invisible)
+ findings.Add(new InvisibleChildFinding(layoutId, node.Id, child.Id));
+ }
+ }
+ }
+
+ foreach (ElementInfo child in node.Children)
+ WalkForInvisibleMediaBearingChildren(layoutId, child, findings);
+ }
+
private static void Walk(uint layoutId, ElementInfo node, List findings)
{
if (node.Type == 12u)
diff --git a/tests/AcDream.App.Tests/UI/Layout/UiButtonCorpusSweepTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiButtonCorpusSweepTests.cs
new file mode 100644
index 00000000..05be8fac
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/UiButtonCorpusSweepTests.cs
@@ -0,0 +1,214 @@
+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 closeout, F4: three client-wide blast-radius
+/// sweeps over EVERY installed LayoutDesc, walking every Type-1
+/// (UIElement_Button) element and matching the SAME structural
+/// predicates uses internally
+/// (predicates re-derived here rather than reflected, since the source
+/// methods are private — kept in sync by citing the exact source
+/// line ranges in each sweep's own doc). Same style as
+/// : logs the full
+/// enumeration for the commit message, pins landmark counts rather than a
+/// brittle exact global total.
+///
+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);
+
+ ///
+ /// Sweep (a): which buttons take the GF-11c LabelBox path —
+ /// info.StateMedia.Count==0 (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
+ /// DatWidgetFactory.BuildButton:889-914 for the exact shape this
+ /// mirrors.
+ ///
+ [InstalledDatFact]
+ public void LabelBoxPath_EnumeratesEveryMatchingButton()
+ {
+ 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;
+ 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));
+ }
+
+ ///
+ /// Sweep (b): any button authoring BOTH the custom Unselected/Selected
+ /// radio-pair (UiButton's _hasCustomSelectionPair bypass,
+ /// GF-1/GF-8) AND standard Normal/Highlight media — the custom-pair
+ /// bypass would eat the standard state machine for such a button
+ /// (UiButton.UpdateVisualState's if (_hasCustomSelectionPair)
+ /// branch runs UNCONDITIONALLY when the pair is present, never falling
+ /// through to the standard _availableStates 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).
+ ///
+ [InstalledDatFact]
+ public void CustomSelectionPair_NeverCoexistsWithStandardNormalHighlightMedia()
+ {
+ 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;
+ 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);
+ }
+
+ ///
+ /// Sweep (c): any button with a genuine per-state label-color/outline
+ /// map (AP-222's mechanism, ElementReader.BuildPerStateColorMap/
+ /// BuildPerStateBoolMap against dat properties 0x1B/
+ /// 0x21 — 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.
+ ///
+ [InstalledDatFact]
+ public void PerStateLabelColorMap_EnumeratesEveryButtonBeyondChargen()
+ {
+ 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;
+ 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));
+ }
+
+ /// Town page's four starting-area button ids
+ /// (CharacterCreationTownPage's own private
+ /// StartAreaByButtonId keys — no public constants exist there,
+ /// so the literals are duplicated here).
+ 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;
+
+ /// Verbatim copy of DatWidgetFactory.FindStatefulFaceChildren
+ /// (private there) — a child whose media state names intersect the
+ /// PARENT's own declared state names.
+ 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 findings,
+ Func 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);
+ }
+}
diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/BoundedProcessOutputCaptureTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/BoundedProcessOutputCaptureTests.cs
index 5ad94363..7d13e5fb 100644
--- a/tests/AcDream.Launcher.Core.Tests/Launching/BoundedProcessOutputCaptureTests.cs
+++ b/tests/AcDream.Launcher.Core.Tests/Launching/BoundedProcessOutputCaptureTests.cs
@@ -81,6 +81,36 @@ public sealed class BoundedProcessOutputCaptureTests
}
}
+ /// F7 (Campaign CC gate round 1 closeout): AppendLine
+ /// now combines the text and its trailing newline into ONE buffer
+ /// before writing, instead of two separate file open/write/close
+ /// round-trips. Pins the boundary case that change touches most
+ /// directly — a line whose TEXT ALONE exactly exhausts the remaining
+ /// cap, so the newline byte must be dropped by the SAME truncation
+ /// decision as the text, not a second one.
+ [Fact]
+ public void ALineWhoseTextExactlyExhaustsTheCap_DropsOnlyTheTrailingNewline()
+ {
+ string path = TempPath();
+ try
+ {
+ // "0123456789" is exactly 10 bytes; maxBytes=10 leaves no room
+ // for the newline the combined buffer also carries.
+ using var capture = new BoundedProcessOutputCapture(path, maxBytes: 10);
+
+ capture.AppendLine("0123456789");
+
+ Assert.True(capture.IsDone);
+ string written = File.ReadAllText(path);
+ Assert.StartsWith("0123456789", written, StringComparison.Ordinal);
+ Assert.Contains("truncated at 10 bytes", written, StringComparison.Ordinal);
+ }
+ finally
+ {
+ TryDelete(path);
+ }
+ }
+
[Fact]
public void ALogSpammingChildCannotGrowTheFileUnboundedly()
{