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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -997,6 +997,31 @@ internal sealed class LivePresentationCompositionPhase
// Same "both arms exist, needs a dispatcher + the retained-UI // Same "both arms exist, needs a dispatcher + the retained-UI
// viewport widget" shape as paperdoll/creature-appraisal above — // viewport widget" shape as paperdoll/creature-appraisal above —
// this is the THIRD private creature viewport, not a new pattern. // 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< CompositionAcquisitionScope.CompositionAcquisitionLease<
ChargenPreviewRenderer>? chargenPreviewLease = null; ChargenPreviewRenderer>? chargenPreviewLease = null;
ChargenPreviewController? chargenPreviewController = 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); Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated);
var envCellFrustum = new WbFrustum(); var envCellFrustum = new WbFrustum();

View file

@ -225,9 +225,24 @@ internal sealed class ChargenPreviewController :
return true; 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, options, heritageId, genderKey, selection,
_palSets, _clothingTables, out ChargenAppearanceResult result)) _palSets, _clothingTables, out result);
}
if (!composed)
{ {
return false; return false;
} }
@ -291,6 +306,14 @@ internal sealed class ChargenPreviewController :
if (_disposed) if (_disposed)
return; return;
_disposed = true; _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 renderer itself is a leased composition resource disposed by
// the composition root (mirrors PaperdollViewportRenderer — this // the composition root (mirrors PaperdollViewportRenderer — this
// class does not own its lifetime, only its per-frame drive). // 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 /// user actually sees. The same override, independently, is what every
/// other <c>gmCG3DView</c> owner does for ITS own instance: /// other <c>gmCG3DView</c> owner does for ITS own instance:
/// <c>gmCGSummaryPage::InitializePage @0x0047BD54</c> (a separate /// <c>gmCGSummaryPage::InitializePage @0x0047BD54</c> (a separate
/// viewport/page, CC5's scope, not this one) and /// viewport/page, CC5's scope, not this one) and <c>gmBarberUI</c>
/// <c>gmBarberUI::PostInit</c> (~<c>0x004DE330</c>, pushed at /// corroborate 180 TWICE, in two separate functions (fix round F4
/// <c>0x004E03B5</c>) both call the identical /// correction — the original citation here wrongly attributed both
/// <c>SetPlayerHeading(m_p3DView, 180f)</c> for their own pages. Since /// 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 /// this controller — like retail's <c>m_fCurHeading</c> — is itself the
/// PAGE-level heading owner (not the view's), matching the value every /// PAGE-level heading owner (not the view's), matching the value every
/// real page converges on before its first frame is the retail-faithful /// 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)) if (_spins.TryGetValue(Part.Mouth, out UiButton? mouthSpin))
mouthSpin.Visible = !clothesHidden; mouthSpin.Visible = !clothesHidden;
_eyesArrowsDisabled = clothesHidden; _eyesArrowsDisabled = clothesHidden;
if (clothesHidden && _currentChoice == Choice.Clothes) if (clothesHidden)
{ {
// Update forces SetChoice(ECG_CHOICE_FACE) when Clothes becomes // Fix round F3: retail's Gearknight branch
// unreachable so the page never gets stuck showing a hidden tab. // (@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; _currentChoice = Choice.Face;
_currentPart = Part.Hair; _currentPart = Part.Hair;
} }
@ -334,10 +343,87 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
{ {
if (_disposed) if (_disposed)
return; return;
NormalizeChoiceOnSelect(part);
_currentPart = part; _currentPart = part;
RefreshColorAndShadeControlsFromLatestSnapshot(); 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) private void CycleStyle(Part part, int delta)
{ {
if (_disposed) if (_disposed)
@ -378,14 +464,29 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
/// <paramref name="count"/>+1 positions (every real index, plus /// <paramref name="count"/>+1 positions (every real index, plus
/// <see cref="Unset"/> — decrementing from index 0 lands on Unset, /// <see cref="Unset"/> — decrementing from index 0 lands on Unset,
/// incrementing from Unset lands on index 0, matching /// incrementing from Unset lands on index 0, matching
/// <c>ListenToElementMessage</c>'s cases <c>6</c> exactly). Every other /// <c>ListenToElementMessage</c>'s cases <c>6</c> exactly).
/// 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 /// <para>
/// AP-214's <c>RandomizeCharacter</c>-at-open finding, which acdream /// <b>Fix round F1:</b> every OTHER style spin ALSO has a decomp-
/// does not port this round) — an Unset start there is an edge case /// observable Unset-cycling case — it lives in the same switch the
/// retail itself never reaches, so the first click either direction just /// headgear ring was ported from, at the shared decrement tail
/// starts cycling from index 0 rather than reconstructing an unfounded /// (<c>label_47f065</c>/<c>label_47f6d9</c>, reached from Hair's
/// wrap direction. /// 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> /// </summary>
internal static uint CycleIndex(uint current, int delta, int count, bool allowUnset) internal static uint CycleIndex(uint current, int delta, int count, bool allowUnset)
{ {
@ -401,7 +502,22 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
} }
if (current == Unset) 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); return (uint)Mod((int)current + delta, count);
} }
@ -455,6 +571,23 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
IRuntimeCharacterCreationView view, IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot) 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); ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart);
uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance); uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance);
for (int i = 0; i < _swatches.Length; i++) for (int i = 0; i < _swatches.Length; i++)
@ -466,7 +599,12 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);
if (_shadeScroll is null) if (_shadeScroll is null)
return; 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) if (shadeSlot is { } slot)
{ {
double shade = ShadeCurrent(slot, snapshot.Appearance); 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 /// through retail <c>gmCharGenMainUI</c>'s authored retained layout — the
/// mount + master shell (progress bar, tab strip, Back/Next/Finish/Help/ /// mount + master shell (progress bar, tab strip, Back/Next/Finish/Help/
/// Exit/Random nav) plus the Heritage/Profession/Skills/Town pages this /// Exit/Random nav) plus the Heritage/Profession/Skills/Town pages this
/// slice builds. The Appearance (<c>0x100003d4</c>) and Summary /// slice builds. Fix round F6: the Appearance (<c>0x100003d4</c>) page root
/// (<c>0x100003d6</c>) page roots are mounted but content-inert — CC6/CC5 /// is fully LIVE as of CC6b-MOUNT (<see cref="CharacterCreationAppearancePage"/>);
/// fill them (register TS-82). /// 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> /// <para>
/// Decomp anchors: root construction + child resolution /// Decomp anchors: root construction + child resolution
@ -639,10 +641,14 @@ internal sealed class CharacterCreationUiController : IDisposable
break; break;
} }
// Random (0x100003cb): retail refuses on Skills (no // Random (0x100003cb): fix round F5 — retail's DoRandom @0x004e7d70
// RandomizeSkills primitive ported — AP-212) and on Summary // case 3 fully ENABLES Random on Appearance (RandomizeClothing when
// (MakeRandomizeWarningDialog is CC5's); Appearance is this round's // m_eCurType == ECG_CHOICE_CLOTHES, else RandomizeAppearance); this
// placeholder. // 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 _random.Enabled = _currentPage
is not (Page.Skills or Page.Appearance or Page.Summary); is not (Page.Skills or Page.Appearance or Page.Summary);
// Finish stays ghosted regardless of page — Summary is a // 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 /// <summary>Campaign CC slice CC6b-MOUNT: the Appearance page's authored
/// viewport (<c>0x100003bb</c>) — null until the screen has mounted. /// 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 => internal UiViewport? ChargenPreviewViewportWidget =>
CharacterCreationController?.AppearanceViewport; CharacterCreationController?.AppearanceViewport;

View file

@ -249,6 +249,12 @@ public sealed class UiScrollbar : UiElement
return; return;
} }
if (ScalarChanged is not null)
{
DrawVerticalScalar(ctx, resolve);
return;
}
if (Model is not { } m) return; if (Model is not { } m) return;
// Track background — TILED vertically (retail DrawMode=Normal). The native track // 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> /// <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, private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h) uint id, float x, float y, float w, float h)
@ -412,8 +442,17 @@ public sealed class UiScrollbar : UiElement
if (e.Type == UiEventType.MouseMove) if (e.Type == UiEventType.MouseMove)
_hoveredButton = ButtonAt(e.Data1, e.Data2); _hoveredButton = ButtonAt(e.Data1, e.Data2);
if (Horizontal && ScalarChanged is not null) // Fix round F11: retail's chargen shade scrollbar (0x10000321) is
return OnScalarEvent(e); // 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) if (Horizontal && Model is not null)
return OnHorizontalModelEvent(e); return OnHorizontalModelEvent(e);
@ -590,14 +629,77 @@ public sealed class UiScrollbar : UiElement
return false; 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) if (resolve is not null && ThumbSprite != 0)
{ {
var (_, width, _) = resolve(ThumbSprite); var (_, width, height) = resolve(ThumbSprite);
if (width > 0) return MathF.Min(width, Width); 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) 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 /// live preview re-composes on every appearance change, and the same
/// PalSet/ClothingTable ids repeat constantly across heritages, genders, and /// PalSet/ClothingTable ids repeat constantly across heritages, genders, and
/// re-selections within one session. /// 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> /// </summary>
public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource
{ {

View file

@ -1,5 +1,6 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Tests.UI.Layout; // InstalledDatFactAttribute (fix round F13)
using AcDream.Content; using AcDream.Content;
using AcDream.Content.CharGen; using AcDream.Content.CharGen;
using AcDream.Content.Vfx; using AcDream.Content.Vfx;
@ -22,6 +23,18 @@ namespace AcDream.App.Tests.Rendering;
/// REAL dat-backed <see cref="ChargenOptions"/>/<see cref="ChargenAppearanceCatalog"/> /// REAL dat-backed <see cref="ChargenOptions"/>/<see cref="ChargenAppearanceCatalog"/>
/// so <c>ChargenAppearanceFactory.TryCompose</c> and /// so <c>ChargenAppearanceFactory.TryCompose</c> and
/// <c>ChargenPreviewEntityBuilder.TryBuildAnimated</c> actually run. /// <c>ChargenPreviewEntityBuilder.TryBuildAnimated</c> actually run.
///
/// <para>
/// Fix round F13: every case uses
/// <see cref="InstalledDatFactAttribute"/> (shared with
/// <c>CharacterCreationLiveDatTests</c>/<c>CharacterManagementLiveDatTests</c>)
/// instead of plain <c>[Fact]</c>. Before this fix, a bare <c>[Fact]</c> plus
/// <see cref="TryOpen"/>'s own <c>if (!TryOpen(...)) return;</c> guard made
/// all six cases pass SILENTLY with zero assertions run whenever
/// <c>ACDREAM_DAT_DIR</c> was unavailable — indistinguishable in the test
/// runner's summary from an actual passing run. The attribute now reports
/// those runs as Skipped so the counts show "ran" separately from "no-op'd".
/// </para>
/// </summary> /// </summary>
public sealed class ChargenPreviewControllerTests public sealed class ChargenPreviewControllerTests
{ {
@ -31,7 +44,7 @@ public sealed class ChargenPreviewControllerTests
private const uint AluvianId = 1u; private const uint AluvianId = 1u;
private const uint GearknightId = 6u; private const uint GearknightId = 6u;
[Fact] [InstalledDatFact]
public void Rebuild_SameSelectionTwice_IsANoOpSecondTime() public void Rebuild_SameSelectionTwice_IsANoOpSecondTime()
{ {
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
@ -54,7 +67,7 @@ public sealed class ChargenPreviewControllerTests
} }
} }
[Fact] [InstalledDatFact]
public void Rebuild_HeritageChange_ResetsCameraToTheNewHeritagesDefaultEye() public void Rebuild_HeritageChange_ResetsCameraToTheNewHeritagesDefaultEye()
{ {
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
@ -101,7 +114,7 @@ public sealed class ChargenPreviewControllerTests
/// changes ONLY the appearance selection (same heritage, same gender) /// changes ONLY the appearance selection (same heritage, same gender)
/// must NOT snap the camera back to the heritage default. /// must NOT snap the camera back to the heritage default.
/// </summary> /// </summary>
[Fact] [InstalledDatFact]
public void Rebuild_AppearanceOnlyChange_LeavesTheCameraUntouched() public void Rebuild_AppearanceOnlyChange_LeavesTheCameraUntouched()
{ {
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
@ -129,7 +142,7 @@ public sealed class ChargenPreviewControllerTests
} }
} }
[Fact] [InstalledDatFact]
public void Rebuild_PreservesZoomState_AcrossAnAppearanceOnlyChange() public void Rebuild_PreservesZoomState_AcrossAnAppearanceOnlyChange()
{ {
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
@ -160,7 +173,7 @@ public sealed class ChargenPreviewControllerTests
} }
} }
[Fact] [InstalledDatFact]
public void Rebuild_ThenRender_SeedsTheEntityHeadingToTheRetailDefault180Degrees() public void Rebuild_ThenRender_SeedsTheEntityHeadingToTheRetailDefault180Degrees()
{ {
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
@ -191,7 +204,7 @@ public sealed class ChargenPreviewControllerTests
} }
} }
[Fact] [InstalledDatFact]
public void Render_WhilePageInvisible_SkipsRenderAndTexturePublication() public void Render_WhilePageInvisible_SkipsRenderAndTexturePublication()
{ {
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
@ -230,6 +243,14 @@ public sealed class ChargenPreviewControllerTests
private static (ChargenOptions, ChargenAppearanceCatalog) LoadFixture(IDatReaderWriter dats) => private static (ChargenOptions, ChargenAppearanceCatalog) LoadFixture(IDatReaderWriter dats) =>
(ChargenTableReader.Load(dats), new ChargenAppearanceCatalog(dats)); (ChargenTableReader.Load(dats), new ChargenAppearanceCatalog(dats));
/// <summary>
/// Fix round F13: <see cref="InstalledDatFactAttribute"/> already gates
/// every case above this call, so <c>datDir is null</c> should not be
/// reachable in practice once a case actually runs — this stays as
/// defense-in-depth (a DAT directory that exists but a corrupt/renamed
/// <c>client_portal.dat</c> the attribute's own lighter check missed)
/// rather than a silent no-assertions pass.
/// </summary>
private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter) private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)
{ {
string? datDir = CornerFloodReplayTests.ResolveDatDir(); string? datDir = CornerFloodReplayTests.ResolveDatDir();

View file

@ -359,7 +359,17 @@ public sealed class CharacterCreationLiveDatTests
CharacterCreationAppearancePage.FootwearSpinId, CharacterCreationAppearancePage.FootwearSpinId,
}) })
{ {
AssertButton(appearanceRoot, spinId); UiButton spin = AssertButton(appearanceRoot, spinId);
// Fix round F2 item 2: the current-part highlight
// (TrySetRetailState(Normal/Highlight)) only has a visible
// effect through UiButton's ToggleBehavior branch when the
// authored spin actually sets DAT property 0x0B — measured
// (not assumed, matching this file's own discipline for the
// arrow geometry above) True for all nine spins against the
// installed EoR dat. Pinned so a future DAT revision that
// drops it shows up here instead of as a silently-dead
// highlight.
Assert.True(spin.ToggleBehavior, $"spin 0x{spinId:X8} must author ToggleBehavior for the current-part highlight to work.");
} }
// Every color-wheel-family id resolves through EXISTING // Every color-wheel-family id resolves through EXISTING
@ -367,8 +377,20 @@ public sealed class CharacterCreationLiveDatTests
// Type-3 fallback) — the risk-item-4 scouting result, pinned. // Type-3 fallback) — the risk-item-4 scouting result, pinned.
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds) foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
AssertButton(appearanceRoot, swatchId); AssertButton(appearanceRoot, swatchId);
Assert.IsType<UiScrollbar>( UiScrollbar shadeScroll = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ShadeScrollId)); UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ShadeScrollId));
// Fix round F11: measured (not assumed) against the installed EoR
// dat — the shade scrollbar (0x10000321) is authored VERTICAL
// (33x85, taller than wide). Before this fix, UiScrollbar.OnEvent
// only routed to ScalarChanged when Horizontal was true, so mouse
// input on this control never reached SetShadeFromScalar in
// production. Pinned so a future DAT revision that flips this
// orientation is caught here rather than silently reintroducing the
// dead-input bug (UiScrollbar's OnVerticalScalarEvent handles this
// orientation now, but ONLY this orientation gets exercised in
// production).
Assert.False(shadeScroll.Horizontal);
Assert.True(shadeScroll.Height > shadeScroll.Width);
Assert.IsType<UiDatElement>( Assert.IsType<UiDatElement>(
UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.GradCircleId)); UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.GradCircleId));
@ -426,6 +448,15 @@ public sealed class CharacterCreationLiveDatTests
Assert.NotNull(increment); Assert.NotNull(increment);
Assert.Equal(80f, decrement!.X); Assert.Equal(80f, decrement!.X);
Assert.Equal(127f, increment!.X); Assert.Equal(127f, increment!.X);
// Fix round F10: the arrow WIDTHS are what actually derive
// CharacterCreationAppearancePage.IncrementZoneEnd (174 =
// IncrementZoneStart 127 + this measured 47px width) — X alone
// pins the LEFT edge of each zone, not where the increment zone
// ends and the select-as-current-part body zone begins. Measured
// against the installed EoR dat: both arrows are 47px wide,
// uniformly, across all nine spins.
Assert.Equal(47f, decrement.Width);
Assert.Equal(47f, increment.Width);
} }
} }
@ -457,7 +488,7 @@ public sealed class CharacterCreationLiveDatTests
private static void AssertButton(ImportedLayout layout, uint elementId) => private static void AssertButton(ImportedLayout layout, uint elementId) =>
Assert.IsType<UiButton>(layout.FindElement(elementId)); Assert.IsType<UiButton>(layout.FindElement(elementId));
private static void AssertButton(UiElement root, uint elementId) => private static UiButton AssertButton(UiElement root, uint elementId) =>
Assert.IsType<UiButton>(UiElement.FindDescendant(root, elementId)); Assert.IsType<UiButton>(UiElement.FindDescendant(root, elementId));
private static ImportedLayout BuildSelected( private static ImportedLayout BuildSelected(

View file

@ -527,21 +527,88 @@ public sealed class CharacterCreationUiControllerTests
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
} }
/// <summary>
/// Fix round F1: this test previously asserted index 0 here, pinning a
/// doc claim ("no decomp-observable Unset-cycling case") the decomp
/// refutes. Hair's own decrement case
/// (<c>gmCGAppearancePage::ListenToElementMessage @0x0047f465-0x0047f086</c>,
/// the same shared tail the headgear ring reuses at
/// <c>label_47f065</c>/<c>label_47f6d9</c>) computes
/// <c>new = cur - 1</c> on the raw signed int32 (Unset = -1), giving
/// <c>new = -2</c>; since <c>-2 &lt; 0</c> it wraps to <c>count - 1</c>,
/// NOT 0 — see <see cref="CharacterCreationAppearancePage.CycleIndex"/>'s
/// own corrected doc. The fixture's Hair style count is 3
/// (<see cref="EnvironmentHarness.BuildOptions"/>), so the expected
/// landing index is 2.
/// </summary>
[Fact] [Fact]
public void AppearanceSpin_DecrementZoneClick_FromUnset_StartsAtStyleZero() public void AppearanceSpin_DecrementZoneClick_FromUnset_WrapsToLastStyle()
{ {
using var environment = new EnvironmentHarness(); using var environment = new EnvironmentHarness();
environment.Controller.Open(); environment.Controller.Open();
SelectAluvianMale(environment); SelectAluvianMale(environment);
// Non-headgear spins have no decomp-observable Unset-cycling case
// (retail always has a real 0-based index by the time the user can
// click — CycleIndex's own citation) — a first click from Unset in
// EITHER direction just starts cycling at style 0, not a ring.
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(100, 10); environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(100, 10);
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot); Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(2u, environment.Runtime.LastAppearanceIndex);
}
/// <summary>
/// Fix round F10: pins the SELECT zone — x=[174,200), right of the
/// increment arrow's own x=[127,174) (live-DAT-measured, both arrows
/// 47px wide — <c>CharacterCreationLiveDatTests</c>) — as a body click
/// that selects the part WITHOUT invoking another style cycle, once the
/// part already holds a real (non-Unset) index. Distinguishes the third
/// <c>OnClickAt</c> zone from the two arrow-zone tests above, which no
/// prior test isolated.
/// </summary>
[Fact]
public void AppearanceSpin_SelectZoneClick_SelectsPartWithoutChangingIndex()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// Establish a real Hair index first (increment zone, x=150).
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(150, 10);
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
int callsAfterCycle = environment.Runtime.AppearanceIndexCallCount;
// x=180 is inside [174,200) — past the increment arrow's own zone,
// still inside the 200px-wide spin — the spin's own BODY, not
// either arrow.
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(180, 10);
// Already in [0,count) — NormalizeChoiceOnSelect (F1) is a no-op,
// so the select zone must not re-invoke SetAppearanceIndex.
Assert.Equal(callsAfterCycle, environment.Runtime.AppearanceIndexCallCount);
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
}
/// <summary>
/// Fix round F1: pins the body-click normalize-and-write-back
/// (<see cref="CharacterCreationAppearancePage"/>'s own
/// <c>NormalizeChoiceOnSelect</c> doc) for the case that's actually
/// reachable in acdream — a part still Unset (AP-214 honest-blank open)
/// gets clicked in its SELECT zone (not an arrow) — wraps to
/// <c>count-1</c> and writes it back, exactly like a decrement click
/// would, even though no arrow was pressed.
/// </summary>
[Fact]
public void AppearanceSpin_SelectZoneClick_FromUnset_NormalizesToLastStyle()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// Hair starts Unset (AP-214 honest-blank). x=180 is the select
// zone, not either arrow.
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(180, 10);
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(2u, environment.Runtime.LastAppearanceIndex); // count-1, fixture has 3 hair styles.
} }
/// <summary>CharGenState::SetHeadgearStyle's decomp-derived /// <summary>CharGenState::SetHeadgearStyle's decomp-derived
@ -576,7 +643,12 @@ public sealed class CharacterCreationUiControllerTests
[InlineData(1u, -1, 3, false, 0u)] [InlineData(1u, -1, 3, false, 0u)]
[InlineData(0u, -1, 3, false, 2u)] // plain wrap backward past the start. [InlineData(0u, -1, 3, false, 2u)] // plain wrap backward past the start.
[InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, false, 0u)] [InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, false, 0u)]
[InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, false, 0u)] // Fix round F1: decrement-from-Unset wraps to count-1 (2), not 0 — the
// decomp's shared decrement tail (label_47f065/label_47f6d9) computes
// new = cur - 1 = -2 on the raw signed int32, which is < 0, so it wraps
// to count-1 exactly like headgear's own ring does for its non-Unset
// range. See CycleIndex's own corrected doc.
[InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, false, 2u)]
[InlineData(0u, -1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: 0 -> Unset. [InlineData(0u, -1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: 0 -> Unset.
[InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, true, 0u)] // headgear ring: Unset -> 0. [InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, true, 0u)] // headgear ring: Unset -> 0.
[InlineData(2u, +1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: last -> Unset. [InlineData(2u, +1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: last -> Unset.

View file

@ -103,6 +103,43 @@ public class UiScrollbarTests
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45))); Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45)));
} }
/// <summary>
/// Fix round F11 (Campaign CC CC6b-MOUNT review): retail's chargen shade
/// scrollbar (<c>0x10000321</c>) is authored VERTICAL (measured 33x85
/// against the installed dat — see
/// <c>CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport</c>),
/// but before this fix <c>UiScrollbar.OnEvent</c> only routed to
/// <c>ScalarChanged</c> when <c>Horizontal</c> was true — a vertical
/// scalar bar's clicks fell through to the Model-mode branch, which
/// returns false with no <see cref="UiScrollbar.Model"/> set, so the
/// shade control never fired in production. Mirrors
/// <see cref="HorizontalScalar_clickAndDrag_updatesNormalizedValue"/>
/// exactly, transposed onto Y/Height/Data2.
/// </summary>
[Fact]
public void VerticalScalar_clickAndDrag_updatesNormalizedValue()
{
float value = 1f;
var bar = new UiScrollbar
{
Width = 14f,
Height = 90f,
Horizontal = false,
ScalarChanged = next => value = next,
};
bar.SetScalarPosition(1f);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data2: 8)));
Assert.Equal(0f, value, 3);
Assert.Equal(0f, bar.ScalarPosition, 3);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data2: 45)));
Assert.Equal(0.5f, value, 3);
Assert.Equal(0.5f, bar.ScalarPosition, 3);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data2: 45)));
}
// ── OP5 review fix S1: the drag-end seam (IsDragging / DragCompleted) ──── // ── OP5 review fix S1: the drag-end seam (IsDragging / DragCompleted) ────
[Fact] [Fact]