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

@ -997,6 +997,31 @@ internal sealed class LivePresentationCompositionPhase
// Same "both arms exist, needs a dispatcher + the retained-UI
// viewport widget" shape as paperdoll/creature-appraisal above —
// this is the THIRD private creature viewport, not a new pattern.
//
// Fix round F8 disposition: unlike paperdoll's PaperdollViewportWidget
// (an eager, non-retryable auto-property — see that property's own
// corrected doc comment), ChargenPreviewViewportWidget is
// computed-through a coordinator (CharacterCreationUiMountCoordinator)
// that IS explicitly retryable/idempotent across frames. This
// composition pass itself runs EXACTLY ONCE, synchronously, inside
// GameWindow.OnLoad — if the coordinator's mount hasn't succeeded
// yet at this exact instant, this block is skipped and NEVER
// retried; the coordinator's own later per-frame retries (driven
// from RetailUiRuntime.Tick) can still complete the CONTROLLER mount
// afterward, but this GPU-side renderer/viewport binding will not
// pick that up. DECIDED at the review: this composition pass is a
// one-shot GPU-resource wiring step (matching paperdoll's and
// creature-appraisal's own one-shot binding in this exact method,
// and PublishLivePresentation's own "set exactly once" invariant a
// few hundred lines below) — retrofitting cross-frame retry here
// would mean restructuring this whole composition's one-shot
// contract (and the fixed PrivateEntityViewportFrameGroup array
// FrameRootComposition builds from its result) for every private
// viewport, not just this one; that is out of this fix round's
// blast radius. What changes here instead: a loud diagnostic
// instead of a silent skip, so an operator can SEE the preview
// failed to bind this session rather than the symptom (dead
// zoom/rotate buttons) reading as unexplained.
CompositionAcquisitionScope.CompositionAcquisitionLease<
ChargenPreviewRenderer>? chargenPreviewLease = null;
ChargenPreviewController? chargenPreviewController = null;
@ -1053,6 +1078,18 @@ internal sealed class LivePresentationCompositionPhase
}
});
}
else if (dispatcherLease.Resource is not null)
{
// Fix round F8: dispatcher is available but the mount coordinator
// hadn't resolved ChargenPreviewViewportWidget by this one-shot
// pass — loud instead of silent, since the coordinator's own
// later per-frame retries cannot recover this GPU-side binding
// (see this block's own disposition comment above).
Console.WriteLine(
"[UI] chargen preview viewport unavailable at composition "
+ "time — the Appearance page's zoom/rotate controls and "
+ "3D preview will not function this session.");
}
Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated);
var envCellFrustum = new WbFrustum();

View file

@ -225,9 +225,24 @@ internal sealed class ChargenPreviewController :
return true;
}
if (!ChargenAppearanceFactory.TryCompose(
// Fix round F7 (BLOCKER, CC6a's own F4 re-introduced at a new site):
// TryCompose reaches ChargenAppearanceCatalog.TryGetPalSet/
// TryGetClothingTable (_palSets/_clothingTables), which do lazy raw
// DatCollection.Get<T>() reads on first use — DatCollection is NOT
// thread-safe (feedback_phase_a1_hotfix_saga.md), and this UI-thread
// Rebuild call is the catalog's first production call site. Every
// sibling DAT read in this same method already guards with
// _datLock (see the TryBuildAnimated call just below) — this one
// must too.
bool composed;
ChargenAppearanceResult result;
lock (_datLock)
{
composed = ChargenAppearanceFactory.TryCompose(
options, heritageId, genderKey, selection,
_palSets, _clothingTables, out ChargenAppearanceResult result))
_palSets, _clothingTables, out result);
}
if (!composed)
{
return false;
}
@ -291,6 +306,14 @@ internal sealed class ChargenPreviewController :
if (_disposed)
return;
_disposed = true;
// Fix round F9: release the preview entity NOW rather than leaving
// the leased renderer holding it until the renderer's OWN disposal
// (a separate manifest entry, one step later) — this class built
// the entity via Rebuild, so it releases it on its own teardown
// instead of relying on a downstream owner to notice.
_renderer.SetPreview(null);
_animator = null;
_zoom = null;
// The renderer itself is a leased composition resource disposed by
// the composition root (mirrors PaperdollViewportRenderer — this
// class does not own its lifetime, only its per-frame drive).

