fix(chargen): Campaign CC CC6b-MOUNT review fix round — F1-F13

Fixes every finding from the dual-lens review of 34c6fceab0 (architectural
PASS-with-items, retail-fidelity FAIL). Re-derived every decomp citation
against docs/research/named-retail/acclient_2013_pseudo_c.txt directly
rather than trusting the reviewer's transcription.

Wrap/normalize semantics (F1): CycleIndex's decrement-from-Unset landed on
0; the decomp's shared decrement tail (label_47f065/label_47f6d9, the same
switch the headgear ring was ported from) computes new=cur-1=-2 on the raw
signed int32, which wraps to count-1 — matching headgear's own ring shape.
Also ports the spin body-click normalize-and-write-back retail's cases
0xa5-0xae all share (NormalizeChoiceOnSelect), which acdream had dropped
entirely. Flips the one test that pinned the wrong expectation and adds
select-zone coverage no prior test isolated.

Heritage gate (F3): Update's Gearknight/Olthoi/OlthoiAcid branches reset
SetChoice(FACE)/SetSelection(HAIR) unconditionally, not only when Clothes
was showing — a conditional gate stranded Nose/Mouth as the current part
under a Face-tab session.

Doc corrections propagated everywhere they repeated (F4, F5, F6, plus the
plan doc's own CC6b-MOUNT ledger row for F1/F3): the gmBarberUI heading
citation conflated PostInit with InitializePage; Random's Appearance
disable was mislabeled a placeholder when it's really AP-212's unported
RandomizeAppearance/RandomizeClothing gap; the master-page doc still called
the Appearance page content-inert after this campaign made it real.

Visual substitutions widened (F2): AP-215 named only two of the Appearance
page's swatch/spin substitutions. Ports the two cheap ones directly —
current-part highlight via SetSelection's SetState(1)/SetState(6), routed
through the existing UiButtonStateMachine.Normal/Highlight ids and
IUiDatStateful.TrySetRetailState seam (installed-DAT-confirmed
ToggleBehavior=true on all nine spins); the shade scrollbar's SetVisible(0)
for Eyes vs acdream's Enabled=false. Files the other five (DoColorSpots,
the inert GradCircle, spin-caption/heritage-caption loss, the Skin-spin
MoveTo reposition, the Gearknight-boundary randomize calls) as new register
rows AP-216..AP-220 and corrects the plan doc's false claim that AP-215
already named the GradCircle.

Unlocked DAT read (F7, BLOCKER): ChargenPreviewController.Rebuild called
ChargenAppearanceFactory.TryCompose outside _datLock while the very next
line correctly locked TryBuildAnimated — CC6a's own F4 class of bug,
reintroduced at this catalog's first production call site. Wrapped in the
same lock; documented the invariant on ChargenAppearanceCatalog itself.

One-shot preview mount (F8): LivePresentationComposition reads
ChargenPreviewViewportWidget once, but its underlying mount
(CharacterCreationUiMountCoordinator) is explicitly retryable while this
GPU-resource composition pass is not — unlike PaperdollViewportWidget,
which IS eager/non-retryable, so the "mirrors Paperdoll" doc claim was
false. Retrofitting cross-frame retry here would mean restructuring this
composition's one-shot contract for every private viewport (paperdoll,
creature appraisal) and FrameRootComposition's fixed frame-group array —
out of this round's blast radius. Corrected the doc and made the failure
loud (a diagnostic log) instead of silent.

Dispose leak (F9): ChargenPreviewController.Dispose left the preview
WorldEntity referenced by the leased renderer until the renderer's own,
later disposal. Releases it on its own teardown now.

Test-quality items (F10, F11, F13): pinned the spin arrow widths
(47px, both arrows) the 174 zone boundary is derived from, plus a
controller test for the previously-uncovered select zone. Measured the
shade scrollbar's authored orientation instead of assuming it — it is
VERTICAL (33x85) — which is a real production bug: UiScrollbar only routed
scalar-mode mouse events when Horizontal was true, so the shade control
never fired in production. Added OnVerticalScalarEvent/DrawVerticalScalar
mirroring the existing horizontal scalar path. Converted
ChargenPreviewControllerTests from silent-pass [Fact] to the shared
InstalledDatFactAttribute skip-reporting pattern.

Adjudication (F12): AD-101's retirement leaves TryBeginFinish's four local
refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) with no
heritage/gender gate — currently latent since Finish stays hard-disabled
this round. Amended the campaign plan's CC5 slice scope to require BOTH a
heritage/gender refusal AND a real RandomizeCharacter port before the
connected user gate opens Finish; noted the interaction on AP-214's own
register row. No CC5 implementation in this commit.

