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.ReleaseOwnerCompositeTextureArrayCache.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() {