View file

@ -59,10 +59,18 @@ internal sealed class ChargenPreviewRotationController
/// user actually sees. The same override, independently, is what every
/// other <c>gmCG3DView</c> owner does for ITS own instance:
/// <c>gmCGSummaryPage::InitializePage @0x0047BD54</c> (a separate
/// viewport/page, CC5's scope, not this one) and
/// <c>gmBarberUI::PostInit</c> (~<c>0x004DE330</c>, pushed at
/// <c>0x004E03B5</c>) both call the identical
/// <c>SetPlayerHeading(m_p3DView, 180f)</c> for their own pages. Since
/// viewport/page, CC5's scope, not this one) and <c>gmBarberUI</c>
/// corroborate 180 TWICE, in two separate functions (fix round F4
/// correction — the original citation here wrongly attributed both
/// writes to <c>PostInit</c>): <c>gmBarberUI::PostInit @0x004de2e0</c>
/// has its OWN <c>m_fCurHeading = 180f</c> write at <c>0x004de330</c>
/// (no push there — <c>PostInit</c> ends right after that assignment);
/// separately, <c>gmBarberUI::InitializePage @0x004e0040</c> has its OWN
/// redundant <c>m_fCurHeading = 180f</c> write at <c>0x004e03ab</c>,
/// THEN pushes it via <c>SetPlayerHeading(m_p3DView, 180f)</c> at
/// <c>0x004e03b5</c> — the address the original citation attributed to
/// <c>PostInit</c>. Two functions, both landing on 180, not one
/// function pushing from the other's write. Since
/// this controller — like retail's <c>m_fCurHeading</c> — is itself the
/// PAGE-level heading owner (not the view's), matching the value every
/// real page converges on before its first frame is the retail-faithful

View file