Gates: dotnet build -c Release green across the full solution. App suite
(Release, ACDREAM_PROBE_LIVE_MOUNT=1) 5223/3 skips, Runtime suite
1713/0 — both clean across repeated runs. A full-solution run surfaced
three pre-existing, previously-documented flakes unrelated to this change
(Streaming.LandblockBuildFactoryTests/LandblockPresentationPipelineTests
#402, Core.Net.Tests.NakEmissionTests loss soak) — each confirmed passing
in isolation, consistent with their known full-suite-parallelism-timing
history; none touch any file this commit changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 22:19:02 +02:00
parent 34c6fceab0
commit d2a71152d2
14 changed files with 571 additions and 54 deletions

View file

@ -249,6 +249,12 @@ public sealed class UiScrollbar : UiElement
return;
}
if (ScalarChanged is not null)
{
DrawVerticalScalar(ctx, resolve);
return;
}
if (Model is not { } m) return;
// Track background — TILED vertically (retail DrawMode=Normal). The native track
@ -313,6 +319,30 @@ public sealed class UiScrollbar : UiElement
}
}
/// <summary>
/// Fix round F11 (Campaign CC CC6b-MOUNT review): the mirror-image
/// counterpart of the horizontal scalar draw block above, for scalar-mode
/// bars authored VERTICAL (taller than wide) — retail's chargen shade
/// scrollbar (<c>0x10000321</c>) is one, measured against the installed
/// EoR dat (<c>Width=33 Height=85</c>). Retail's own
/// <c>UIElement_Scrollbar</c> is one class handling both a model-driven
/// list scroll and a scalar-value slider on EITHER axis; this class only
/// had the horizontal half of the scalar shape before this fix, so a
/// vertically-authored scalar bar (like the shade control) drew nothing
/// scalar-specific and fell through to the model-mode branch below,
/// which requires a <see cref="UiScrollable"/> <see cref="Model"/> a
/// scalar-mode bar never has.
/// </summary>
private void DrawVerticalScalar(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve)
{
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
float thumbHeight = ScalarThumbExtent(resolve, Height);
float travel = MathF.Max(0f, Height - thumbHeight);
float y = travel * ScalarPosition;
DrawSprite(ctx, resolve, ThumbSprite, 0f, y, Width, thumbHeight);
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
@ -412,8 +442,17 @@ public sealed class UiScrollbar : UiElement
if (e.Type == UiEventType.MouseMove)
_hoveredButton = ButtonAt(e.Data1, e.Data2);
if (Horizontal && ScalarChanged is not null)
return OnScalarEvent(e);
// Fix round F11: retail's chargen shade scrollbar (0x10000321) is
// authored VERTICAL (measured against the installed dat), but a
// scalar-mode bar (ScalarChanged set, no Model) has always been
// possible on either axis in retail's own UIElement_Scrollbar.
// Gating this dispatch on Horizontal silently dropped every mouse
// event for a vertical scalar bar — it fell through the Horizontal
// Model branch below too, then hit "Model is not {} m => return
// false" since a scalar bar has no Model, so NOTHING ever routed to
// ScalarChanged in production for this orientation.
if (ScalarChanged is not null)
return Horizontal ? OnScalarEvent(e) : OnVerticalScalarEvent(e);
if (Horizontal && Model is not null)
return OnHorizontalModelEvent(e);
@ -590,14 +629,77 @@ public sealed class UiScrollbar : UiElement
return false;
}
private float ScalarThumbWidth(Func<uint, (uint tex, int w, int h)>? resolve)
/// <summary>F11: the vertical mirror of <see cref="OnScalarEvent"/> —
/// same click-thumb-to-drag / click-track-to-jump shape, along Y/Height
/// instead of X/Width. Reuses <see cref="_dragOffsetY"/> (otherwise only
/// touched by the vertical MODEL-mode drag, mutually exclusive with
/// scalar mode on one instance) rather than adding a third offset field.
/// </summary>
private bool OnVerticalScalarEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.MouseDown:
{
float thumbHeight = ScalarThumbExtent(SpriteResolve, Height);
float travel = MathF.Max(1f, Height - thumbHeight);
float thumbY = travel * ScalarPosition;
float y = e.Data2;
// OP5 re-check R2 (mirrored from OnScalarEvent): latch
// before the jump so the jump's own tick defers its flush
// to MouseUp's DragCompleted.
_draggingThumb = true;
if (y >= thumbY && y <= thumbY + thumbHeight)
{
_dragOffsetY = y - thumbY;
}
else
{
_dragOffsetY = thumbHeight * 0.5f;
ChangeScalarPosition((y - _dragOffsetY) / travel);
}
return true;
}
case UiEventType.MouseMove when _draggingThumb:
{
float thumbHeight = ScalarThumbExtent(SpriteResolve, Height);
float travel = MathF.Max(1f, Height - thumbHeight);
ChangeScalarPosition(((float)e.Data2 - _dragOffsetY) / travel);
return true;
}
case UiEventType.MouseUp:
{
bool wasDragging = _draggingThumb;
_draggingThumb = false;
_pressedButton = EndButton.None;
if (wasDragging) DragCompleted?.Invoke();
return true;
}
}
return false;
}
private float ScalarThumbWidth(Func<uint, (uint tex, int w, int h)>? resolve) =>
ScalarThumbExtent(resolve, Width);
/// <summary>F11: generalized over <see cref="ScalarThumbWidth"/> so
/// <see cref="DrawVerticalScalar"/> can size the thumb along the
/// authored axis (native sprite width for a horizontal bar, native
/// sprite height for a vertical one) instead of assuming horizontal.
/// </summary>
private float ScalarThumbExtent(
Func<uint, (uint tex, int w, int h)>? resolve, float axisLength)
{
if (resolve is not null && ThumbSprite != 0)
{
var (_, width, _) = resolve(ThumbSprite);
if (width > 0) return MathF.Min(width, Width);
var (_, width, height) = resolve(ThumbSprite);
int native = Horizontal ? width : height;
if (native > 0) return MathF.Min(native, axisLength);
}
return MathF.Min(16f, Width);
return MathF.Min(16f, axisLength);
}
private void ChangeScalarPosition(float position)