fix(chargen): Campaign CC gate round 1 closeout — Group 3: round review fixes (F4-F11, F14, F16)

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>
This commit is contained in:
Erik 2026-08-16 15:34:07 +02:00
parent 0fed5fdd91
commit bd359d5181
17 changed files with 636 additions and 37 deletions

View file

@ -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",

View file

@ -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);
}

View file

@ -117,6 +117,37 @@ internal static class ChargenPreviewEntityBuilder
/// member (<c>gmCG3DView::m_pbgObject</c>).</summary>
public const uint PreviewBackdropRenderId = 0xDA11_D034u;
/// <summary>
/// F16 (Campaign CC gate round 1 closeout, 2026-08-16): the Summary
/// page's OWN preview render-local id — DISTINCT from
/// <see cref="PreviewRenderId"/>. Both the Appearance and Summary pages
/// construct their own <c>ChargenPreviewRenderer</c>, but they share
/// ONE process-wide <c>TextureCache</c> (<c>Wb.IEntityTextureLifetime</c>)
/// via <c>LivePresentationComposition</c>'s <c>foundation.TextureCache</c>
/// — confirmed by tracing <c>FixedEntityTextureOwnerLease.Replace</c> →
/// <c>TextureCache.ReleaseOwner</c> → <c>CompositeTextureArrayCache.ReleaseOwner</c>
/// → its own <c>_owners</c> tracker, keyed ONLY by the raw
/// <c>ownerLocalId</c> uint with no per-renderer namespace. Both pages
/// are mounted as PERMANENT siblings (register AP-229) and can be
/// simultaneously live, so two <c>PrivateEntityViewportRenderer</c>
/// instances sharing <see cref="PreviewRenderId"/> would share this
/// SAME owner bucket: either page re-dressing its own entity (a
/// <c>FixedEntityTextureOwnerLease.Replace</c> call) or being disposed
/// would call <c>ReleaseOwner(PreviewRenderId)</c> 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.
/// </summary>
public const uint SummaryPreviewRenderId = 0xDA11_D035u;
/// <summary>F16: the Summary page's own backdrop render-local id,
/// paired with <see cref="SummaryPreviewRenderId"/> exactly as
/// <see cref="PreviewBackdropRenderId"/> pairs with
/// <see cref="PreviewRenderId"/> — see that constant's own doc for why a
/// distinct id is required, not merely tidy.</summary>
public const uint SummaryPreviewBackdropRenderId = 0xDA11_D036u;
/// <summary>
/// Retail's held-pose (REST) animation DID enum key, resolved through
/// master map slot 7 exactly like <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>
@ -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,

View file

@ -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;

View file

@ -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];

View file

@ -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))
{

View file

@ -174,7 +174,13 @@ internal sealed class CharacterCreationHeritagePage : IDisposable
IReadOnlyList<DatRichText.Segment> 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<UiText.Line> composed = DatRichText.Compose(_description, segments);
_description.LinesProvider = () => composed;
}
internal void Randomize(RuntimeCharacterCreationSnapshot snapshot)

View file

@ -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<UiText.Line> composed = DatRichText.Compose(_description, segments);
_description.LinesProvider = () => composed;
}
}

View file

@ -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<UiText.Line> composedLines = DatRichText.Compose(_howToText, segments);
_howToText.LinesProvider = () => composedLines;
}
// ── Name field (ListenToElementMessage @ 0x0047bf40) ────────────────

View file

@ -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<UiText.Line> composedLines = DatRichText.Compose(_description, segments);
_description.LinesProvider = () => composedLines;
}
internal void Randomize(IRuntimeCharacterCreationView view)

View file

@ -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;

View file

@ -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);
}
}