@ -257,10 +257,19 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
if (_spins.TryGetValue(Part.Mouth, out UiButton? mouthSpin))
mouthSpin.Visible = !clothesHidden;
_eyesArrowsDisabled = clothesHidden;
if (clothesHidden && _currentChoice == Choice.Clothes)
if (clothesHidden)
{
// Update forces SetChoice(ECG_CHOICE_FACE) when Clothes becomes
// unreachable so the page never gets stuck showing a hidden tab.
// Fix round F3: retail's Gearknight branch
// (@0x0047eac6/0x0047eacf) and Olthoi/OlthoiAcid branch
// (@0x0047ee32/0x0047ee3b) both call SetChoice(ECG_CHOICE_FACE)
// + SetSelection(ECG_PARTS_HAIR) UNCONDITIONALLY — every single
// time Update runs while the heritage hides Clothes, not only
// when the Clothes tab happened to be showing. A conditional
// gate here (checking _currentChoice == Choice.Clothes) missed
// the case where _currentPart was Nose or Mouth — both ALSO
// hidden by this same branch — while _currentChoice was still
// Face: acdream would leave the hidden Nose/Mouth part driving
// the shade control; retail always snaps back to Hair.
_currentChoice = Choice.Face;
_currentPart = Part.Hair;
}
@ -334,10 +343,87 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
{
if (_disposed)
return;
NormalizeChoiceOnSelect(part);
_currentPart = part;
RefreshColorAndShadeControlsFromLatestSnapshot();
}
/// <summary>
/// Fix round F1: ports retail's spin BODY-click normalize-and-write-back
/// — <c>gmCGAppearancePage::ListenToElementMessage</c> cases <c>0xa5</c>-
/// <c>0xa9</c> (hair/eyes/nose/mouth/skin, <c>@0x0047f04b-0x0047f1bf</c>)
/// and <c>0xab</c>-<c>0xae</c> (headgear/shirt/trousers/footwear,
/// <c>@0x0047f212-0x0047f3ac</c>) each re-clamp the part's current index
/// into <c>[0, count)</c> BEFORE selecting it as current, not just read
/// it. Retail's rule (Hair's case 0xa5 is representative,
/// <c>@0x0047f051-0x0047f081</c> plus the shared tail at
/// <c>label_47f065</c>/<c>label_47f6d9</c>): <c>cur &gt;= count -&gt; 0</c>;
/// <c>cur &lt; 0 -&gt; count-1</c>. Headgear's own case (0xab,
/// <c>@0x0047f218-0x0047f23e</c>) excludes its <c>0xffffffff</c> Unset
/// sentinel from the "cur &lt; 0" branch
/// (<c>iCurrentChoice &lt; 0 &amp;&amp; iCurrentChoice != 0xffffffff</c>),
/// so an Unset headgear survives a body click untouched; every other
/// indexed spin has no such exclusion, so an Unset (AP-214 honest-blank)
/// style wraps to <c>count-1</c> on the FIRST body click — the same
/// count-1 wrap <see cref="CycleIndex"/>'s own decrement-from-Unset fix
/// (F1's sibling finding) applies. Skin (case 0xa9,
/// <c>@0x0047f1bf-0x0047f1fb</c>) normalizes its local cache too but
/// never writes back (no <c>CharGenState</c> field for Skin — acdream:
/// no <see cref="StyleSlotFor"/> case), matching this method's no-op
/// early return for it. In acdream there is no separate UI-local cache
/// to desync from the persisted index (unlike retail's <c>m_tChoices</c>)
/// — <see cref="RuntimeCharacterCreationState.TrySetAppearanceIndex"/>
/// already rejects any out-of-range write and
/// <c>ConstrainAppearanceByGenderLocked</c> already clamps on every
/// gender change — so the ONLY reachable out-of-range case here is
/// Unset itself; the &gt;=count branch is kept for completeness/fidelity
/// with retail's own defensive shape, not because acdream can hit it.
/// </summary>
private void NormalizeChoiceOnSelect(Part part)
{
ChargenAppearanceSlot? slot = StyleSlotFor(part);
if (slot is null)
return; // Skin: retail normalizes locally but never writes back.
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is null)
return;
RuntimeCharacterCreationSnapshot snapshot = view.Snapshot;
if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender))
return;
int count = StyleCount(part, gender);
if (count <= 0)
return;
uint current = StyleCurrent(part, snapshot.Appearance);
uint normalized;
if (part == Part.Headgear)
{
// 0x0047f218/0x0047f226: cur >= count -> Unset; Unset itself
// (cur < 0 as signed int32) is explicitly excluded from the
// "cur < 0 -> count-1" branch, so it stays Unset.
if (current != Unset && current >= (uint)count)
normalized = Unset;
else
return;
}
else
{
// 0x0047f04b family: cur >= count -> 0; cur < 0 -> count-1.
// Unset (0xFFFFFFFF) reads as -1 in retail's signed int32 store,
// so it takes the "cur < 0" branch same as any other negative.
if (current != Unset && current >= (uint)count)
normalized = 0u;
else if (current == Unset)
normalized = (uint)(count - 1);
else
return;
}
_bindings.SetAppearanceIndex?.Invoke(slot.Value, normalized);
}
private void CycleStyle(Part part, int delta)
{
if (_disposed)
@ -378,14 +464,29 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
/// <paramref name="count"/>+1 positions (every real index, plus
/// <see cref="Unset"/> — decrementing from index 0 lands on Unset,
/// incrementing from Unset lands on index 0, matching
/// <c>ListenToElementMessage</c>'s cases <c>6</c> exactly). Every other
/// style spin has no decomp-observable Unset-cycling case (retail always
/// has a real 0-based index by the time the user can click — see
/// AP-214's <c>RandomizeCharacter</c>-at-open finding, which acdream
/// does not port this round) — an Unset start there is an edge case
/// retail itself never reaches, so the first click either direction just
/// starts cycling from index 0 rather than reconstructing an unfounded
/// wrap direction.
/// <c>ListenToElementMessage</c>'s cases <c>6</c> exactly).
///
/// <para>
/// <b>Fix round F1:</b> every OTHER style spin ALSO has a decomp-
/// observable Unset-cycling case — it lives in the same switch the
/// headgear ring was ported from, at the shared decrement tail
/// (<c>label_47f065</c>/<c>label_47f6d9</c>, reached from Hair's
/// decrement case <c>@0x0047f465-0x0047f486</c> and, inlined per-part,
/// from Eyes/Nose/Mouth/Shirt/Trousers/Footwear's own decrement cases
/// <c>@0x0047f491-0x0047f65c</c>): decrementing FROM Unset
/// (<c>cur=-1</c> as signed int32) computes <c>new = cur - 1 = -2</c>,
/// which is <c>&lt; 0</c>, so it wraps to <c>count - 1</c> — the SAME
/// "wrap to the last index" shape headgear's own ring uses, just without
/// headgear's extra Unset ring position. Incrementing FROM Unset
/// computes <c>new = -1 + 1 = 0</c>, which is already in
/// <c>[0, count)</c>, so it lands on style 0 — this half was already
/// correct. The prior doc here claimed "no decomp-observable
/// Unset-cycling case" and picked index 0 for BOTH directions; the
/// decomp refutes that for decrement. This matters in practice: AP-214's
/// honest-blank open leaves every non-headgear index Unset, so the
/// FIRST left-arrow click a user makes on this page hits this exact
/// path.
/// </para>
/// </summary>
internal static uint CycleIndex(uint current, int delta, int count, bool allowUnset)
{
@ -401,7 +502,22 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
}
if (current == Unset)
return 0u;
{
// Retail's per-part decrement/increment cases each recompute
// `new = cur + delta` on the RAW signed int32 (Unset = -1) and
// apply a SINGLE-STEP clamp (not a full modulo): new < 0 wraps
// to count-1, new >= count wraps to 0. Since every real caller
// only ever passes delta = -1/+1 here, evaluating that one-step
// clamp directly (rather than routing Unset through the general
// Mod() below, which assumes a valid starting index) reproduces
// retail exactly for both directions.
int fromUnset = -1 + delta;
if (fromUnset < 0)
return (uint)(count - 1);
if (fromUnset >= count)
return 0u;
return (uint)fromUnset;
}
return (uint)Mod((int)current + delta, count);
}
@ -455,6 +571,23 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
// Fix round F2 item 2: gmCGAppearancePage::SetSelection
// @0x0047e260 resets the PREVIOUS current-part spin to state 1
// (@0x0047e306, this->m_pCurSelection->vtable->SetState(1)) and sets
// the NEW one to state 6 (@0x0047e837,
// this->m_pCurSelection->vtable->SetState(6)) — a literal highlight
// toggle. UiButtonStateMachine.Normal/Highlight are already retail's
// own numeric ids 1/6 (see that class); IUiDatStateful.TrySetRetailState
// is the established seam for pushing a raw retail state id
// (CharacterCreationUiController.SetMasterPageState's own pattern).
foreach ((Part spinPart, UiButton spin) in _spins)
{
spin.TrySetRetailState(
spinPart == _currentPart
? UiButtonStateMachine.Highlight
: UiButtonStateMachine.Normal);
}
ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart);
uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance);
for (int i = 0; i < _swatches.Length; i++)
@ -466,7 +599,12 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);
if (_shadeScroll is null)
return;
_shadeScroll.Enabled = shadeSlot is not null;
// Fix round F2 item 3: gmCGAppearancePage::SetSelection HIDES the
// shade scrollbar for Eyes (@0x0047e862, SetVisible(0) — Eyes has no
// shade case in SetShade at all) and shows it otherwise
// (@0x0047e878, SetVisible(1)) — retail never DISABLES it, it
// removes it from the layout entirely.
_shadeScroll.Visible = shadeSlot is not null;
if (shadeSlot is { } slot)
{
double shade = ShadeCurrent(slot, snapshot.Appearance);

View file

@ -50,9 +50,11 @@ public sealed record CharacterCreationRuntimeBindings(
/// through retail <c>gmCharGenMainUI</c>'s authored retained layout — the
/// mount + master shell (progress bar, tab strip, Back/Next/Finish/Help/
/// Exit/Random nav) plus the Heritage/Profession/Skills/Town pages this
/// slice builds. The Appearance (<c>0x100003d4</c>) and Summary
/// (<c>0x100003d6</c>) page roots are mounted but content-inert — CC6/CC5
/// fill them (register TS-82).
/// slice builds. Fix round F6: the Appearance (<c>0x100003d4</c>) page root
/// is fully LIVE as of CC6b-MOUNT (<see cref="CharacterCreationAppearancePage"/>);
/// only the Summary (<c>0x100003d6</c>) page root remains mounted but
/// content-inert — CC5 fills it (register TS-82, narrowed to Summary-only
/// at CC6b-MOUNT).
///
/// <para>
/// Decomp anchors: root construction + child resolution
@ -639,10 +641,14 @@ internal sealed class CharacterCreationUiController : IDisposable
break;
}
// Random (0x100003cb): retail refuses on Skills (no
// RandomizeSkills primitive ported — AP-212) and on Summary
// (MakeRandomizeWarningDialog is CC5's); Appearance is this round's
// placeholder.
// Random (0x100003cb): fix round F5 — retail's DoRandom @0x004e7d70
// case 3 fully ENABLES Random on Appearance (RandomizeClothing when
// m_eCurType == ECG_CHOICE_CLOTHES, else RandomizeAppearance); this
// is NOT a placeholder gap the way the old comment claimed. The
// disable here rests on the SAME unported-primitive gap AP-212
// tracks for Skills (no RandomizeSkills) and Summary (no
// RandomizeCharacter) — RandomizeAppearance/RandomizeClothing are
// two more of AP-212's six named-but-unported primitives.
_random.Enabled = _currentPage
is not (Page.Skills or Page.Appearance or Page.Summary);
// Finish stays ghosted regardless of page — Summary is a

View file

@ -624,8 +624,30 @@ public sealed class RetailUiRuntime : IDisposable
/// <summary>Campaign CC slice CC6b-MOUNT: the Appearance page's authored
/// viewport (<c>0x100003bb</c>) — null until the screen has mounted.
/// Mirrors <see cref="PaperdollViewportWidget"/>'s own computed-through
/// shape.</summary>
///
/// <para>
/// Fix round F8 correction: this is NOT the same shape as
/// <see cref="PaperdollViewportWidget"/> — that one is a plain
/// <c>{ get; private set; }</c> auto-property assigned exactly once,
/// eagerly and non-retryably, inside <c>MountInventory()</c> (itself
/// called synchronously from <c>Initialize()</c>; if it fails the whole
/// <see cref="Mount"/> call throws and the WHOLE UI runtime fails to
/// construct — there is no partial-failure case where
/// <see cref="PaperdollViewportWidget"/> stays null while the rest of
/// the runtime comes up). THIS property is computed-through specifically
/// BECAUSE its underlying mount, <c>_characterCreationMount</c>
/// (<see cref="CharacterCreationUiMountCoordinator"/>), is explicitly
/// retryable/idempotent — ticked once per frame via <see cref="Tick"/>
/// until it succeeds, tolerating a DAT/resource read that isn't ready
/// yet without failing the rest of the UI. <see cref="AcDream.App.Composition.LivePresentationComposition"/>
/// reads this property EXACTLY ONCE, during the single synchronous
/// startup composition pass (<c>GameWindow.OnLoad</c>) — unlike the
/// coordinator's own per-frame <c>Tick</c>, that one-shot GPU-resource
/// composition pass is NOT retried, matching every other private
/// viewport binding in that same method (paperdoll, creature appraisal)
/// — see that call site's own comment for the full disposition.
/// </para>
/// </summary>
internal UiViewport? ChargenPreviewViewportWidget =>
CharacterCreationController?.AppearanceViewport;

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)

View file

@ -17,6 +17,21 @@ namespace AcDream.Content.CharGen;
/// live preview re-composes on every appearance change, and the same
/// PalSet/ClothingTable ids repeat constantly across heritages, genders, and
/// re-selections within one session.
///
/// <para>
/// <b>NOT thread-safe on its own (fix round F7, CC6b-MOUNT review):</b>
/// <see cref="TryGetPalSet"/>/<see cref="TryGetClothingTable"/> do a lazy raw
/// <c>_dats.Get&lt;T&gt;()</c> read on first use per id — and the shared
/// <c>DatCollection</c> every sibling in this codebase guards with the
/// process-wide DAT lock is itself NOT thread-safe
/// (<c>feedback_phase_a1_hotfix_saga.md</c>). Every call site MUST hold that
/// same lock (<c>ChargenPreviewController</c>'s <c>_datLock</c>, the
/// composition root's <c>d.DatLock</c>) around calls into this class, exactly
/// like every other DAT-touching call in this codebase already does. This
/// class's own <see cref="ConcurrentDictionary{TKey,TValue}"/> caches only
/// protect the CACHE from concurrent mutation — they do nothing for the
/// underlying <c>DatCollection</c> read the cache miss triggers.
/// </para>
/// </summary>
public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource
{