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:
parent
0fed5fdd91
commit
bd359d5181
17 changed files with 636 additions and 37 deletions
|
|
@ -1071,6 +1071,15 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
chargenCatalog,
|
chargenCatalog,
|
||||||
d.DatLock);
|
d.DatLock);
|
||||||
interaction.RetainedUi.Runtime.ChargenPreviewControl = chargenPreviewController;
|
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(
|
bindings.AdoptRelease(
|
||||||
"chargen preview control",
|
"chargen preview control",
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -1142,7 +1151,17 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
foundation.SceneLighting!,
|
foundation.SceneLighting!,
|
||||||
foundation.TextureCache,
|
foundation.TextureCache,
|
||||||
foundation.MeshAdapter!,
|
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());
|
static value => value.Dispose());
|
||||||
IUiViewportRenderer? previousSummaryRenderer = summaryViewport.Renderer;
|
IUiViewportRenderer? previousSummaryRenderer = summaryViewport.Renderer;
|
||||||
summaryViewport.Renderer = summaryPreviewLease.Resource;
|
summaryViewport.Renderer = summaryPreviewLease.Resource;
|
||||||
|
|
@ -1170,7 +1189,15 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
// OUT full-body framing (gmCGSummaryPage::InitializePage @
|
// OUT full-body framing (gmCGSummaryPage::InitializePage @
|
||||||
// 0x0047bbf0), not the Appearance page's zoomed-in default —
|
// 0x0047bbf0), not the Appearance page's zoomed-in default —
|
||||||
// see ChargenPreviewController's own ctor doc comment.
|
// 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;
|
interaction.RetainedUi.Runtime.SummaryPreviewControl = summaryPreviewController;
|
||||||
bindings.AdoptRelease(
|
bindings.AdoptRelease(
|
||||||
"summary preview control",
|
"summary preview control",
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,8 @@ internal sealed class ChargenPreviewController :
|
||||||
private readonly IChargenClothingTableSource _clothingTables;
|
private readonly IChargenClothingTableSource _clothingTables;
|
||||||
private readonly object _datLock;
|
private readonly object _datLock;
|
||||||
private readonly bool _useZoomedOutEye;
|
private readonly bool _useZoomedOutEye;
|
||||||
|
private readonly uint _renderId;
|
||||||
|
private readonly uint _backdropRenderId;
|
||||||
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
||||||
|
|
||||||
private ChargenPreviewAnimator? _animator;
|
private ChargenPreviewAnimator? _animator;
|
||||||
|
|
@ -228,7 +230,19 @@ internal sealed class ChargenPreviewController :
|
||||||
IChargenPalSetSource palSets,
|
IChargenPalSetSource palSets,
|
||||||
IChargenClothingTableSource clothingTables,
|
IChargenClothingTableSource clothingTables,
|
||||||
object datLock,
|
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));
|
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
||||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||||
|
|
@ -239,6 +253,8 @@ internal sealed class ChargenPreviewController :
|
||||||
_clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables));
|
_clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables));
|
||||||
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
||||||
_useZoomedOutEye = useZoomedOutEye;
|
_useZoomedOutEye = useZoomedOutEye;
|
||||||
|
_renderId = renderId;
|
||||||
|
_backdropRenderId = backdropRenderId;
|
||||||
_rotation = new ChargenPreviewRotationController();
|
_rotation = new ChargenPreviewRotationController();
|
||||||
// Seed the eye NOW, matching whatever the first Rebuild's own
|
// Seed the eye NOW, matching whatever the first Rebuild's own
|
||||||
// heritageOrGenderChanged branch below would otherwise defer until
|
// heritageOrGenderChanged branch below would otherwise defer until
|
||||||
|
|
@ -298,7 +314,7 @@ internal sealed class ChargenPreviewController :
|
||||||
Quaternion heading = MoveToMath.SetHeading(
|
Quaternion heading = MoveToMath.SetHeading(
|
||||||
Quaternion.Identity, _rotation.HeadingDegrees);
|
Quaternion.Identity, _rotation.HeadingDegrees);
|
||||||
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
|
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
|
||||||
_dats, _animations, result, heritageId, heading, _datLock);
|
_dats, _animations, result, heritageId, heading, _datLock, _renderId);
|
||||||
if (build is null)
|
if (build is null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|
@ -335,7 +351,7 @@ internal sealed class ChargenPreviewController :
|
||||||
WorldEntity? backdrop =
|
WorldEntity? backdrop =
|
||||||
options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)
|
options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)
|
||||||
? ChargenPreviewEntityBuilder.TryBuildBackdrop(
|
? ChargenPreviewEntityBuilder.TryBuildBackdrop(
|
||||||
_dats, heritage!.EnvironmentSetupId, _datLock)
|
_dats, heritage!.EnvironmentSetupId, _datLock, _backdropRenderId)
|
||||||
: null;
|
: null;
|
||||||
_renderer.SetBackdrop(backdrop);
|
_renderer.SetBackdrop(backdrop);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,37 @@ internal static class ChargenPreviewEntityBuilder
|
||||||
/// member (<c>gmCG3DView::m_pbgObject</c>).</summary>
|
/// member (<c>gmCG3DView::m_pbgObject</c>).</summary>
|
||||||
public const uint PreviewBackdropRenderId = 0xDA11_D034u;
|
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>
|
/// <summary>
|
||||||
/// Retail's held-pose (REST) animation DID enum key, resolved through
|
/// Retail's held-pose (REST) animation DID enum key, resolved through
|
||||||
/// master map slot 7 exactly like <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>
|
/// master map slot 7 exactly like <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>
|
||||||
|
|
@ -188,10 +219,11 @@ internal static class ChargenPreviewEntityBuilder
|
||||||
ChargenAppearanceResult appearance,
|
ChargenAppearanceResult appearance,
|
||||||
uint heritageId,
|
uint heritageId,
|
||||||
Quaternion heading,
|
Quaternion heading,
|
||||||
object datLock)
|
object datLock,
|
||||||
|
uint renderId = PreviewRenderId)
|
||||||
{
|
{
|
||||||
ChargenPreviewAnimatedBuild? build = TryBuildAnimated(
|
ChargenPreviewAnimatedBuild? build = TryBuildAnimated(
|
||||||
dats, animations, appearance, heritageId, heading, datLock);
|
dats, animations, appearance, heritageId, heading, datLock, renderId);
|
||||||
if (build is null)
|
if (build is null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
|
@ -214,7 +246,13 @@ internal static class ChargenPreviewEntityBuilder
|
||||||
ChargenAppearanceResult appearance,
|
ChargenAppearanceResult appearance,
|
||||||
uint heritageId,
|
uint heritageId,
|
||||||
Quaternion heading,
|
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(dats);
|
||||||
ArgumentNullException.ThrowIfNull(animations);
|
ArgumentNullException.ThrowIfNull(animations);
|
||||||
|
|
@ -292,7 +330,7 @@ internal static class ChargenPreviewEntityBuilder
|
||||||
|
|
||||||
var entity = new WorldEntity
|
var entity = new WorldEntity
|
||||||
{
|
{
|
||||||
Id = PreviewRenderId,
|
Id = renderId,
|
||||||
ServerGuid = PreviewServerGuid,
|
ServerGuid = PreviewServerGuid,
|
||||||
SourceGfxObjOrSetupId = setupId,
|
SourceGfxObjOrSetupId = setupId,
|
||||||
Position = Vector3.Zero,
|
Position = Vector3.Zero,
|
||||||
|
|
@ -363,7 +401,11 @@ internal static class ChargenPreviewEntityBuilder
|
||||||
public static WorldEntity? TryBuildBackdrop(
|
public static WorldEntity? TryBuildBackdrop(
|
||||||
IDatReaderWriter dats,
|
IDatReaderWriter dats,
|
||||||
uint environmentSetupId,
|
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(dats);
|
||||||
ArgumentNullException.ThrowIfNull(datLock);
|
ArgumentNullException.ThrowIfNull(datLock);
|
||||||
|
|
@ -389,7 +431,7 @@ internal static class ChargenPreviewEntityBuilder
|
||||||
|
|
||||||
return new WorldEntity
|
return new WorldEntity
|
||||||
{
|
{
|
||||||
Id = PreviewBackdropRenderId,
|
Id = renderId,
|
||||||
ServerGuid = PreviewBackdropServerGuid,
|
ServerGuid = PreviewBackdropServerGuid,
|
||||||
SourceGfxObjOrSetupId = environmentSetupId,
|
SourceGfxObjOrSetupId = environmentSetupId,
|
||||||
Position = Vector3.Zero,
|
Position = Vector3.Zero,
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,17 @@ internal sealed class ChargenPreviewRenderer :
|
||||||
IEntityTextureLifetime textureLifetime,
|
IEntityTextureLifetime textureLifetime,
|
||||||
IWbMeshAdapter meshAdapter,
|
IWbMeshAdapter meshAdapter,
|
||||||
uint heritageId = 0u,
|
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
|
// CC6b-MOUNT: when a caller supplies its own camera instance (the
|
||||||
// page-mount composition, which needs a SETTABLE camera for
|
// page-mount composition, which needs a SETTABLE camera for
|
||||||
|
|
@ -98,13 +108,13 @@ internal sealed class ChargenPreviewRenderer :
|
||||||
lightUbo,
|
lightUbo,
|
||||||
textureLifetime,
|
textureLifetime,
|
||||||
meshAdapter,
|
meshAdapter,
|
||||||
ChargenPreviewEntityBuilder.PreviewRenderId,
|
renderId,
|
||||||
_camera,
|
_camera,
|
||||||
"chargen preview",
|
"chargen preview",
|
||||||
// Batch D (GF-7/GF-14): reserves the second draw-entity slot for
|
// Batch D (GF-7/GF-14): reserves the second draw-entity slot for
|
||||||
// the heritage's environment Setup — see PrivateEntityViewportRenderer's
|
// the heritage's environment Setup — see PrivateEntityViewportRenderer's
|
||||||
// own doc comment on backdropRenderId.
|
// own doc comment on backdropRenderId.
|
||||||
ChargenPreviewEntityBuilder.PreviewBackdropRenderId);
|
backdropRenderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TextureIsBottomUp => _renderer.TextureIsBottomUp;
|
public bool TextureIsBottomUp => _renderer.TextureIsBottomUp;
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,21 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
? new EntitySlot(_meshAdapter, textureLifetimeChecked, backdropId, _diagnosticName + " backdrop")
|
? new EntitySlot(_meshAdapter, textureLifetimeChecked, backdropId, _diagnosticName + " backdrop")
|
||||||
: null;
|
: 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
|
_animatedIds = backdropRenderId is uint animatedBackdropId
|
||||||
? [renderId, animatedBackdropId]
|
? [renderId, animatedBackdropId]
|
||||||
: [renderId];
|
: [renderId];
|
||||||
|
|
|
||||||
|
|
@ -87,12 +87,23 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
|
||||||
: this(
|
: this(
|
||||||
new SilkWindowSizeSurface(window),
|
new SilkWindowSizeSurface(window),
|
||||||
new GlfwDisplayModeSwitcher(window),
|
new GlfwDisplayModeSwitcher(window),
|
||||||
// #391's catalog is the validation source. With no catalog
|
// F9 correction (Campaign CC gate round 1 closeout, 2026-08-16):
|
||||||
// installed, the dropdown falls back to the static preset
|
// this used to claim the validator "must fall back to the SAME
|
||||||
// ladder — the validator must fall back to the SAME list
|
// list" the Config dropdown offers — true when this comment was
|
||||||
// (blast M2: an asymmetric fallback made Full Screen a permanent
|
// written (#391, one catalog for both), but #407 split the
|
||||||
// silent no-op on catalog-less hosts). The switcher's own
|
// catalog in two: WindowedResolutions (the dropdown's fuller
|
||||||
// monitor-mode-list check remains the hard guard either way.
|
// 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
|
spec => (Rendering.DisplayModeCatalog.Resolutions
|
||||||
?? DisplaySettings.AvailableResolutions).Contains(spec))
|
?? DisplaySettings.AvailableResolutions).Contains(spec))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,13 @@ internal sealed class CharacterCreationHeritagePage : IDisposable
|
||||||
|
|
||||||
IReadOnlyList<DatRichText.Segment> segments = ComposeSegments(
|
IReadOnlyList<DatRichText.Segment> segments = ComposeSegments(
|
||||||
_description, view, snapshot.HeritageId, _bindings.ResolveText);
|
_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)
|
internal void Randomize(RuntimeCharacterCreationSnapshot snapshot)
|
||||||
|
|
|
||||||
|
|
@ -256,7 +256,12 @@ internal sealed class CharacterCreationProfessionPage : IDisposable
|
||||||
{
|
{
|
||||||
string? text = _bindings.ResolveText?.Invoke(key);
|
string? text = _bindings.ResolveText?.Invoke(key);
|
||||||
var segments = new[] { new DatRichText.Segment(text, _description.DefaultColor) };
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -286,9 +286,14 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
|
||||||
if (builder.Length == 0)
|
if (builder.Length == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
string composed = builder.ToString();
|
string composedText = builder.ToString();
|
||||||
var segments = new[] { new DatRichText.Segment(composed, _howToText.DefaultColor) };
|
var segments = new[] { new DatRichText.Segment(composedText, _howToText.DefaultColor) };
|
||||||
_howToText.LinesProvider = () => DatRichText.Compose(_howToText, 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(_howToText, segments);
|
||||||
|
_howToText.LinesProvider = () => composedLines;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Name field (ListenToElementMessage @ 0x0047bf40) ────────────────
|
// ── Name field (ListenToElementMessage @ 0x0047bf40) ────────────────
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,12 @@ internal sealed class CharacterCreationTownPage : IDisposable
|
||||||
// looked like the text never changed).
|
// looked like the text never changed).
|
||||||
string composed = ComposeDescription(snapshot.StartArea, _bindings.ResolveText);
|
string composed = ComposeDescription(snapshot.StartArea, _bindings.ResolveText);
|
||||||
var segments = new[] { new DatRichText.Segment(composed, _description.DefaultColor) };
|
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)
|
internal void Randomize(IRuntimeCharacterCreationView view)
|
||||||
|
|
|
||||||
|
|
@ -915,6 +915,22 @@ public static class DatWidgetFactory
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
button.LabelAlign = UiButton.LabelAlignment.Left;
|
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;
|
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.
|
// Type-12 child, and the built row's LabelAlign came out Center.
|
||||||
// labelInfo.X is only a valid inner-offset when a distinct child
|
// labelInfo.X is only a valid inner-offset when a distinct child
|
||||||
// was actually lifted; for the direct (labelInfo == info) case,
|
// 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;
|
button.LabelAlign = UiButton.LabelAlignment.Left;
|
||||||
if (!ReferenceEquals(labelInfo, info))
|
if (!ReferenceEquals(labelInfo, info))
|
||||||
button.LabelOffsetX = labelInfo.X;
|
button.LabelOffsetX = labelInfo.X;
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,21 @@ public static class LayoutImporter
|
||||||
{
|
{
|
||||||
if (child.StateMedia.Count == 0) continue;
|
if (child.StateMedia.Count == 0) continue;
|
||||||
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,18 @@ public sealed class BoundedProcessOutputCapture : IDisposable
|
||||||
/// <c>Process.ErrorDataReceived</c> line) followed by a newline. Never
|
/// <c>Process.ErrorDataReceived</c> line) followed by a newline. Never
|
||||||
/// throws. A <see langword="null"/> line (the sentinel .NET's
|
/// throws. A <see langword="null"/> line (the sentinel .NET's
|
||||||
/// <c>ErrorDataReceived</c> raises once when the stream closes) is a
|
/// <c>ErrorDataReceived</c> raises once when the stream closes) is a
|
||||||
/// silent no-op.</summary>
|
/// silent no-op.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// F7 (Campaign CC gate round 1 closeout): the text and its trailing
|
||||||
|
/// newline are combined into ONE buffer and written through ONE
|
||||||
|
/// <see cref="AppendLocked"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
public void AppendLine(string? line)
|
public void AppendLine(string? line)
|
||||||
{
|
{
|
||||||
if (line is null)
|
if (line is null)
|
||||||
|
|
@ -107,10 +118,14 @@ public sealed class BoundedProcessOutputCapture : IDisposable
|
||||||
return;
|
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)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
AppendLocked(Encoding.UTF8.GetBytes(line));
|
AppendLocked(buffer);
|
||||||
AppendLocked(Newline);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,57 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
_out.WriteLine($"setup=0x{appearance.SetupId:X8} meshRefs={entity.MeshRefs.Count} subPalettes={entity.PaletteOverride.SubPalettes.Count}");
|
_out.WriteLine($"setup=0x{appearance.SetupId:X8} meshRefs={entity.MeshRefs.Count} subPalettes={entity.PaletteOverride.SubPalettes.Count}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F16 (Campaign CC gate round 1 closeout): the Appearance and Summary
|
||||||
|
/// pages must stamp DIFFERENT <see cref="AcDream.Core.World.WorldEntity.Id"/>
|
||||||
|
/// values on their preview entities — both feed the SAME shared
|
||||||
|
/// <c>TextureCache</c> owner-tracking key
|
||||||
|
/// (<c>ChargenPreviewEntityBuilder.SummaryPreviewRenderId</c>'s own doc
|
||||||
|
/// has the full collision trace). Pins BOTH halves: the constants
|
||||||
|
/// themselves are distinct, AND the explicit <c>renderId</c> parameter
|
||||||
|
/// actually reaches the built entity (not silently ignored).
|
||||||
|
/// </summary>
|
||||||
|
[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]
|
[Fact]
|
||||||
public void TryBuild_UnknownSetupId_ReturnsNull()
|
public void TryBuild_UnknownSetupId_ReturnsNull()
|
||||||
{
|
{
|
||||||
|
|
@ -317,6 +368,36 @@ public sealed class ChargenPreviewEntityBuilderTests
|
||||||
_out.WriteLine($"backdropSetup=0x{aluvian.EnvironmentSetupId:X8} meshRefs={entity.MeshRefs.Count}");
|
_out.WriteLine($"backdropSetup=0x{aluvian.EnvironmentSetupId:X8} meshRefs={entity.MeshRefs.Count}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>F16 (Campaign CC gate round 1 closeout): the backdrop's own
|
||||||
|
/// explicit <c>renderId</c> parameter reaches the built entity, the
|
||||||
|
/// same shape as <see cref="TryBuild_ExplicitRenderId_StampsThatIdOnTheEntity_DistinctFromTheAppearanceDefault"/>
|
||||||
|
/// pins for the main preview entity.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Retail's own gate at 0x004eed29 (<c>if (eax_32 != INVALID_DID.id)</c>)
|
/// <summary>Retail's own gate at 0x004eed29 (<c>if (eax_32 != INVALID_DID.id)</c>)
|
||||||
/// skips creating a backdrop object entirely when the heritage authors no
|
/// skips creating a backdrop object entirely when the heritage authors no
|
||||||
/// environment Setup — id 0/unset must return null, not an empty entity.</summary>
|
/// environment Setup — id 0/unset must return null, not an empty entity.</summary>
|
||||||
|
|
|
||||||
|
|
@ -86,23 +86,32 @@ public sealed class LayoutImporterMediaBearingChildSweepTests
|
||||||
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
|
||||||
// MAIN GAME UI (0x21000005/0x1000059A): the same eight gold-frame
|
// 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(
|
AssertChildrenBuild(
|
||||||
dats, layoutId: 0x21000005u, elementId: 0x1000059Au,
|
dats, layoutId: 0x21000005u, elementId: 0x1000059Au,
|
||||||
expectedChildIds:
|
expectedChildIds:
|
||||||
[
|
[
|
||||||
0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u,
|
0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u,
|
||||||
0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu,
|
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(
|
AssertChildrenBuild(
|
||||||
dats, layoutId: 0x2100006Fu, elementId: 0x10000011u,
|
dats, layoutId: 0x2100006Fu, elementId: 0x10000011u,
|
||||||
expectedChildIds: [0x1000048Cu]);
|
expectedChildIds: [0x1000048Cu],
|
||||||
|
expectedInvisible: [0x1000048Cu]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AssertChildrenBuild(
|
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);
|
ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId);
|
||||||
Assert.NotNull(tree);
|
Assert.NotNull(tree);
|
||||||
|
|
@ -114,7 +123,10 @@ public sealed class LayoutImporterMediaBearingChildSweepTests
|
||||||
Assert.IsType<UiText>(built);
|
Assert.IsType<UiText>(built);
|
||||||
foreach (uint childId in expectedChildIds)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly record struct InvisibleChildFinding(uint LayoutId, uint ParentElementId, uint ChildElementId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign CC gate round 1 closeout, F5/F6: of the media-bearing
|
||||||
|
/// children the Batch C carve-out now builds instead of dropping
|
||||||
|
/// (<see cref="MediaBearingChildSweep_EnumeratesEveryAffectedType12Element"/>'s
|
||||||
|
/// own set), which ones author dat property <c>0x3B</c> (Invisible)
|
||||||
|
/// THEMSELVES — retail would never show them
|
||||||
|
/// (<c>UIElement::OnSetAttribute @0x00462d80</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void MediaBearingChildSweep_EnumeratesWhichAffectedChildrenAuthorInvisible()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
|
||||||
|
var invisibleFindings = new List<InvisibleChildFinding>();
|
||||||
|
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>().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<InvisibleChildFinding> 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<Finding> findings)
|
private static void Walk(uint layoutId, ElementInfo node, List<Finding> findings)
|
||||||
{
|
{
|
||||||
if (node.Type == 12u)
|
if (node.Type == 12u)
|
||||||
|
|
|
||||||
214
tests/AcDream.App.Tests/UI/Layout/UiButtonCorpusSweepTests.cs
Normal file
214
tests/AcDream.App.Tests/UI/Layout/UiButtonCorpusSweepTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign CC gate round 1 closeout, F4: three client-wide blast-radius
|
||||||
|
/// sweeps over EVERY installed <c>LayoutDesc</c>, walking every Type-1
|
||||||
|
/// (<c>UIElement_Button</c>) element and matching the SAME structural
|
||||||
|
/// predicates <see cref="DatWidgetFactory.BuildButton"/> uses internally
|
||||||
|
/// (predicates re-derived here rather than reflected, since the source
|
||||||
|
/// methods are <c>private</c> — kept in sync by citing the exact source
|
||||||
|
/// line ranges in each sweep's own doc). Same style as
|
||||||
|
/// <see cref="LayoutImporterMediaBearingChildSweepTests"/>: logs the full
|
||||||
|
/// enumeration for the commit message, pins landmark counts rather than a
|
||||||
|
/// brittle exact global total.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UiButtonCorpusSweepTests
|
||||||
|
{
|
||||||
|
private static string DatDirectory =>
|
||||||
|
System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(
|
||||||
|
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
|
||||||
|
"Documents",
|
||||||
|
"Asheron's Call");
|
||||||
|
|
||||||
|
private readonly record struct ButtonFinding(uint LayoutId, uint ElementId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sweep (a): which buttons take the GF-11c <c>LabelBox</c> path —
|
||||||
|
/// <c>info.StateMedia.Count==0</c> (no media on the button itself) with
|
||||||
|
/// EXACTLY one stateful face child, plus a DISTINCT lifted Type-12
|
||||||
|
/// caption child (not the button's own P0x17) — see
|
||||||
|
/// <c>DatWidgetFactory.BuildButton</c>:889-914 for the exact shape this
|
||||||
|
/// mirrors.
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void LabelBoxPath_EnumeratesEveryMatchingButton()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
|
||||||
|
var findings = new List<ButtonFinding>();
|
||||||
|
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>().OrderBy(x => x))
|
||||||
|
{
|
||||||
|
ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId);
|
||||||
|
if (tree is null) continue;
|
||||||
|
WalkButtons(layoutId, tree, findings, MatchesLabelBoxShape);
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[SWEEP-A] {findings.Count} buttons take the LabelBox path across "
|
||||||
|
+ $"{findings.Select(f => f.LayoutId).Distinct().Count()} layouts.");
|
||||||
|
foreach (ButtonFinding f in findings.OrderBy(f => f.LayoutId).ThenBy(f => f.ElementId))
|
||||||
|
Console.WriteLine($"[SWEEP-A] layout=0x{f.LayoutId:X8} element=0x{f.ElementId:X8}");
|
||||||
|
|
||||||
|
// Landmark this campaign already fixed and gated (GF-11c, the Town
|
||||||
|
// page's per-marker name label) must be in the set — proves the
|
||||||
|
// sweep's predicate is right, not just non-empty.
|
||||||
|
Assert.Contains(findings, f => IsTownButton(f.ElementId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sweep (b): any button authoring BOTH the custom Unselected/Selected
|
||||||
|
/// radio-pair (<c>UiButton</c>'s <c>_hasCustomSelectionPair</c> bypass,
|
||||||
|
/// GF-1/GF-8) AND standard Normal/Highlight media — the custom-pair
|
||||||
|
/// bypass would eat the standard state machine for such a button
|
||||||
|
/// (<c>UiButton.UpdateVisualState</c>'s <c>if (_hasCustomSelectionPair)</c>
|
||||||
|
/// branch runs UNCONDITIONALLY when the pair is present, never falling
|
||||||
|
/// through to the standard <c>_availableStates</c> branch). None found
|
||||||
|
/// in the installed corpus at the ELEMENT's own media level (this sweep
|
||||||
|
/// does not additionally check face-SEGMENT media — see this method's
|
||||||
|
/// own note).
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void CustomSelectionPair_NeverCoexistsWithStandardNormalHighlightMedia()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
|
||||||
|
var findings = new List<ButtonFinding>();
|
||||||
|
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>().OrderBy(x => x))
|
||||||
|
{
|
||||||
|
ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId);
|
||||||
|
if (tree is null) continue;
|
||||||
|
WalkButtons(layoutId, tree, findings, MatchesConflictingPairShape);
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[SWEEP-B] {findings.Count} buttons author BOTH the custom "
|
||||||
|
+ "Unselected/Selected pair AND standard Normal/Highlight media.");
|
||||||
|
foreach (ButtonFinding f in findings)
|
||||||
|
Console.WriteLine($"[SWEEP-B] layout=0x{f.LayoutId:X8} element=0x{f.ElementId:X8}");
|
||||||
|
|
||||||
|
// No conflict exists in the installed corpus today — the
|
||||||
|
// `_hasCustomSelectionPair` bypass in `UiButton.UpdateVisualState`
|
||||||
|
// is safe as-is (unconditional-when-present) without needing a
|
||||||
|
// tighter gate. If a future DAT drop introduces one, this test
|
||||||
|
// fails here rather than silently regressing that button's
|
||||||
|
// Highlight/rollover feedback.
|
||||||
|
Assert.Empty(findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sweep (c): any button with a genuine per-state label-color/outline
|
||||||
|
/// map (AP-222's mechanism, <c>ElementReader.BuildPerStateColorMap</c>/
|
||||||
|
/// <c>BuildPerStateBoolMap</c> against dat properties <c>0x1B</c>/
|
||||||
|
/// <c>0x21</c> — non-null only when the authored dat carries MORE THAN
|
||||||
|
/// ONE distinct value across states) beyond the chargen Appearance
|
||||||
|
/// spins and Town buttons this campaign already ported and gated.
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void PerStateLabelColorMap_EnumeratesEveryButtonBeyondChargen()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
|
||||||
|
var findings = new List<ButtonFinding>();
|
||||||
|
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>().OrderBy(x => x))
|
||||||
|
{
|
||||||
|
ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId);
|
||||||
|
if (tree is null) continue;
|
||||||
|
WalkButtons(layoutId, tree, findings, MatchesPerStateLabelStyleShape);
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[SWEEP-C] {findings.Count} buttons carry a genuine per-state "
|
||||||
|
+ "label color/outline map.");
|
||||||
|
foreach (ButtonFinding f in findings.OrderBy(f => f.LayoutId).ThenBy(f => f.ElementId))
|
||||||
|
Console.WriteLine($"[SWEEP-C] layout=0x{f.LayoutId:X8} element=0x{f.ElementId:X8}");
|
||||||
|
|
||||||
|
// Landmarks this campaign already ported: the nine Appearance spins
|
||||||
|
// (Hair/Eyes/Skin/Headgear/Shirt/Trousers/Footwear/Nose/Mouth, all
|
||||||
|
// sharing one Highlight-gold-brightening state pair) and the four
|
||||||
|
// Town buttons (Normal-gold -> Selected-white caption swap).
|
||||||
|
Assert.Contains(findings, f =>
|
||||||
|
f.ElementId == CharacterCreationAppearancePage.HairSpinId);
|
||||||
|
Assert.Contains(findings, f => IsTownButton(f.ElementId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Town page's four starting-area button ids
|
||||||
|
/// (<c>CharacterCreationTownPage</c>'s own private
|
||||||
|
/// <c>StartAreaByButtonId</c> keys — no public constants exist there,
|
||||||
|
/// so the literals are duplicated here).</summary>
|
||||||
|
private static bool IsTownButton(uint elementId) => elementId is
|
||||||
|
0x1000040Bu or 0x1000040Du or 0x1000040Eu or 0x1000040Fu;
|
||||||
|
|
||||||
|
// ── Shared predicates (re-derived from DatWidgetFactory.BuildButton) ──
|
||||||
|
|
||||||
|
private static bool MatchesLabelBoxShape(ElementInfo info)
|
||||||
|
{
|
||||||
|
if (info.StateMedia.Count != 0)
|
||||||
|
return false;
|
||||||
|
ElementInfo[] faces = FindStatefulFaceChildren(info);
|
||||||
|
if (faces.Length != 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// A DISTINCT lifted Type-12 caption child (not the button's own
|
||||||
|
// P0x17) — DatWidgetFactory.BuildButton's own "label is null on the
|
||||||
|
// button itself, found on a Type-12 child instead" fallback.
|
||||||
|
bool ownCaption = HasStringInfoProperty(info);
|
||||||
|
if (ownCaption)
|
||||||
|
return false;
|
||||||
|
return info.Children.Any(child => child.Type == 12u && HasStringInfoProperty(child));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesConflictingPairShape(ElementInfo info)
|
||||||
|
{
|
||||||
|
bool hasCustomPair = info.StateMedia.ContainsKey("Unselected") && info.StateMedia.ContainsKey("Selected");
|
||||||
|
bool hasStandardPair = info.StateMedia.ContainsKey("Normal") || info.StateMedia.ContainsKey("Highlight");
|
||||||
|
return hasCustomPair && hasStandardPair;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesPerStateLabelStyleShape(ElementInfo info)
|
||||||
|
{
|
||||||
|
// Mirror BuildButton's labelInfo resolution: the button's own P0x17
|
||||||
|
// if present, else the first Type-12 child with a resolvable one.
|
||||||
|
ElementInfo labelInfo = HasStringInfoProperty(info)
|
||||||
|
? info
|
||||||
|
: info.Children.FirstOrDefault(child => child.Type == 12u && HasStringInfoProperty(child)) ?? info;
|
||||||
|
|
||||||
|
return ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu) is not null
|
||||||
|
|| ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u) is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasStringInfoProperty(ElementInfo info) =>
|
||||||
|
info.TryGetEffectiveProperty(0x17u, out UiPropertyValue property)
|
||||||
|
&& property.Kind == UiPropertyKind.StringInfo;
|
||||||
|
|
||||||
|
/// <summary>Verbatim copy of <c>DatWidgetFactory.FindStatefulFaceChildren</c>
|
||||||
|
/// (private there) — a child whose media state names intersect the
|
||||||
|
/// PARENT's own declared state names.</summary>
|
||||||
|
private static ElementInfo[] FindStatefulFaceChildren(ElementInfo info) =>
|
||||||
|
[.. info.Children
|
||||||
|
.Where(child =>
|
||||||
|
child.StateMedia.Count != 0
|
||||||
|
&& child.StateMedia.Keys.Any(childState =>
|
||||||
|
info.States.Values.Any(parentState =>
|
||||||
|
string.Equals(parentState.Name, childState, StringComparison.Ordinal))))
|
||||||
|
.OrderBy(child => child.ReadOrder)];
|
||||||
|
|
||||||
|
private static void WalkButtons(
|
||||||
|
uint layoutId,
|
||||||
|
ElementInfo node,
|
||||||
|
List<ButtonFinding> findings,
|
||||||
|
Func<ElementInfo, bool> predicate)
|
||||||
|
{
|
||||||
|
if (node.Type == 1u && predicate(node))
|
||||||
|
findings.Add(new ButtonFinding(layoutId, node.Id));
|
||||||
|
|
||||||
|
foreach (ElementInfo child in node.Children)
|
||||||
|
WalkButtons(layoutId, child, findings, predicate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,36 @@ public sealed class BoundedProcessOutputCaptureTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>F7 (Campaign CC gate round 1 closeout): <c>AppendLine</c>
|
||||||
|
/// 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.</summary>
|
||||||
|
[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]
|
[Fact]
|
||||||
public void ALogSpammingChildCannotGrowTheFileUnboundedly()
|
public void ALogSpammingChildCannotGrowTheFileUnboundedly()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue