feat(chargen): Campaign CC slice CC5 — Summary page, Finish flow, RandomizeCharacter port

Fills TS-82's Summary placeholder with a faithful port of gmCGSummaryPage
(name field with NameInputFilter + the retail commit-on-focus-lost/submit
dispatch + the >32-char ID_CharGen_NameTooLong reject-and-revert path, the
REAL three-row-template listbox confirmed against the installed EoR dat
before writing any page code, and Summary's own independent gmCG3DView
preview instance wired through a second ChargenPreviewController pair
mirroring the Appearance page's exact composition shape).

Ports CharGenState::RandomizeCharacter and its six sub-primitives into
RuntimeCharacterCreationState — not approximated: the RandInt/RollDice
semantics are independently confirmed from both the decompiled RNG bodies
and the CharGenStateVtbl union struct in acclient.h. Three consumers:
the chargen screen's open-roll (retiring AP-214's honest-blank deviation
and reproducing the Appearance page's gender-flip-on-init quirk), the
Summary page's Random button (behind the retail randomize-warning
confirm), and the Appearance page's Random button (narrowing AP-212 to
just Heritage/Profession/Town's still-approximated rolls and Skills'
still-unported RandomizeSkills).

Wires the Finish button (previously ghosted) with retail's NoName/
CreditWarning dialog pair, adds the F12 amendment's HeritageOrGenderUnset
local refusal to TryBeginFinish (register AP-223) as a defensive backstop
now that the screen-open roll normally makes it unreachable, and wires
the four ID_Character_Err_* rejection dialogs for the 0xF643 response
codes CC3 already parsed but nothing displayed.

Register: TS-82 retired, AP-214 retired, AP-212 narrowed, AP-223/224/225
filed (heritage/gender Finish refusal, Summary's two-bucket skill-list
narrowing, the 32-vs-33 name-length threshold reconciliation).

Runtime 1722/0 (was 1713), App 5240/3 skips (was 5223/3), Headless 166/0
unchanged, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 00:01:07 +02:00
parent 6114b2dda2
commit 34e3a534be
22 changed files with 2217 additions and 79 deletions

View file

@ -1657,6 +1657,45 @@ public sealed class LiveSessionController
}
}
public RuntimeCommandResult RandomizeCharacter(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TryRandomizeCharacter());
}
}
public RuntimeCommandResult RandomizeAppearance(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TryRandomizeAppearance());
}
}
public RuntimeCommandResult RandomizeClothing(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TryRandomizeClothing());
}
}
private RuntimeCommandStatus ValidateCharacterCreationCommand(
RuntimeGenerationToken expectedGeneration)
{

View file

@ -124,16 +124,32 @@ public readonly record struct RuntimeCharacterCreationAppearance(
/// evaluates before a Finish click is allowed to reach the wire, plus the
/// campaign's client-side slot cap (risk item 3 — retail's UI, not
/// <c>DoFinish</c> itself, refuses when the roster is already full versus
/// <c>CharacterList.slotCount</c>; ACE never checks this server-side).
/// <c>CharacterList.slotCount</c>; ACE never checks this server-side), plus
/// (Campaign CC slice CC5, the CC6b-MOUNT review's F12 amendment)
/// <see cref="HeritageOrGenderUnset"/> — an acdream-ONLY addition with no
/// direct <c>DoFinish</c> citation (register AP-223): retail's own
/// <c>DoFinish</c> never checks heritage/gender because it can't reach a
/// state where either is unset — <c>gmCharGenMainUI</c>'s constructor calls
/// <c>CharGenState::RandomizeCharacter</c> before any page (including
/// Summary/Finish) exists, so a real heritage+gender selection is an
/// ARCHITECTURAL guarantee by the time Finish is clickable at all. Once
/// <see cref="RuntimeCharacterCreationState.TryRandomizeCharacter"/> is
/// wired at the App layer's screen-open edge (mirroring that same ctor
/// call), this refusal is normally unreachable through the ordinary UI —
/// it exists as a defensive backstop for any caller (a headless bot, a
/// future direct command) that can reach <c>Finish</c> without that
/// open-edge roll ever having run.
/// </summary>
public readonly record struct RuntimeCharacterCreationLocalRefusal(
bool NoName,
bool AttributeCreditsUnspent,
bool AlreadyPending,
bool RosterFull)
bool RosterFull,
bool HeritageOrGenderUnset = false)
{
public bool Any =>
NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull;
NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull
|| HeritageOrGenderUnset;
public static RuntimeCharacterCreationLocalRefusal None { get; } = default;
}
@ -487,27 +503,38 @@ public sealed class RuntimeCharacterCreationState : IDisposable
if (_disposed || !_active)
return false;
_heritageId = heritageId;
_totalAttributeCredits = heritage.AttributeCredits;
_totalSkillCredits = heritage.SkillCredits;
_remainingSkillCredits = checked((int)heritage.SkillCredits);
ApplyTemplateLocked(heritage);
RandomizeStartAreaLocked(heritage);
ConstrainAppearanceByGenderLocked();
RecomputeRemainingAttributeCreditsLocked();
// ConstrainAllByHeritage's UpdateRemainingSkillCredits + defensive
// re-reset (0x005C66D2/0x005C66DD) — cheap and unreachable through
// our own gated skill commands, but kept for parity with a
// heritage switch that leaves stale skill picks over-budget.
if (RecomputeSkillSpendLocked(heritage) < 0)
ResetSkillLevelsLocked(heritage);
SetHeritageGroupLocked(heritageId, heritage);
_revision++;
}
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
return true;
}
/// <summary>
/// The locked body of <c>CharGenState::SetHeritageGroup @ 0x005C67A0</c> —
/// factored out of <see cref="TrySelectHeritage"/> (Campaign CC slice
/// CC5) so <see cref="RandomizeCharacterLocked"/>'s own heritage roll
/// can reuse it without re-entering <see cref="_gate"/>.
/// </summary>
private void SetHeritageGroupLocked(uint heritageId, ChargenHeritageOptions heritage)
{
_heritageId = heritageId;
_totalAttributeCredits = heritage.AttributeCredits;
_totalSkillCredits = heritage.SkillCredits;
_remainingSkillCredits = checked((int)heritage.SkillCredits);
ApplyTemplateLocked(heritage);
RandomizeStartAreaLocked(heritage);
ConstrainAppearanceByGenderLocked();
RecomputeRemainingAttributeCreditsLocked();
// ConstrainAllByHeritage's UpdateRemainingSkillCredits + defensive
// re-reset (0x005C66D2/0x005C66DD) — cheap and unreachable through
// our own gated skill commands, but kept for parity with a
// heritage switch that leaves stale skill picks over-budget.
if (RecomputeSkillSpendLocked(heritage) < 0)
ResetSkillLevelsLocked(heritage);
}
/// <summary>Ports <c>CharGenState::SetGender @ 0x005C64A0</c>: clamps
/// every appearance index into the new gender's option-list bounds. The
/// four <c>SetXStyle(this, this-&gt;XStyle)</c> re-invocations retail
@ -527,14 +554,23 @@ public sealed class RuntimeCharacterCreationState : IDisposable
return false;
}
_genderKey = genderKey;
ConstrainAppearanceByGenderLocked();
SetGenderLocked(genderKey);
_revision++;
}
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
return true;
}
/// <summary>The locked body of <c>CharGenState::SetGender @
/// 0x005C64A0</c> — factored out of <see cref="TrySelectGender"/>
/// (Campaign CC slice CC5) so <see cref="RandomizeCharacterLocked"/>'s
/// own gender roll can reuse it.</summary>
private void SetGenderLocked(uint genderKey)
{
_genderKey = genderKey;
ConstrainAppearanceByGenderLocked();
}
/// <summary>
/// Ports the seven Profession-page buttons, each of which calls
/// <c>CharGenState::SetTemplate(state, N, 1) @ 0x005C5A60</c> — template
@ -1164,6 +1200,362 @@ public sealed class RuntimeCharacterCreationState : IDisposable
: value;
}
// ── Randomize (Campaign CC slice CC5) ───────────────────────────────
// Ports CharGenState::RandomizeCharacter @ 0x005c6d80 and its six named
// sub-primitives (RandomizeHeritageGroup/RandomizeAppearance/
// RandomizeHeadgear/RandomizeShirt/RandomizeTrousers/RandomizeFootwear/
// RandomizeTemplate/RandomizeStartArea — register AP-212's own citation
// list). The RNG primitive both retail's own RandInt(int) and
// RandInt(int,int) overloads reduce to is decompiled verbatim at
// 0x00684400/0x00684420: RandInt(count) is a uniform pick in [0,count);
// RandInt(count,exclude) loops the same roll until it differs from
// exclude (a no-op when count<=1, matching retail's own early-return —
// ported as RandomizeIndexExcludingLocked below). CharGenState's own
// vtable (acclient.h's $A0F97670E669114D706A75D718F5A366 union —
// "GetRandomInt(this,int,int)"/"Grandom Int(this,int)") confirms
// RandomizeAppearance's vtable-indirected calls are this SAME RandInt
// pair, not a distinct algorithm.
/// <summary>Ports <c>Random::RollDice(int,int) @ 0x0042c5c0</c>: returns
/// <paramref name="min"/> unchanged when the two bounds are equal
/// (matching retail's own <c>arg2==arg1</c> fast path), otherwise a
/// uniform pick over the INCLUSIVE range
/// <c>[min(min,max), max(min,max)]</c>.</summary>
private int RollDiceLocked(int min, int max)
{
if (min == max)
return min;
int lo = Math.Min(min, max);
int hi = Math.Max(min, max);
return lo + _random.Next(hi - lo + 1);
}
/// <summary>Ports <c>RandInt(int,int) @ 0x00684420</c> exactly: for
/// <paramref name="count"/> &lt;= 1 there is only one possible outcome,
/// so retail returns 0 immediately WITHOUT ever comparing against
/// <paramref name="exclude"/> (the guard that keeps the do/while loop
/// below from spinning forever); otherwise re-rolls uniformly in
/// <c>[0,count)</c> until the result differs from
/// <paramref name="exclude"/> — an <paramref name="exclude"/> outside
/// <c>[0,count)</c> (e.g. <see cref="RuntimeCharacterCreationAppearance.Unset"/>
/// on a freshly-<see cref="ClearSessionState"/>'d field) can never match,
/// so the loop always exits on its first iteration and this degrades to
/// a plain uniform pick.</summary>
private uint RandomizeIndexExcludingLocked(int count, uint exclude)
{
if (count <= 1)
return 0u;
uint result;
do
{
result = (uint)_random.Next(count);
} while (result == exclude);
return result;
}
/// <summary>Ports <c>CharGenState::RandomizeAppearance(this, 0) @
/// 0x005c4f10</c> — every real call site in the retail binary passes
/// <c>arg2 == 0</c> (an exhaustive grep of every <c>RandomizeAppearance</c>
/// call found none with <c>arg2 != 0</c>), so the <c>arg2 != 0</c> arm
/// (a hard-coded vtable-index-7 hair-style pick) is decompiled but dead
/// code and is not ported. Each field is only rolled when its list is
/// non-empty (retail's own per-field <c>if (count != 0)</c> guards);
/// <c>skinShade</c>/<c>hairShade</c> are <c>vtable-&gt;GetRandomReal()</c>
/// — the SAME <c>rand()*(1/32768)</c> uniform-[0,1) shade roll every
/// other Randomize* function below uses explicitly inline.</summary>
private void RandomizeAppearanceLocked()
{
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
return;
RuntimeCharacterCreationAppearance a = _appearance;
if (gender.EyeStrips.Count > 0)
a = a with { EyesStrip = RandomizeIndexExcludingLocked(gender.EyeStrips.Count, a.EyesStrip) };
if (gender.NoseStrips.Count > 0)
a = a with { NoseStrip = RandomizeIndexExcludingLocked(gender.NoseStrips.Count, a.NoseStrip) };
if (gender.MouthStrips.Count > 0)
a = a with { MouthStrip = RandomizeIndexExcludingLocked(gender.MouthStrips.Count, a.MouthStrip) };
a = a with { SkinShade = _random.NextDouble(), HairShade = _random.NextDouble() };
if (gender.HairColors.Count > 0)
a = a with { HairColor = RandomizeIndexExcludingLocked(gender.HairColors.Count, a.HairColor) };
if (gender.EyeColors.Count > 0)
a = a with { EyeColor = RandomizeIndexExcludingLocked(gender.EyeColors.Count, a.EyeColor) };
if (gender.HairStyles.Count > 0)
a = a with { HairStyle = RandomizeIndexExcludingLocked(gender.HairStyles.Count, a.HairStyle) };
_appearance = a;
}
/// <summary>
/// Ports <c>CharGenState::RandomizeHeadgear(this, arg2) @ 0x005c5e10</c>.
/// Headgear alone gets the <c>count+1</c>-position Unset ring
/// (<c>CharacterCreationAppearancePage.CycleIndex</c>'s own already-cited
/// sibling finding): <paramref name="excludeCurrent"/> false (every
/// <c>RandomizeCharacter</c> call site, <c>arg2==0</c>) rolls a plain
/// uniform <c>RandInt(count+1)</c>; true (<c>RandomizeClothing(state,1)</c>'s
/// own Appearance-page Random-button case) excludes the current style
/// via <c>RandInt(count+1, headgearStyle+1)</c> — the <c>+1</c>
/// reindexes Unset (retail's signed <c>-1</c>) to <c>0</c> so the
/// exclude comparison stays in <c>[0,count]</c>. Color uses the SAME
/// <see cref="AppearanceSlotCountLocked"/> shared-<c>ClothingColors</c>
/// approximation (register AP-208) every other clothing slot's color
/// count already uses, not retail's own per-heritage
/// <c>numHeadgearColors</c> field acdream's model does not carry.
/// </summary>
private void RandomizeHeadgearLocked(bool excludeCurrent)
{
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
return;
int styleCount = gender.Headgears.Count;
if (styleCount > 0)
{
uint current = _appearance.HeadgearStyle;
int currentPlusOne = current == RuntimeCharacterCreationAppearance.Unset
? 0
: (int)current + 1;
int rolled = excludeCurrent
? (int)RandomizeIndexExcludingLocked(styleCount + 1, (uint)currentPlusOne)
: _random.Next(styleCount + 1);
uint newStyle = rolled == 0 ? RuntimeCharacterCreationAppearance.Unset : (uint)(rolled - 1);
_appearance = _appearance with { HeadgearStyle = newStyle };
}
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.HeadgearColor, gender);
if (colorCount > 0)
{
_appearance = _appearance with
{
HeadgearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.HeadgearColor),
};
}
_appearance = _appearance with { HeadgearShade = _random.NextDouble() };
}
/// <summary>Ports <c>CharGenState::RandomizeShirt @ 0x005c5ef0</c> —
/// unlike headgear, retail's shirt/trousers/footwear randomizers take no
/// <c>arg2</c> and always exclude the current style/color.</summary>
private void RandomizeShirtLocked()
{
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
return;
int styleCount = gender.Shirts.Count;
if (styleCount > 0)
{
_appearance = _appearance with
{
ShirtStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.ShirtStyle),
};
}
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.ShirtColor, gender);
if (colorCount > 0)
{
_appearance = _appearance with
{
ShirtColor = RandomizeIndexExcludingLocked(colorCount, _appearance.ShirtColor),
};
}
_appearance = _appearance with { ShirtShade = _random.NextDouble() };
}
/// <summary>Ports <c>CharGenState::RandomizeTrousers @ 0x005c5fb0</c>.</summary>
private void RandomizeTrousersLocked()
{
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
return;
int styleCount = gender.Pants.Count;
if (styleCount > 0)
{
_appearance = _appearance with
{
TrousersStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.TrousersStyle),
};
}
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.TrousersColor, gender);
if (colorCount > 0)
{
_appearance = _appearance with
{
TrousersColor = RandomizeIndexExcludingLocked(colorCount, _appearance.TrousersColor),
};
}
_appearance = _appearance with { TrousersShade = _random.NextDouble() };
}
/// <summary>Ports <c>CharGenState::RandomizeFootwear @ 0x005c6070</c>.</summary>
private void RandomizeFootwearLocked()
{
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
return;
int styleCount = gender.Footwear.Count;
if (styleCount > 0)
{
_appearance = _appearance with
{
FootwearStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.FootwearStyle),
};
}
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.FootwearColor, gender);
if (colorCount > 0)
{
_appearance = _appearance with
{
FootwearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.FootwearColor),
};
}
_appearance = _appearance with { FootwearShade = _random.NextDouble() };
}
/// <summary>Ports <c>CharGenState::RandomizeClothing(this, arg2) @
/// 0x005c6770</c>: headgear (with <paramref name="excludeCurrent"/>
/// forwarded), then shirt/trousers/footwear (always exclude-current,
/// they take no <c>arg2</c>).</summary>
private void RandomizeClothingLocked(bool excludeCurrent)
{
RandomizeHeadgearLocked(excludeCurrent);
RandomizeShirtLocked();
RandomizeTrousersLocked();
RandomizeFootwearLocked();
}
/// <summary>
/// Ports <c>CharGenState::RandomizeTemplate @ 0x005c6500</c>. The two
/// Olthoi heritages force template 0 unconditionally
/// (<c>this-&gt;template_ = 1; ApplyTemplate(this);</c> in retail — but
/// <c>ApplyTemplate</c>'s own Olthoi branch immediately re-forces
/// <c>template_ = 0</c> regardless, so the intermediate write to 1 is
/// observably a no-op; this port skips straight to
/// <see cref="ApplyTemplateLocked"/>, which already carries that force).
/// Otherwise, when the heritage has more than one template (Custom plus
/// at least one preset), picks <c>RandInt(count-1, template_-1) + 1</c> —
/// a uniform pick over indices <c>[1, count-1]</c> (retail's own
/// preset templates, NEVER index 0/Custom) excluding the CURRENT
/// template (offset by <c>-1</c> to align with the shifted range; an
/// Unset/<c>0xFFFFFFFF</c> current value wraps far outside <c>[0,count-1)</c>
/// and can never match, so a fresh roll off a Reset state is
/// unconstrained).
/// </summary>
private void RandomizeTemplateLocked()
{
if (_heritageId == 0 || _genderKey == 0)
return;
if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage))
return;
if (_heritageId == (uint)ChargenHeritageGroup.Olthoi
|| _heritageId == (uint)ChargenHeritageGroup.OlthoiAcid)
{
ApplyTemplateLocked(heritage);
return;
}
int count = heritage.Templates.Count;
if (count <= 1)
return;
uint excludeShifted = unchecked(_template - 1u);
uint picked = RandomizeIndexExcludingLocked(count - 1, excludeShifted) + 1u;
_template = picked;
ApplyTemplateLocked(heritage);
}
/// <summary>
/// Ports <c>CharGenState::RandomizeCharacter(this, hasToD) @
/// 0x005c6d80</c>: <see cref="ClearSessionState"/> (retail's own
/// <c>Reset()</c>), roll a heritage
/// (<c>SetHeritageGroup(RollDice(1, hasToD?4:3))</c> — heritage ids
/// 1..3/4 are the four HUMAN heritage groups (Aluvian/Gharu'ndim/Sho/
/// Viamontian, <see cref="ChargenHeritageGroup"/>'s own numbering); a
/// "random" character in retail is deliberately always human, never one
/// of the other nine heritages — a genuine retail quirk, not a porting
/// shortcut), roll a gender (<c>SetGender(RollDice(1,2))</c>), then
/// appearance/headgear/shirt/trousers/footwear/template/start-area, in
/// that exact order. acdream has no account/DLC-ownership signal (AD-102's
/// already-established convention: every installed heritage/town ships
/// unconditionally selectable, matching what a ToD-owning account would
/// see) — this reuses that SAME convention rather than inventing a
/// second one, so the heritage roll always uses the 4-heritage bound.
/// <c>SetHeritageGroupLocked</c> already rolls a starting area once as
/// part of its own <c>RandomizeStartAreaLocked</c> call (mirroring
/// retail's own <c>SetHeritageGroup</c>); the explicit
/// <c>RandomizeStartAreaLocked</c> call at the end re-rolls it a SECOND
/// time, matching retail's own redundant double-roll exactly (harmless —
/// each roll is independently uniform over the same list).
/// </summary>
private void RandomizeCharacterLocked()
{
ClearSessionState();
uint heritageId = (uint)RollDiceLocked(1, 4);
if (_options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
SetHeritageGroupLocked(heritageId, heritage);
uint genderKey = (uint)RollDiceLocked(1, 2);
SetGenderLocked(genderKey);
RandomizeAppearanceLocked();
RandomizeHeadgearLocked(excludeCurrent: false);
RandomizeShirtLocked();
RandomizeTrousersLocked();
RandomizeFootwearLocked();
RandomizeTemplateLocked();
if (_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? finalHeritage))
RandomizeStartAreaLocked(finalHeritage);
}
/// <summary>Public command surface for <see cref="RandomizeCharacterLocked"/> —
/// consumed by the App layer's screen-open edge (mirrors
/// <c>gmCharGenMainUI</c>'s ctor-time roll, retiring AP-214's
/// honest-blank deviation) and the Summary page's Random button
/// (<c>gmCharGenMainUI::DoRandom</c> case 5, behind the
/// <c>ID_CharGen_RandomizeWarning</c> confirmation the App layer
/// owns).</summary>
internal bool TryRandomizeCharacter()
{
lock (_gate)
{
if (_disposed || !_active)
return false;
RandomizeCharacterLocked();
_revision++;
}
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
return true;
}
/// <summary>Public command surface for <see cref="RandomizeAppearanceLocked"/> —
/// the Appearance page's Random button when its Face sub-tab is showing
/// (<c>gmCharGenMainUI::DoRandom</c> case 3's <c>else</c> arm).</summary>
internal bool TryRandomizeAppearance()
{
lock (_gate)
{
if (_disposed || !_active || _heritageId == 0 || _genderKey == 0)
return false;
RandomizeAppearanceLocked();
_revision++;
}
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
return true;
}
/// <summary>Public command surface for <see cref="RandomizeClothingLocked"/>
/// with <c>excludeCurrent: true</c> — the Appearance page's Random
/// button when its Clothes sub-tab is showing
/// (<c>gmCharGenMainUI::DoRandom</c> case 3's
/// <c>RandomizeClothing(state, 1)</c> arm).</summary>
internal bool TryRandomizeClothing()
{
lock (_gate)
{
if (_disposed || !_active || _heritageId == 0 || _genderKey == 0)
return false;
RandomizeClothingLocked(excludeCurrent: true);
_revision++;
}
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
return true;
}
// ── Town / name / slot ─────────────────────────────────────────────
/// <summary>Ports <c>CharGenState::SetStartArea @ 0x005C4000</c> — bounds
@ -1290,16 +1682,19 @@ public sealed class RuntimeCharacterCreationState : IDisposable
refusal = trimmed.Length == 0
? new RuntimeCharacterCreationLocalRefusal(
NoName: true, false, false, false)
: !confirmedUnspentCredits && _remainingAttributeCredits > 0
: _heritageId == 0 || _genderKey == 0
? new RuntimeCharacterCreationLocalRefusal(
false, AttributeCreditsUnspent: true, false, false)
: _verificationPending
false, false, false, false, HeritageOrGenderUnset: true)
: !confirmedUnspentCredits && _remainingAttributeCredits > 0
? new RuntimeCharacterCreationLocalRefusal(
false, false, AlreadyPending: true, false)
: slotCount > 0 && rosterCount >= slotCount
false, AttributeCreditsUnspent: true, false, false)
: _verificationPending
? new RuntimeCharacterCreationLocalRefusal(
false, false, false, RosterFull: true)
: RuntimeCharacterCreationLocalRefusal.None;
false, false, AlreadyPending: true, false)
: slotCount > 0 && rosterCount >= slotCount
? new RuntimeCharacterCreationLocalRefusal(
false, false, false, RosterFull: true)
: RuntimeCharacterCreationLocalRefusal.None;
_lastLocalRefusal = refusal;
accepted = !refusal.Any;