fix(chargen): Campaign CC CC5 review fix round — F1-F14
Opus dual-lens review of 34e3a534+a975efd1 returned architectural PASS-with-items / retail-fidelity FAIL. Every finding fixed: - F1 (BLOCKER): deleted CharacterCreationSummaryPage's dead _suppressNextFieldEvent latch. UiField.SetText never raises OnFocusLost/OnSubmit, so the latch never had anything genuine to suppress — it stayed armed until the player's own next real commit and silently ate their typed name. - F2: byte-re-derived gmCharGenMainUI::RecvNotice_ CharGenVerificationResponse @0x004e9030's jump table — Pending is an explicit switch case landing on the SAME NameDBDown label as Corrupt/DatabaseDown, and Undef/out-of-range falls through the function's own unsigned-underflow default arm to that identical label. Retail's dispatch has NO silent branch. ApplyCreationResponse now produces a real rejection for Pending/Undef instead of a silent reset; ReconcileDialogs maps them to NameDBDown. Corrects the wrong "retail swallows Pending" claim everywhere it was repeated (plan doc, Core.Net doc comment, Runtime doc comments). - F3: skill rows now use the key/value template with CharGenState::GetSkillScore @0x005C4B50 as the value (ported via the new RetailSkillFormula.CalculateChargenScore / ChargenSkillScoreResolver, wired through a new GetSkillScore binding), not template 0/name-only; bucket headers are unconditional. Writing this fix's own regression test surfaced a second, more severe bug: CharacterCreationSummaryPage never wired _list.TemplateResolver at all, so RebuildListbox has been a silent no-op since CC5 shipped — fixed by threading templateResolver through the page's constructor, matching every sibling UiTemplateListBox owner. - F4: added the missing _errorMessageDialogContext one-outstanding guard to the 0xF643 rejection dialog, matching MakeErrorMessageDialog's own guard @0x004e8cc4 and the other four sibling dialogs' shape (registered in CloseAllDialogs, suppress- callback checked). - F5: the Summary preview camera now seeds/re-derives retail's zoomed-OUT eye (byte-decoded (0,-2.5,0.95) at gmCGSummaryPage:: InitializePage ~0x0047bd14-0x0047bd44) instead of Appearance's zoomed-in default, via a new ChargenPreviewController useZoomedOutEye flag. - F6: retired AP-225 outright — re-derived the ListenToElementMessage length gate is NUL-inclusive, so MaxNameLength=32 was always byte-correct, not merely internally consistent. - F7: amended AP-221 to cover the Summary preview's duplicate one-shot-composition binding gap (CC5 duplicated the pattern instead of closing it). - F8: byte-decoded GetRandomReal @0x00563940's fmul operand at 0x007cd650 — an 8-byte double, not a 4-byte float — is EXACTLY 1.0/32767.0, not 1/32768. Added RollShadeLocked (_random.Next(32768) * (1.0/32767.0)) and switched all six shade rolls onto it. - F9: evaluated porting retail's exact empty-name-commit no-op (NUL-inclusive length==1 skips SetName entirely) and rejected it — it would fight the F1 field-sync model by spontaneously reverting an emptied field on the next unrelated revision bump. Kept the clear, documented the tradeoff, filed AP-227. - F11: filed AP-226 documenting retail's static pcProfessions/pcGender/ pcHeritage/pcTown label tables versus acdream's DAT-sourced labels, including the non-human-heritage-renders-bare-"Heritage:" retail quirk. - F12: added exclude-current determinism (count-2 lists), Random- clears-name, repeat-identical-rejection-reshows, and RebuildListbox content tests (the last one found F3's TemplateResolver bug). - F13: threaded an optional Random through GameRuntimeDependencies -> LiveSessionController -> RuntimeCharacterCreationState, matching the existing TimeProvider injection shape, closing the Slice-K determinism hazard on a bot-reachable Randomize* command family. - F14: RandomizeCharacterLocked now assigns _heritageId unconditionally before the TryGetHeritage gate, matching retail's SetHeritageGroup @0x005C67A0 (mHeritageGroup written before the DAT lookup). Gates: Runtime 1726/0 (was 1722/0), App 5242/3 skips (was 5240/3), Headless 166/0, Core.Net 993/994 (the one failure, NakEmissionTests LossSoak, is a known pre-existing flake — passes standalone), full solution Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a975efd1d5
commit
0c8e1e7df1
14 changed files with 720 additions and 141 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -26,6 +26,7 @@ using AcDream.UI.Abstractions.Input;
|
|||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Vitals;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using Silk.NET.Input;
|
||||
using Silk.NET.Windowing;
|
||||
|
||||
|
|
@ -656,6 +657,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
// outside the lock matches this file's existing pattern
|
||||
// elsewhere (construct once, lock only around Resolve calls).
|
||||
var characterCreationStrings = new DatStringResolver(d.Dats);
|
||||
// CC5 review fix round F3 (2026-08-16): read the global
|
||||
// SkillTable (portal.dat 0x0E000004 — the SAME file
|
||||
// ChargenOptions.GlobalSkillCostsBySkillId's own doc comment and
|
||||
// LiveSessionRuntimeFactory.CreateCharacterBindings already read)
|
||||
// ONCE at composition time, under the DatLock DatCollection's
|
||||
// thread-safety contract requires — mirrors LiveSkillCreditResolver's
|
||||
// own constructor-time load. The resolver itself does no further
|
||||
// DAT access per call (pure SkillFormula arithmetic), so the
|
||||
// Summary page's GetSkillScore binding below needs no lock.
|
||||
SkillTable? chargenSkillTable;
|
||||
lock (d.DatLock)
|
||||
chargenSkillTable = d.Dats.Get<SkillTable>(0x0E000004u);
|
||||
var chargenSkillScoreResolver = new ChargenSkillScoreResolver(chargenSkillTable);
|
||||
var bindings = new RetailUiRuntimeBindings(
|
||||
Host: host,
|
||||
Assets: assets,
|
||||
|
|
@ -1012,6 +1026,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
RandomizeCharacter: late.GameRuntime.CharacterCreationRandomizeCharacter,
|
||||
RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance,
|
||||
RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing,
|
||||
GetSkillScore: chargenSkillScoreResolver.Resolve,
|
||||
OpenOnStart: d.Options.OpenCharacterCreationOnStart)
|
||||
: null);
|
||||
RetailUiRuntime runtime = lease.Mount(
|
||||
|
|
|
|||
|
|
@ -1112,14 +1112,17 @@ internal sealed class LivePresentationCompositionPhase
|
|||
// (gmCGSummaryPage::InitializePage @0x0047bbf0, confirmed a SEPARATE
|
||||
// instance from the Appearance page's own during the CC6b-MOUNT
|
||||
// review) — same one-shot binding shape as the Appearance preview
|
||||
// immediately above (AP-221's own disposition applies here too: a
|
||||
// DAT/resource read not ready on this exact composition frame means
|
||||
// the Summary preview stays permanently unbound for the session,
|
||||
// same tracked follow-up as the Appearance preview). No zoom/rotate
|
||||
// control surface is wired — retail's Summary page has no such
|
||||
// buttons (only <c>StartAnimation</c>'s idle loop and a fixed 180°
|
||||
// heading), so this controller's ZoomIn/RotateClockwise etc. simply
|
||||
// never get called.
|
||||
// immediately above. Review fix round F7 (2026-08-16): AP-221 is now
|
||||
// AMENDED to cover this second binding explicitly (it originally
|
||||
// named CC5 as the slice that should CLOSE the gap; CC5 duplicated
|
||||
// the pattern here instead) — a DAT/resource read not ready on this
|
||||
// exact composition frame means the Summary preview stays
|
||||
// permanently unbound for the session, same tracked follow-up as
|
||||
// the Appearance preview, now under the same amended row. No
|
||||
// zoom/rotate control surface is wired — retail's Summary page has
|
||||
// no such buttons (only <c>StartAnimation</c>'s idle loop and a
|
||||
// fixed 180° heading), so this controller's ZoomIn/RotateClockwise
|
||||
// etc. simply never get called.
|
||||
CompositionAcquisitionScope.CompositionAcquisitionLease<
|
||||
ChargenPreviewRenderer>? summaryPreviewLease = null;
|
||||
ChargenPreviewController? summaryPreviewController = null;
|
||||
|
|
@ -1162,7 +1165,12 @@ internal sealed class LivePresentationCompositionPhase
|
|||
content.AnimationLoader,
|
||||
summaryCatalog,
|
||||
summaryCatalog,
|
||||
d.DatLock);
|
||||
d.DatLock,
|
||||
// F5 (2026-08-16): the Summary preview is retail's zoomed-
|
||||
// OUT full-body framing (gmCGSummaryPage::InitializePage @
|
||||
// 0x0047bbf0), not the Appearance page's zoomed-in default —
|
||||
// see ChargenPreviewController's own ctor doc comment.
|
||||
useZoomedOutEye: true);
|
||||
interaction.RetainedUi.Runtime.SummaryPreviewControl = summaryPreviewController;
|
||||
bindings.AdoptRelease(
|
||||
"summary preview control",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using AcDream.Core.CharGen;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
|
|
@ -32,6 +33,45 @@ internal static class RetailSkillFormula
|
|||
result = (uint)Math.Floor((double)numerator / divisor + 0.5d);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC CC5 review fix round, F3 (2026-08-16). Ports
|
||||
/// <c>CharGenState::GetSkillScore @ 0x005C4B50</c>'s FULL behavior, not
|
||||
/// just the shared <see cref="TryCalculate"/> base: after the formula
|
||||
/// result, retail adds a level-based bonus keyed off the skill's CURRENT
|
||||
/// advancement class (<c>edi_1</c> in the decomp) — <c>edi_1 == 2</c>
|
||||
/// (Trained) → <c>result += 5</c>; <c>edi_1 == 3</c> (Specialized) →
|
||||
/// <c>result += 10</c> — before returning. The decomp's own gate,
|
||||
/// <c>if (edi_1 >= var_38)</c> (<c>var_38</c> resolves to
|
||||
/// <c>SkillBase.MinLevel</c> — a decompiler-mangled local the raw
|
||||
/// pseudo-C renders as an uninitialized read; DatReaderWriter's own
|
||||
/// typed <c>SkillBase.MinLevel</c> field is the same value cleanly), is
|
||||
/// satisfied for both callers of this method (Specialized=3 and
|
||||
/// Trained=2 are the only two advancement classes CC5's Summary listbox
|
||||
/// still shows — AP-224 — and no retail-authored skill sets
|
||||
/// <c>MinLevel</c> above Untrained=1) so it is not reproduced as a
|
||||
/// separate branch; a future caller passing <see cref="ChargenSkillAdvancementClass.Untrained"/>
|
||||
/// or <see cref="ChargenSkillAdvancementClass.Inactive"/> would need
|
||||
/// that gate ported for real.
|
||||
/// </summary>
|
||||
public static uint CalculateChargenScore(
|
||||
SkillBase skillBase,
|
||||
uint attribute1,
|
||||
uint attribute2,
|
||||
ChargenSkillAdvancementClass level)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(skillBase);
|
||||
|
||||
if (!TryCalculate(skillBase.Formula, attribute1, attribute2, out uint result))
|
||||
return 0u;
|
||||
|
||||
return level switch
|
||||
{
|
||||
ChargenSkillAdvancementClass.Trained => result + 5u,
|
||||
ChargenSkillAdvancementClass.Specialized => result + 10u,
|
||||
_ => result,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -70,3 +110,54 @@ internal sealed class LiveSkillCreditResolver(SkillTable? skillTable)
|
|||
: 0u;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC CC5 review fix round, F3 (2026-08-16). Chargen-side sibling
|
||||
/// of <see cref="LiveSkillCreditResolver"/>: resolves
|
||||
/// <see cref="RetailSkillFormula.CalculateChargenScore"/> against the SAME
|
||||
/// global <c>SkillTable</c> (portal.dat <c>0x0E000004</c>), fed by a
|
||||
/// candidate character's CHARGEN attribute spread (<see cref="ChargenAttributeValues"/>,
|
||||
/// keyed the same way <c>AcDream.Runtime.Session.ChargenAttributeId</c>
|
||||
/// already does — verified against DatReaderWriter's own
|
||||
/// <c>DatReaderWriter.Enums.AttributeId</c> generated enum, which carries the
|
||||
/// identical Strength=1/Endurance=2/Quickness=3/Coordination=4/Focus=5/
|
||||
/// Self=6 numbering) rather than a live player's server-echoed current
|
||||
/// attributes. Wired at composition time
|
||||
/// (<c>InteractionRetainedUiComposition.cs</c>) so
|
||||
/// <c>CharacterCreationSummaryPage</c> never needs a DAT/Chorizite
|
||||
/// dependency of its own — same shape as that composition's existing
|
||||
/// <c>ResolveText</c> binding.
|
||||
/// </summary>
|
||||
internal sealed class ChargenSkillScoreResolver(SkillTable? skillTable)
|
||||
{
|
||||
public uint Resolve(
|
||||
uint skillId,
|
||||
ChargenAttributeValues attributes,
|
||||
ChargenSkillAdvancementClass level)
|
||||
{
|
||||
if (skillTable?.Skills is null
|
||||
|| !skillTable.Skills.TryGetValue(
|
||||
(DatReaderWriter.Enums.SkillId)skillId,
|
||||
out var skillBase))
|
||||
{
|
||||
return 0u;
|
||||
}
|
||||
|
||||
uint attribute1 = ResolveAttribute(skillBase.Formula.Attribute1, attributes);
|
||||
uint attribute2 = ResolveAttribute(skillBase.Formula.Attribute2, attributes);
|
||||
return RetailSkillFormula.CalculateChargenScore(skillBase, attribute1, attribute2, level);
|
||||
}
|
||||
|
||||
private static uint ResolveAttribute(
|
||||
DatReaderWriter.Enums.AttributeId attributeId,
|
||||
ChargenAttributeValues attributes) => attributeId switch
|
||||
{
|
||||
DatReaderWriter.Enums.AttributeId.Strength => (uint)Math.Max(0, attributes.Strength),
|
||||
DatReaderWriter.Enums.AttributeId.Endurance => (uint)Math.Max(0, attributes.Endurance),
|
||||
DatReaderWriter.Enums.AttributeId.Quickness => (uint)Math.Max(0, attributes.Quickness),
|
||||
DatReaderWriter.Enums.AttributeId.Coordination => (uint)Math.Max(0, attributes.Coordination),
|
||||
DatReaderWriter.Enums.AttributeId.Focus => (uint)Math.Max(0, attributes.Focus),
|
||||
DatReaderWriter.Enums.AttributeId.Self => (uint)Math.Max(0, attributes.Self),
|
||||
_ => 0u,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ internal sealed class ChargenPreviewController :
|
|||
private readonly IChargenPalSetSource _palSets;
|
||||
private readonly IChargenClothingTableSource _clothingTables;
|
||||
private readonly object _datLock;
|
||||
private readonly bool _useZoomedOutEye;
|
||||
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
||||
|
||||
private ChargenPreviewAnimator? _animator;
|
||||
|
|
@ -193,6 +194,19 @@ internal sealed class ChargenPreviewController :
|
|||
/// <see cref="ChargenPreviewRenderer"/>'s own <c>camera</c> constructor
|
||||
/// parameter — see this class's own doc comment on why the renderer and
|
||||
/// the zoom controller must share one mutable camera.</param>
|
||||
/// <param name="useZoomedOutEye">Review fix round F5 (2026-08-16):
|
||||
/// <see langword="false"/> (the default) reproduces the Appearance
|
||||
/// page's own zoomed-IN default eye
|
||||
/// (<c>gmCGAppearancePage::InitializePage @ 0x0047FDD0</c>,
|
||||
/// <see cref="ChargenPreviewCamera.ResolveDefaultEye"/>).
|
||||
/// <see langword="true"/> reproduces the Summary page's own eye
|
||||
/// (<c>gmCGSummaryPage::InitializePage @ 0x0047bbf0</c>, byte-decoded
|
||||
/// eye literal <c>(0, -2.5, 0.95)</c> at <c>~0x0047bd14-0x0047bd44</c> —
|
||||
/// exactly <see cref="ChargenPreviewCamera.ResolveZoomedOutEye"/>'s
|
||||
/// default-heritage value, NOT the zoomed-in one this controller used
|
||||
/// before the fix). Summary has no zoom buttons at all (retail's own
|
||||
/// viewport there is fixed-framing), so this is a permanent camera
|
||||
/// profile for the controller's whole lifetime, not a toggle.</param>
|
||||
public ChargenPreviewController(
|
||||
IChargenPreviewRenderer renderer,
|
||||
ChargenPreviewCamera camera,
|
||||
|
|
@ -201,7 +215,8 @@ internal sealed class ChargenPreviewController :
|
|||
IAnimationLoader animations,
|
||||
IChargenPalSetSource palSets,
|
||||
IChargenClothingTableSource clothingTables,
|
||||
object datLock)
|
||||
object datLock,
|
||||
bool useZoomedOutEye = false)
|
||||
{
|
||||
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
|
|
@ -211,7 +226,15 @@ internal sealed class ChargenPreviewController :
|
|||
_palSets = palSets ?? throw new ArgumentNullException(nameof(palSets));
|
||||
_clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables));
|
||||
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
||||
_useZoomedOutEye = useZoomedOutEye;
|
||||
_rotation = new ChargenPreviewRotationController();
|
||||
// Seed the eye NOW, matching whatever the first Rebuild's own
|
||||
// heritageOrGenderChanged branch below would otherwise defer until
|
||||
// the first successful compose — avoids one frame of the wrong
|
||||
// (Appearance-profile) eye if this controller ever renders before
|
||||
// Rebuild's first call succeeds.
|
||||
if (_useZoomedOutEye)
|
||||
_camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye(0u);
|
||||
}
|
||||
|
||||
/// <summary>Test-observability seam only — production callers use
|
||||
|
|
@ -275,7 +298,14 @@ internal sealed class ChargenPreviewController :
|
|||
bool heritageOrGenderChanged =
|
||||
!_hasComposed || heritageId != _lastHeritageId || genderKey != _lastGenderKey;
|
||||
if (heritageOrGenderChanged)
|
||||
_camera.SetHeritage(heritageId);
|
||||
{
|
||||
// F5: the Summary controller (_useZoomedOutEye) re-derives the
|
||||
// FIXED zoomed-out eye per heritage instead of SetHeritage's
|
||||
// zoomed-in default — see the ctor param's own doc comment.
|
||||
_camera.Eye = _useZoomedOutEye
|
||||
? ChargenPreviewCamera.ResolveZoomedOutEye(heritageId)
|
||||
: ChargenPreviewCamera.ResolveDefaultEye(heritageId);
|
||||
}
|
||||
|
||||
// ChargenPreviewZoomController's animator dependency is required at
|
||||
// construction (fix round F2) — a fresh animator means a fresh
|
||||
|
|
|
|||
|
|
@ -56,17 +56,24 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
|
|||
private const uint KeyTextId = 0x100002FCu;
|
||||
private const uint ValueTextId = 0x100002FDu;
|
||||
|
||||
/// <summary>Retail's <c>name[33]</c> buffer (32 usable chars + null
|
||||
/// terminator — <c>RuntimeCharacterCreationState.TrySetName</c>'s own
|
||||
/// already-established storage cap). The decompiled UI-side check at
|
||||
/// <c>ListenToElementMessage @ 0x0047bfd1</c> compares the raw input
|
||||
/// length against the literal <c>0x21</c> (33) — one more than this —
|
||||
/// but that comparison's exact base (visible character count vs. an
|
||||
/// internal length-prefix accounting the decompiler didn't resolve
|
||||
/// cleanly) is not fully certain from the pseudo-C. Using 32 here keeps
|
||||
/// the UI-level reject-and-revert threshold CONSISTENT with the
|
||||
/// already-reviewed storage cap rather than trusting an ambiguous
|
||||
/// 1-off decomp literal over that established contract.</summary>
|
||||
/// <summary>
|
||||
/// Retail's <c>name[33]</c> buffer (32 usable chars + null terminator —
|
||||
/// <c>RuntimeCharacterCreationState.TrySetName</c>'s own
|
||||
/// already-established storage cap). Review fix round F6 (2026-08-16):
|
||||
/// the decompiled UI-side check at <c>ListenToElementMessage @
|
||||
/// 0x0047bf40</c> (<c>~0x0047bfd1</c>) compares the field text's
|
||||
/// <c>m_charbuffer</c> LENGTH FIELD against the literal <c>0x21</c>
|
||||
/// (33) — that field is confirmed NUL-INCLUSIVE (the SAME method's own
|
||||
/// empty-field check earlier at <c>0x0047bf93</c> compares that field to
|
||||
/// <c>1</c>, i.e. an empty string's length reads as 1, not 0). So
|
||||
/// <c>length > 33</c> is EXACTLY <c>visibleChars > 32</c>: a
|
||||
/// 32-character name has length 33 (not <c>> 33</c>, accepted), a
|
||||
/// 33-character name has length 34 (<c>> 33</c>, rejected). This
|
||||
/// constant was ALWAYS byte-correct, not merely internally consistent
|
||||
/// with the storage cap it was originally justified against — the
|
||||
/// earlier "not fully certain" hedge and its AP-225 register row are
|
||||
/// both retired.
|
||||
/// </summary>
|
||||
private const int MaxNameLength = 32;
|
||||
|
||||
private readonly CharacterCreationRuntimeBindings _bindings;
|
||||
|
|
@ -75,7 +82,6 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
|
|||
private readonly UiTemplateListBox? _list;
|
||||
private readonly UiField? _nameField;
|
||||
private string _lastCommittedName = string.Empty;
|
||||
private bool _suppressNextFieldEvent;
|
||||
private uint _nameTooLongDialogContext;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -92,13 +98,27 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
|
|||
UiElement pageRoot,
|
||||
CharacterCreationRuntimeBindings bindings,
|
||||
RetailDialogFactory dialogs,
|
||||
string nameTooLongMessage)
|
||||
string nameTooLongMessage,
|
||||
Func<uint, uint, UiElement?> templateResolver)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_dialogs = dialogs;
|
||||
_nameTooLongMessage = nameTooLongMessage;
|
||||
|
||||
_list = UiElement.FindDescendant(pageRoot, ListBoxId) as UiTemplateListBox;
|
||||
// Review fix round F3 residual (found while adding its own test,
|
||||
// 2026-08-16): this assignment was MISSING outright — every sibling
|
||||
// page that owns a UiTemplateListBox (CharacterCreationSkillsPage,
|
||||
// CharacterManagementUiController, every Options-panel controller)
|
||||
// wires TemplateResolver in its own constructor; this page never
|
||||
// did. Without it, ResolveTemplateRow's own `_list.TemplateResolver
|
||||
// is null` guard made EVERY RebuildListbox call a silent no-op —
|
||||
// the Summary listbox has never rendered a single row (Profession/
|
||||
// Gender/Heritage/Town, Attributes, Health/Stamina/Mana/Skill
|
||||
// Credits, or the skill buckets) since CC5 shipped, independent of
|
||||
// and masking the F3 template/score fix above.
|
||||
if (_list is not null)
|
||||
_list.TemplateResolver = templateResolver;
|
||||
|
||||
_nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField;
|
||||
if (_nameField is not null)
|
||||
|
|
@ -137,10 +157,19 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
|
|||
// Keep the field's displayed text in sync with the committed name
|
||||
// unless the player is actively typing (a mid-edit Refresh — driven
|
||||
// by an unrelated selection change elsewhere on the screen — must
|
||||
// not clobber their in-progress keystrokes).
|
||||
// not clobber their in-progress keystrokes). Review fix round F1
|
||||
// (2026-08-16): this used to arm a "_suppressNextFieldEvent" latch
|
||||
// before calling SetText, on the assumption that SetText raises the
|
||||
// same commit event a real keystroke/blur would. It does not —
|
||||
// UiField.SetText (UiField.cs:240-248) only mutates _text/_caret and
|
||||
// never invokes OnFocusLost/OnSubmit (those fire exclusively from
|
||||
// OnEvent's own idMessage dispatch, UiField.cs:~313-316/:729). The
|
||||
// latch therefore never had anything genuine to suppress; it just
|
||||
// sat armed until the PLAYER's own next real commit, which then hit
|
||||
// this early-return and silently dropped their typed name. Deleting
|
||||
// the latch outright (nothing to reproduce) fixes that bug.
|
||||
if (_nameField is { IsFocused: false } field && field.Text != snapshot.Name)
|
||||
{
|
||||
_suppressNextFieldEvent = true;
|
||||
field.SetText(snapshot.Name);
|
||||
_lastCommittedName = snapshot.Name;
|
||||
}
|
||||
|
|
@ -151,15 +180,35 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
|
|||
|
||||
// ── Name field (ListenToElementMessage @ 0x0047bf40) ────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Review fix round F9 (2026-08-16), empty-name commit: retail's
|
||||
/// <c>ListenToElementMessage @ ~0x0047bf93</c> reads a length field that
|
||||
/// is NUL-INCLUSIVE (confirmed at F6/F2's own byte-decode — an empty
|
||||
/// field's length is 1, not 0) and gates the ENTIRE commit block —
|
||||
/// including <c>SetName</c> — behind <c>if (length != 1)</c>. Blurring
|
||||
/// an EMPTIED field in retail therefore leaves <c>CharGenState.name</c>
|
||||
/// UNCHANGED (whatever it held before), not cleared; <c>DoFinish</c>
|
||||
/// later reads that unchanged internal name, so retail's field and its
|
||||
/// internal state can legitimately show different things after an
|
||||
/// empty-field blur. This port deliberately does NOT reproduce that:
|
||||
/// it calls <see cref="CharacterCreationRuntimeBindings.SetName"/>
|
||||
/// (line below) for every commit including an empty one, so the state
|
||||
/// always agrees with what the field just showed. Verified this is a
|
||||
/// genuine, not cosmetic, choice — porting the exact skip would fight
|
||||
/// <see cref="Refresh"/>'s own field-sync block above (the F1 fix): the
|
||||
/// NEXT time anything else bumps the Runtime revision (e.g. the player
|
||||
/// returns to Attributes and changes a slider, then comes back), Refresh
|
||||
/// would see <c>field.Text ("") != snapshot.Name (the stale unchanged
|
||||
/// name)</c> and forcibly restore the OLD name into the field — a
|
||||
/// spontaneous, unexplained repopulation of a field the player
|
||||
/// deliberately emptied, which retail's own non-continuously-refreshed
|
||||
/// UI never produces. Register AP-227 records this as a deliberate
|
||||
/// divergence.
|
||||
/// </summary>
|
||||
private void CommitNameFromField(string text)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
if (_suppressNextFieldEvent)
|
||||
{
|
||||
_suppressNextFieldEvent = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.Length > MaxNameLength)
|
||||
{
|
||||
|
|
@ -236,8 +285,8 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
|
|||
AddPair(pairTemplate, "Mana", a.Self);
|
||||
AddPair(pairTemplate, "Skill Credits", snapshot.RemainingSkillCredits);
|
||||
|
||||
AddSkillBucket(headerTemplate, lineTemplate, view, "Specialized Skills", ChargenSkillAdvancementClass.Specialized);
|
||||
AddSkillBucket(headerTemplate, lineTemplate, view, "Trained Skills", ChargenSkillAdvancementClass.Trained);
|
||||
AddSkillBucket(headerTemplate, pairTemplate, view, snapshot, "Specialized Skills", ChargenSkillAdvancementClass.Specialized);
|
||||
AddSkillBucket(headerTemplate, pairTemplate, view, snapshot, "Trained Skills", ChargenSkillAdvancementClass.Trained);
|
||||
}
|
||||
|
||||
private void AddLine(UiTemplateListEntry template, string text)
|
||||
|
|
@ -283,24 +332,33 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
|
|||
return row is null ? null : UiElement.FindDescendant(row, childId) as UiText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review fix round F3 (2026-08-16), byte-decoded against
|
||||
/// <c>SetSummaryText @ ~0x0047b6be-0x0047b9e0</c>: retail adds each
|
||||
/// bucket's HEADER row UNCONDITIONALLY, before it ever walks
|
||||
/// <c>skillRecordList</c> for that bucket (an empty bucket still shows
|
||||
/// its header) — the previous lazy "only if any skill matched" gate had
|
||||
/// no decomp support. Each matching skill row uses template 2 (the
|
||||
/// key/value pair, <c>AddItemFromTemplateList(..., 2, ...)</c> @
|
||||
/// <c>0x0047b938</c>), not template 0's single line — KEY = the skill
|
||||
/// name, VALUE = <c>CharGenState::GetSkillScore(state, skill->id)</c> @
|
||||
/// <c>0x0047b923</c>, ported as <see cref="CharacterCreationRuntimeBindings.GetSkillScore"/>.
|
||||
/// </summary>
|
||||
private void AddSkillBucket(
|
||||
UiTemplateListEntry headerTemplate,
|
||||
UiTemplateListEntry lineTemplate,
|
||||
UiTemplateListEntry pairTemplate,
|
||||
IRuntimeCharacterCreationView view,
|
||||
RuntimeCharacterCreationSnapshot snapshot,
|
||||
string header,
|
||||
ChargenSkillAdvancementClass targetClass)
|
||||
{
|
||||
bool any = false;
|
||||
AddHeader(headerTemplate, header);
|
||||
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
|
||||
{
|
||||
if (view.GetSkillLevel(skillId) != targetClass)
|
||||
continue;
|
||||
if (!any)
|
||||
{
|
||||
AddHeader(headerTemplate, header);
|
||||
any = true;
|
||||
}
|
||||
AddLine(lineTemplate, ItemAppraisalTextFormatter.SkillName((int)skillId));
|
||||
uint score = _bindings.GetSkillScore?.Invoke(skillId, snapshot.Attributes, targetClass) ?? 0u;
|
||||
AddPair(pairTemplate, ItemAppraisalTextFormatter.SkillName((int)skillId), (int)score);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -364,6 +422,8 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
|
|||
_nameTooLongDialogContext = 0u;
|
||||
_dialogs.CloseDialog(closing);
|
||||
}
|
||||
if (_list is not null)
|
||||
_list.TemplateResolver = null;
|
||||
_list?.Flush();
|
||||
// PreviewControl is owned by the composition root (disposed with
|
||||
// the leased ChargenPreviewRenderer) — just drop the reference.
|
||||
|
|
|
|||
|
|
@ -61,6 +61,13 @@ public sealed record CharacterCreationRuntimeBindings(
|
|||
/// <summary>CC5: the Appearance page's Random button on its Clothes
|
||||
/// sub-tab.</summary>
|
||||
Func<RuntimeCommandResult>? RandomizeClothing = null,
|
||||
/// <summary>CC5 review fix round, F3 (2026-08-16): the Summary page's
|
||||
/// skill-row VALUE — <c>CharGenState::GetSkillScore @ 0x005C4B50</c>,
|
||||
/// wired at composition time (<c>AcDream.App.Net.ChargenSkillScoreResolver</c>)
|
||||
/// so this UI-layer record stays free of a direct DAT/Chorizite
|
||||
/// dependency, matching <see cref="ResolveText"/>'s own shape.
|
||||
/// <see langword="null"/> degrades to a "no score available" 0.</summary>
|
||||
Func<uint, ChargenAttributeValues, ChargenSkillAdvancementClass, uint>? GetSkillScore = null,
|
||||
bool OpenOnStart = false);
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -188,6 +195,13 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
private uint _creditWarningDialogContext;
|
||||
private uint _randomizeWarningDialogContext;
|
||||
private uint _noNameWarningDialogContext;
|
||||
// CC5 review fix round F4 (2026-08-16): gmCharGenMainUI's own
|
||||
// m_uiErrorMessageContext (MakeErrorMessageDialog @ 0x004e8cb0's guard
|
||||
// at 0x004e8cc4, assigned at 0x004e8dd3, cleared by the dtor at
|
||||
// 0x004e83b3 alongside m_uiPleaseWaitContext/m_uiExitContext) — the
|
||||
// 0xF643 rejection dialog was the only one of the five dialogs this
|
||||
// controller owns without this same one-outstanding-dialog guard.
|
||||
private uint _errorMessageDialogContext;
|
||||
private RuntimeCharacterCreationRejection? _lastShownRejection;
|
||||
private bool _suppressDialogCallbacks;
|
||||
private bool _disposed;
|
||||
|
|
@ -270,7 +284,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
|
||||
_appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings);
|
||||
_summaryPage = new CharacterCreationSummaryPage(
|
||||
summaryPageRoot, bindings, dialogs, strings.NameTooLong);
|
||||
summaryPageRoot, bindings, dialogs, strings.NameTooLong, templateResolver);
|
||||
|
||||
// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450.
|
||||
_back.OnClick = OnBack;
|
||||
|
|
@ -929,16 +943,21 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
// ── 0xF643 rejection dialogs (Handle_CharGenVerificationResponse @ ──
|
||||
// ── 0x0055E8B0) ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Ports the four rejection-dialog mappings from
|
||||
/// <c>Handle_CharGenVerificationResponse</c>'s per-case switch (restated
|
||||
/// on <see cref="RuntimeCharacterCreationRejection"/>'s own doc
|
||||
/// comment); Pending/Undef never reach this method (CC3's
|
||||
/// <c>ApplyCreationResponse</c> treats them as a silent state reset with
|
||||
/// no <see cref="RuntimeCharacterCreationRejection"/> produced at all).
|
||||
/// Dedups against the LAST rejection instance already shown so a
|
||||
/// same-value re-check on a later <see cref="Tick"/> (this method runs
|
||||
/// every tick, not just on revision change) doesn't reopen the dialog
|
||||
/// the player already dismissed.</summary>
|
||||
/// <summary>
|
||||
/// Ports the COMPLETE rejection-dialog mapping from
|
||||
/// <c>gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @
|
||||
/// 0x004e9030</c>'s own switch + its <c>(arg2-1) > 6</c>
|
||||
/// unsigned-underflow default arm (restated on
|
||||
/// <see cref="RuntimeCharacterCreationRejection"/>'s own doc comment).
|
||||
/// CC5 review-fix round F2 (2026-08-16): every non-Ok code now reaches
|
||||
/// this method (<c>RuntimeCharacterCreationState.ApplyCreationResponse</c>
|
||||
/// no longer special-cases Pending/Undef as a silent reset) and every
|
||||
/// branch here resolves to a real dialog — retail's dispatch has NO
|
||||
/// silent case. Dedups against the LAST rejection instance already
|
||||
/// shown so a same-value re-check on a later <see cref="Tick"/> (this
|
||||
/// method runs every tick, not just on revision change) doesn't reopen
|
||||
/// the dialog the player already dismissed.
|
||||
/// </summary>
|
||||
private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot)
|
||||
{
|
||||
RuntimeCharacterCreationRejection? rejection = snapshot.LastRejection;
|
||||
|
|
@ -951,24 +970,42 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
return;
|
||||
_lastShownRejection = rejection;
|
||||
|
||||
string? key = rejection.Value.Code switch
|
||||
// MakeErrorMessageDialog's own guard @0x004e8cc4: a context already
|
||||
// open is a no-op (the SECOND rejection's dialog is silently
|
||||
// dropped, not queued) — F4's fix, matching the four sibling
|
||||
// dialogs' shape. _lastShownRejection is already updated above even
|
||||
// when this guard blocks the dialog, which is retail-faithful: a
|
||||
// later Tick with the SAME rejection value must not retry it either
|
||||
// (this scenario is not reachable through the ordinary UI today —
|
||||
// TryBeginFinish's AlreadyPending refusal means a second Finish
|
||||
// cannot land while a rejection is still unacknowledged — but the
|
||||
// guard exists so the SHAPE matches retail's even if a future
|
||||
// caller reaches it).
|
||||
if (_errorMessageDialogContext != 0u)
|
||||
return;
|
||||
|
||||
// Pending/Corrupt/DatabaseDown are explicit switch cases in retail's
|
||||
// own dispatch landing on the SAME "ID_Character_Err_NameDBDown"
|
||||
// label; Undef and any code outside 1..7 fall through that
|
||||
// function's unsigned-underflow default arm to the identical label
|
||||
// — the `_` arm below is that default, not a "no dialog" case.
|
||||
string key = rejection.Value.Code switch
|
||||
{
|
||||
CharGenVerificationResponse.Code.NameInUse => "ID_Character_Err_NameReserved",
|
||||
CharGenVerificationResponse.Code.NameBanned => "ID_Character_Err_NameBanned",
|
||||
CharGenVerificationResponse.Code.Corrupt
|
||||
or CharGenVerificationResponse.Code.DatabaseDown => "ID_Character_Err_NameDBDown",
|
||||
CharGenVerificationResponse.Code.AdminPrivilegeDenied => "ID_Character_Err_NameAdminDenied",
|
||||
_ => null,
|
||||
_ => "ID_Character_Err_NameDBDown",
|
||||
};
|
||||
if (key is null)
|
||||
return;
|
||||
string? message = _bindings.ResolveText?.Invoke(key);
|
||||
if (message is null)
|
||||
return;
|
||||
|
||||
_dialogs.MakeMessage(message, data =>
|
||||
_errorMessageDialogContext = _dialogs.MakeMessage(message, data =>
|
||||
{
|
||||
_errorMessageDialogContext = 0u;
|
||||
_ = data;
|
||||
if (_disposed || _suppressDialogCallbacks)
|
||||
return;
|
||||
_bindings.AcknowledgeRejection?.Invoke();
|
||||
});
|
||||
}
|
||||
|
|
@ -1014,6 +1051,12 @@ internal sealed class CharacterCreationUiController : IDisposable
|
|||
_noNameWarningDialogContext = 0u;
|
||||
_dialogs.CloseDialog(closing);
|
||||
}
|
||||
if (_errorMessageDialogContext != 0u)
|
||||
{
|
||||
uint closing = _errorMessageDialogContext;
|
||||
_errorMessageDialogContext = 0u;
|
||||
_dialogs.CloseDialog(closing);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -56,18 +56,27 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// <c>CharacterGenerationVerificationResponse</c> enum
|
||||
/// (<c>ACE.Server/Network/Enum/CharacterGenerationVerificationResponse.cs</c>),
|
||||
/// which is itself retail's own dialog dispatch table
|
||||
/// (<c>Handle_CharGenVerificationResponse@0x0055E8B0</c>): <c>NameInUse</c> →
|
||||
/// <c>ID_Character_Err_NameReserved</c>, <c>NameBanned</c> →
|
||||
/// <c>ID_Character_Err_NameBanned</c>, <c>Corrupt</c>/<c>DatabaseDown</c> →
|
||||
/// <c>ID_Character_Err_NameDBDown</c>, <c>AdminPrivilegeDenied</c> →
|
||||
/// <c>ID_Character_Err_NameAdminDenied</c>. <c>Pending</c>/<c>Undef</c>
|
||||
/// retail treats as a silent state reset with no dialog — notably ACE sends
|
||||
/// (<c>Handle_CharGenVerificationResponse@0x0055E8B0</c> +
|
||||
/// <c>gmCharGenMainUI::RecvNotice_CharGenVerificationResponse@0x004e9030</c>'s
|
||||
/// own jump table). <b>CC5 review-fix round F2 (2026-08-16) correction:</b>
|
||||
/// every non-Ok code shows a dialog — there is no silent branch.
|
||||
/// <c>NameInUse</c> → <c>ID_Character_Err_NameReserved</c>,
|
||||
/// <c>NameBanned</c> → <c>ID_Character_Err_NameBanned</c>,
|
||||
/// <c>AdminPrivilegeDenied</c> → <c>ID_Character_Err_NameAdminDenied</c>,
|
||||
/// and <c>Pending</c>/<c>Corrupt</c>/<c>DatabaseDown</c>/<c>Undef</c>/any
|
||||
/// unrecognized code ALL resolve to <c>ID_Character_Err_NameDBDown</c> —
|
||||
/// <c>Pending</c> is an explicit switch case landing on that same label,
|
||||
/// and <c>Undef</c>/out-of-range falls through
|
||||
/// <c>RecvNotice_CharGenVerificationResponse</c>'s own
|
||||
/// <c>(arg2-1) > 6</c> unsigned-underflow default arm to the identical
|
||||
/// label. This corrects an earlier (wrong) reading of the decomp that
|
||||
/// treated Pending/Undef as a silent state reset — notably ACE sends
|
||||
/// <c>Pending</c> for a disabled-Olthoi rejection
|
||||
/// (<c>CharacterHandler.CharacterCreateEx</c>,
|
||||
/// <c>olthoi_play_disabled</c> branch), so that specific rejection is
|
||||
/// invisible to the retail-faithful client too; this is a retail quirk to
|
||||
/// port as-is, not a bug to fix. Dialog presentation itself is CC5's job
|
||||
/// (App layer), not this Core.Net type's.
|
||||
/// <c>olthoi_play_disabled</c> branch), so that specific rejection now
|
||||
/// correctly surfaces the NameDBDown dialog, matching retail, instead of
|
||||
/// silently resetting verification state. Dialog presentation itself is
|
||||
/// CC5's job (App layer), not this Core.Net type's.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CharGenVerificationResponse
|
||||
|
|
|
|||
|
|
@ -19,7 +19,18 @@ public sealed record GameRuntimeDependencies(
|
|||
ILiveSessionOperations? SessionOperations = null,
|
||||
Func<double>? CombatTime = null,
|
||||
uint FirstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
|
||||
int MaximumChatEntries = 500);
|
||||
int MaximumChatEntries = 500,
|
||||
// Review fix round F13 (2026-08-16): the shared RNG source for
|
||||
// process-wide randomize commands reachable from a headless bot
|
||||
// (RuntimeCharacterCreationState's Randomize* family —
|
||||
// RandomizeCharacter/RandomizeAppearance/RandomizeClothing, exposed on
|
||||
// IRuntimeCharacterCreationCommands). null (the default) keeps every
|
||||
// existing caller's production behavior unchanged (Random.Shared,
|
||||
// threaded the same way TimeProvider already is here) — this only
|
||||
// exists so a future deterministic-bot config (Slice K's contract,
|
||||
// project_linux_headless_bots.md) can supply a seeded Random without a
|
||||
// second construction path.
|
||||
Random? Random = null);
|
||||
|
||||
[Flags]
|
||||
public enum GameRuntimeTeardownStage
|
||||
|
|
@ -180,10 +191,12 @@ public sealed class GameRuntime
|
|||
context.Session = dependencies.SessionOperations is null
|
||||
? new LiveSessionController(
|
||||
ProductionLiveSessionOperations.Instance,
|
||||
dependencies.TimeProvider)
|
||||
dependencies.TimeProvider,
|
||||
random: dependencies.Random)
|
||||
: new LiveSessionController(
|
||||
dependencies.SessionOperations,
|
||||
dependencies.TimeProvider);
|
||||
dependencies.TimeProvider,
|
||||
random: dependencies.Random);
|
||||
construction.Own(context.Session);
|
||||
Fault(
|
||||
GameRuntimeConstructionPoint.SessionCreated,
|
||||
|
|
|
|||
|
|
@ -455,7 +455,13 @@ public sealed class LiveSessionController
|
|||
public LiveSessionController(
|
||||
ILiveSessionOperations operations,
|
||||
TimeProvider? timeProvider = null,
|
||||
ChargenOptions? chargenOptions = null)
|
||||
ChargenOptions? chargenOptions = null,
|
||||
// Review fix round F13 (2026-08-16): threaded from
|
||||
// GameRuntimeDependencies.Random the same way timeProvider already
|
||||
// is — null keeps every existing caller (including this class's own
|
||||
// parameterless ctor below) on RuntimeCharacterCreationState's own
|
||||
// Random.Shared default.
|
||||
Random? random = null)
|
||||
{
|
||||
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
||||
CharacterSelectionState = new RuntimeCharacterSelectionState(
|
||||
|
|
@ -466,7 +472,7 @@ public sealed class LiveSessionController
|
|||
// not this one. A caller that never supplies real options simply
|
||||
// gets an inert chargen surface (every heritage lookup misses).
|
||||
CharacterCreationState = new RuntimeCharacterCreationState(
|
||||
chargenOptions ?? ChargenOptions.Empty);
|
||||
chargenOptions ?? ChargenOptions.Empty, random);
|
||||
}
|
||||
|
||||
public RuntimeCharacterSelectionState CharacterSelectionState { get; }
|
||||
|
|
|
|||
|
|
@ -163,18 +163,25 @@ public readonly record struct RuntimeCharacterCreationIdentity(
|
|||
|
||||
/// <summary>
|
||||
/// A non-Ok <c>0xF643</c> response, mapped to retail's dialog family
|
||||
/// (<c>Handle_CharGenVerificationResponse @ 0x0055E8B0</c>'s per-case dialog
|
||||
/// dispatch, restated in <see cref="CharGenVerificationResponse"/>'s doc
|
||||
/// comment): <see cref="CharGenVerificationResponse.Code.NameInUse"/> →
|
||||
/// NameReserved, <see cref="CharGenVerificationResponse.Code.NameBanned"/> →
|
||||
/// NameBanned, <see cref="CharGenVerificationResponse.Code.Corrupt"/> /
|
||||
/// <see cref="CharGenVerificationResponse.Code.DatabaseDown"/> → NameDBDown,
|
||||
/// <see cref="CharGenVerificationResponse.Code.AdminPrivilegeDenied"/> →
|
||||
/// NameAdminDenied. <see cref="CharGenVerificationResponse.Code.Pending"/> /
|
||||
/// <see cref="CharGenVerificationResponse.Code.Undef"/> never produce this
|
||||
/// record — retail treats them as a silent state reset with no dialog (ACE
|
||||
/// sends <c>Pending</c> for a disabled-Olthoi rejection; this is a genuine
|
||||
/// retail quirk, not a bug — port as-is).
|
||||
/// (<c>Handle_CharGenVerificationResponse @ 0x0055E8B0</c> +
|
||||
/// <c>gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @
|
||||
/// 0x004e9030</c>'s own switch/jump-table dispatch, restated in
|
||||
/// <see cref="CharGenVerificationResponse"/>'s doc comment).
|
||||
/// <b>CC5 review-fix round F2 (2026-08-16) correction:</b> ALL non-Ok codes
|
||||
/// produce this record — <see cref="CharGenVerificationResponse.Code.NameInUse"/>
|
||||
/// → NameReserved, <see cref="CharGenVerificationResponse.Code.NameBanned"/> →
|
||||
/// NameBanned, <see cref="CharGenVerificationResponse.Code.AdminPrivilegeDenied"/> →
|
||||
/// NameAdminDenied, and <see cref="CharGenVerificationResponse.Code.Pending"/> /
|
||||
/// <see cref="CharGenVerificationResponse.Code.Corrupt"/> /
|
||||
/// <see cref="CharGenVerificationResponse.Code.DatabaseDown"/> /
|
||||
/// <see cref="CharGenVerificationResponse.Code.Undef"/> / any unrecognized
|
||||
/// code ALL → NameDBDown (Pending is an explicit switch case landing on
|
||||
/// that same label; Undef/out-of-range falls through the function's own
|
||||
/// unsigned-underflow default arm to the identical label). An earlier
|
||||
/// reading of the decomp treated Pending/Undef as producing a silent state
|
||||
/// reset with NO record and no dialog — that was wrong; retail's dispatch
|
||||
/// has no silent branch (ACE sends <c>Pending</c> for a disabled-Olthoi
|
||||
/// rejection, which now correctly surfaces the NameDBDown dialog).
|
||||
/// </summary>
|
||||
public readonly record struct RuntimeCharacterCreationRejection(
|
||||
uint RawCode,
|
||||
|
|
@ -1253,6 +1260,27 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>CharGenState::GetRandomReal @ 0x00563940</c> exactly:
|
||||
/// <c>(double)rand() * (1.0/32767.0)</c>. Review fix round F8
|
||||
/// (2026-08-16), byte-decoded from the raw PE: the pseudo-C shows only
|
||||
/// <c>return rand(this);</c> (the decompiler elided the FPU multiply
|
||||
/// entirely), but the actual machine code is
|
||||
/// <c>call rand; fild [esp]; fmul qword ptr [0x007cd650]; ret</c> — an
|
||||
/// 8-BYTE double-precision operand (<c>fmul qword</c>, not <c>dword</c>).
|
||||
/// The bytes at <c>0x007cd650</c> are <c>80 00 40 00 20 00 00 3f</c>,
|
||||
/// which as a little-endian IEEE-754 double is EXACTLY
|
||||
/// <c>1.0/32767.0</c> (bit pattern <c>0x3f00002000400080</c>) — NOT
|
||||
/// <c>1.0/32768.0</c> (which would be <c>0x3f00000000000000</c>), a
|
||||
/// prior narrower reading corrects here. Retail's CRT <c>rand()</c>
|
||||
/// returns <c>[0, RAND_MAX]</c> with <c>RAND_MAX == 0x7FFF == 32767</c>
|
||||
/// (MSVC), so the shade roll is a 32768-point lattice on
|
||||
/// <c>[0.0, 1.0]</c> INCLUSIVE (both endpoints reachable) —
|
||||
/// <see cref="Random.NextDouble()"/>'s continuous <c>[0, 1)</c> is a
|
||||
/// different distribution entirely.
|
||||
/// </summary>
|
||||
private double RollShadeLocked() => _random.Next(32768) * (1.0 / 32767.0);
|
||||
|
||||
/// <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>
|
||||
|
|
@ -1261,8 +1289,8 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
/// 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->GetRandomReal()</c>
|
||||
/// — the SAME <c>rand()*(1/32768)</c> uniform-[0,1) shade roll every
|
||||
/// other Randomize* function below uses explicitly inline.</summary>
|
||||
/// — the SAME <see cref="RollShadeLocked"/> shade roll every other
|
||||
/// Randomize* function below uses.</summary>
|
||||
private void RandomizeAppearanceLocked()
|
||||
{
|
||||
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
|
||||
|
|
@ -1275,7 +1303,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
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() };
|
||||
a = a with { SkinShade = RollShadeLocked(), HairShade = RollShadeLocked() };
|
||||
if (gender.HairColors.Count > 0)
|
||||
a = a with { HairColor = RandomizeIndexExcludingLocked(gender.HairColors.Count, a.HairColor) };
|
||||
if (gender.EyeColors.Count > 0)
|
||||
|
|
@ -1328,7 +1356,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
HeadgearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.HeadgearColor),
|
||||
};
|
||||
}
|
||||
_appearance = _appearance with { HeadgearShade = _random.NextDouble() };
|
||||
_appearance = _appearance with { HeadgearShade = RollShadeLocked() };
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>CharGenState::RandomizeShirt @ 0x005c5ef0</c> —
|
||||
|
|
@ -1354,7 +1382,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
ShirtColor = RandomizeIndexExcludingLocked(colorCount, _appearance.ShirtColor),
|
||||
};
|
||||
}
|
||||
_appearance = _appearance with { ShirtShade = _random.NextDouble() };
|
||||
_appearance = _appearance with { ShirtShade = RollShadeLocked() };
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>CharGenState::RandomizeTrousers @ 0x005c5fb0</c>.</summary>
|
||||
|
|
@ -1378,7 +1406,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
TrousersColor = RandomizeIndexExcludingLocked(colorCount, _appearance.TrousersColor),
|
||||
};
|
||||
}
|
||||
_appearance = _appearance with { TrousersShade = _random.NextDouble() };
|
||||
_appearance = _appearance with { TrousersShade = RollShadeLocked() };
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>CharGenState::RandomizeFootwear @ 0x005c6070</c>.</summary>
|
||||
|
|
@ -1402,7 +1430,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
FootwearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.FootwearColor),
|
||||
};
|
||||
}
|
||||
_appearance = _appearance with { FootwearShade = _random.NextDouble() };
|
||||
_appearance = _appearance with { FootwearShade = RollShadeLocked() };
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>CharGenState::RandomizeClothing(this, arg2) @
|
||||
|
|
@ -1486,6 +1514,18 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
ClearSessionState();
|
||||
|
||||
uint heritageId = (uint)RollDiceLocked(1, 4);
|
||||
// Review fix round F14 (2026-08-16): retail's SetHeritageGroup @
|
||||
// 0x005C67A0 writes `this->mHeritageGroup = arg2;` UNCONDITIONALLY
|
||||
// as its very first statement, before the DAT lookup
|
||||
// (ACCharGenData::GetHG) that gates the credit/template/start-area
|
||||
// recompute. Assign the raw field here too, before the
|
||||
// TryGetHeritage gate below, so a hypothetical DAT-lookup miss
|
||||
// leaves `_heritageId` set (matching retail's unconditional write)
|
||||
// instead of the previous half-state where heritage stayed 0 while
|
||||
// SetGenderLocked below still ran and produced a real gender.
|
||||
// Unreachable with the installed DAT — every rolled id 1..4 always
|
||||
// resolves — this is defensive shape-parity only.
|
||||
_heritageId = heritageId;
|
||||
if (_options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
|
||||
SetHeritageGroupLocked(heritageId, heritage);
|
||||
|
||||
|
|
@ -1765,11 +1805,23 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ports the four rejection dialog mappings + the silent
|
||||
/// Pending/Undef reset from <c>Handle_CharGenVerificationResponse @
|
||||
/// 0x0055E8B0</c>. Idempotent-tolerant to a second, unrequested Ok/reject
|
||||
/// while nothing is pending (ACE's own double-NameInUse quirk, CC2
|
||||
/// review F3) — a call that arrives while
|
||||
/// Ports the full rejection-dialog dispatch from
|
||||
/// <c>Handle_CharGenVerificationResponse @ 0x0055E8B0</c> +
|
||||
/// <c>gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @
|
||||
/// 0x004e9030</c>'s own jump table. CC5 review-fix round F2
|
||||
/// (2026-08-16): every non-Ok code produces a
|
||||
/// <see cref="RuntimeCharacterCreationRejection"/> — there is no silent
|
||||
/// branch. Pending previously short-circuited as a bare state reset
|
||||
/// with no rejection produced; that was a misreading of the decomp
|
||||
/// (Pending is an explicit switch case in
|
||||
/// <c>RecvNotice_CharGenVerificationResponse</c> landing on the SAME
|
||||
/// <c>ID_Character_Err_NameDBDown</c> label as Corrupt/DatabaseDown,
|
||||
/// and Undef/any out-of-range code falls through that function's own
|
||||
/// <c>(arg2-1) > 6</c> unsigned-underflow default arm to the
|
||||
/// identical label) — see the <c>else</c> branch's own inline comment
|
||||
/// for the full citation. Idempotent-tolerant to a second, unrequested
|
||||
/// Ok/reject while nothing is pending (ACE's own double-NameInUse
|
||||
/// quirk, CC2 review F3) — a call that arrives while
|
||||
/// <see cref="RuntimeCharacterCreationSnapshot.VerificationPending"/> is
|
||||
/// already false is a no-op rather than a second event.
|
||||
///
|
||||
|
|
@ -1777,13 +1829,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
/// Campaign CC slice CC3 review-fix round (F6): every branch below only
|
||||
/// SETS <c>kind</c> inside <c>lock (_gate)</c>; the single
|
||||
/// <see cref="Publish"/> call happens once, after the lock releases —
|
||||
/// matching every other public method in this class. The Pending/Undef
|
||||
/// branch previously published from inside the lock (harmless on its
|
||||
/// own — <see cref="Publish"/>'s own <c>lock (_gate)</c> is reentrant on
|
||||
/// the same thread — but inconsistent with the rest of the class and a
|
||||
/// lock-ordering risk once an observer callback reaches back into
|
||||
/// caller-held locks, e.g. <c>LiveSessionController._gate</c>, while
|
||||
/// still inside this one).
|
||||
/// matching every other public method in this class.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal void ApplyCreationResponse(CharGenVerificationResponse.Parsed response)
|
||||
|
|
@ -1803,15 +1849,29 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
_lastRejection = null;
|
||||
kind = RuntimeCharacterCreationDeltaKind.Created;
|
||||
}
|
||||
else if (response.AsCode is CharGenVerificationResponse.Code.Pending
|
||||
or CharGenVerificationResponse.Code.Undef)
|
||||
{
|
||||
// Silent state reset — retail shows no dialog (ACE sends
|
||||
// Pending for a disabled-Olthoi rejection; port as-is).
|
||||
kind = RuntimeCharacterCreationDeltaKind.StateChanged;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Review fix round F2 (2026-08-16): Pending/Undef used to
|
||||
// short-circuit here as a silent state reset with no
|
||||
// rejection produced — that was WRONG. Byte-decoded
|
||||
// gmCharGenMainUI::RecvNotice_CharGenVerificationResponse
|
||||
// @0x004e9030's own dispatch: Pending(2) is an explicit
|
||||
// switch case landing on the SAME "ID_Character_Err_
|
||||
// NameDBDown" label as Corrupt/DatabaseDown, and Undef(0)
|
||||
// (plus any code outside 1..7) falls through the function's
|
||||
// own "(arg2-1) > 6" unsigned-underflow default arm to that
|
||||
// identical label — retail's dispatch has NO silent branch
|
||||
// at all; every non-Ok code shows a dialog. Falling through
|
||||
// to this general rejection branch (instead of a special
|
||||
// silent-reset arm) now produces a real
|
||||
// RuntimeCharacterCreationRejection for Pending/Undef too,
|
||||
// which CharacterCreationUiController.ReconcileDialogs maps
|
||||
// to that same NameDBDown dialog (see its own doc comment).
|
||||
// Concrete effect: ACE's disabled-Olthoi Pending rejection
|
||||
// (CharacterHandler.CharacterCreateEx's olthoi_play_disabled
|
||||
// branch) now surfaces a visible dialog instead of silently
|
||||
// resetting verification state with Finish becoming a
|
||||
// permanent no-op.
|
||||
string reason = response.AsCode.ToString();
|
||||
_lastRejection = new RuntimeCharacterCreationRejection(
|
||||
response.RawCode,
|
||||
|
|
|
|||
|
|
@ -1055,6 +1055,64 @@ public sealed class CharacterCreationUiControllerTests
|
|||
Assert.False(field.CharacterFilter!('$'));
|
||||
}
|
||||
|
||||
// ── CC5 review fix round F12(d): RebuildListbox content ─────────────
|
||||
|
||||
/// <summary>
|
||||
/// F12(d) (2026-08-16): pins the F3 fix directly against the listbox's
|
||||
/// actual built rows (not just the header/pair TEMPLATE ids the fixture
|
||||
/// wires) — a skill row uses the key/value pair template (KEY = skill
|
||||
/// name, VALUE = <see cref="FakeRuntime.GetSkillScore"/>'s deterministic
|
||||
/// stand-in), and BOTH bucket headers appear even though only ONE bucket
|
||||
/// (Trained) has a matching skill — the case that actually exercises
|
||||
/// "unconditional" (a header shown only because some row happened to
|
||||
/// match would have passed even with the pre-fix lazy-header bug).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Summary_RebuildListbox_SkillRows_UseKeyValueTemplate_WithUnconditionalHeaders()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
environment.Controller.Open();
|
||||
SelectAluvianMale(environment);
|
||||
// SkillTrainOnly (1, "Axe") is Trained; SkillSpecializable (2,
|
||||
// "Bow") is left Inactive entirely — no skill occupies the
|
||||
// Specialized bucket.
|
||||
environment.Runtime.View.SetSkillLevel(SkillTrainOnly, ChargenSkillAdvancementClass.Trained);
|
||||
GoToSummary(environment);
|
||||
|
||||
UiTemplateListBox listBox = environment.SummaryListBox();
|
||||
IReadOnlyList<UiElement> rows = Assert.IsType<UiScrollablePanel>(
|
||||
listBox.ViewportForTest).Children;
|
||||
|
||||
// Ids match CharacterCreationSummaryPage's own private
|
||||
// HeaderTextId/KeyTextId/ValueTextId constants — the same literals
|
||||
// BuildSummaryHeaderTemplate/BuildSummaryPairTemplate below already
|
||||
// hardcode for the fixture's row templates.
|
||||
const uint headerTextId = 0x100000FEu;
|
||||
const uint keyTextId = 0x100002FCu;
|
||||
const uint valueTextId = 0x100002FDu;
|
||||
|
||||
var headers = new List<string>();
|
||||
var pairs = new List<(string Key, string Value)>();
|
||||
foreach (UiElement row in rows)
|
||||
{
|
||||
if (UiElement.FindDescendant(row, headerTextId) is UiText header)
|
||||
headers.Add(JoinedText(header));
|
||||
else if (UiElement.FindDescendant(row, keyTextId) is UiText key
|
||||
&& UiElement.FindDescendant(row, valueTextId) is UiText value)
|
||||
{
|
||||
pairs.Add((JoinedText(key), JoinedText(value)));
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Contains("Specialized Skills", headers);
|
||||
Assert.Contains("Trained Skills", headers);
|
||||
Assert.Single(pairs, p => p.Key == "Axe" && p.Value == "10");
|
||||
Assert.DoesNotContain(pairs, p => p.Key == "Bow");
|
||||
}
|
||||
|
||||
private static string JoinedText(UiText text) =>
|
||||
string.Join(" ", text.LinesProvider().Select(static line => line.Text));
|
||||
|
||||
// ── CC5: 0xF643 rejection dialogs ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1082,6 +1140,60 @@ public sealed class CharacterCreationUiControllerTests
|
|||
Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F12(c) (CC5 review-fix round, 2026-08-16): a SECOND rejection with
|
||||
/// IDENTICAL field values (same RawCode/Code/Reason/AttemptedName —
|
||||
/// e.g. the player retried Finish with the SAME already-taken name)
|
||||
/// must still show the dialog. <c>ReconcileDialogs</c>' own
|
||||
/// <c>_lastShownRejection</c> dedup only suppresses re-showing a value
|
||||
/// that is STILL the current <c>LastRejection</c> across ticks
|
||||
/// (<see cref="CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog"/>);
|
||||
/// once <see cref="FakeRuntime.AcknowledgeRejection"/> nulls the
|
||||
/// snapshot's rejection (mirroring the real
|
||||
/// <c>RuntimeCharacterCreationState.TryAcknowledgeRejection</c>),
|
||||
/// <c>_lastShownRejection</c> resets to null too, so the identical
|
||||
/// value arriving a second time is treated as new.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreationFailed_IdenticalRejectionAfterAcknowledge_ReshowsTheDialog()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
environment.Runtime.ResolvedStrings["ID_Character_Err_NameReserved"] = "That name is in use.";
|
||||
environment.Controller.Open();
|
||||
|
||||
var rejection = new RuntimeCharacterCreationRejection(
|
||||
3u, CharGenVerificationResponse.Code.NameInUse, "NameInUse", "Adventurer");
|
||||
|
||||
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = rejection };
|
||||
BumpRevisionAndTick(environment);
|
||||
Assert.True(environment.Dialogs.IsOpen);
|
||||
|
||||
environment.DismissActiveMessageDialog();
|
||||
Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls);
|
||||
Assert.False(environment.Dialogs.IsOpen);
|
||||
Assert.Null(environment.Runtime.View.Snapshot.LastRejection);
|
||||
|
||||
// One intervening Tick with LastRejection == null — exactly what
|
||||
// happens continuously in the real game loop between the
|
||||
// acknowledge callback and the player's next Finish attempt — lets
|
||||
// ReconcileDialogs' own `if (rejection is null) _lastShownRejection
|
||||
// = null;` branch run BEFORE the identical value arrives again.
|
||||
// Without this, _lastShownRejection still holds the acknowledged
|
||||
// value and the dedup would (correctly, per its OWN contract)
|
||||
// suppress a value that never actually went away in between.
|
||||
environment.Controller.Tick();
|
||||
|
||||
// The identical rejection value arrives again.
|
||||
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = rejection };
|
||||
BumpRevisionAndTick(environment);
|
||||
|
||||
Assert.True(environment.Dialogs.IsOpen);
|
||||
Assert.Equal("That name is in use.", environment.LastDialogMessage());
|
||||
|
||||
environment.DismissActiveMessageDialog();
|
||||
Assert.Equal(2, environment.Runtime.AcknowledgeRejectionCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog()
|
||||
{
|
||||
|
|
@ -1188,6 +1300,9 @@ public sealed class CharacterCreationUiControllerTests
|
|||
public UiTemplateListBox SkillsList() =>
|
||||
Assert.IsType<UiTemplateListBox>(Screen.FindElement(0x100003F7u));
|
||||
|
||||
public UiTemplateListBox SummaryListBox() =>
|
||||
Assert.IsType<UiTemplateListBox>(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId));
|
||||
|
||||
public UiScrollbar ShadeScroll() =>
|
||||
Assert.IsType<UiScrollbar>(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId));
|
||||
|
||||
|
|
@ -1278,9 +1393,19 @@ public sealed class CharacterCreationUiControllerTests
|
|||
RandomizeCharacter: RandomizeCharacter,
|
||||
RandomizeAppearance: () => { RandomizeAppearanceCalls++; return Result(RuntimeCommandStatus.Accepted); },
|
||||
RandomizeClothing: () => { RandomizeClothingCalls++; return Result(RuntimeCommandStatus.Accepted); },
|
||||
GetSkillScore: GetSkillScore,
|
||||
OpenOnStart: false);
|
||||
}
|
||||
|
||||
/// <summary>F3/F12(d) (CC5 review-fix round, 2026-08-16): deterministic
|
||||
/// stand-in for <c>RetailSkillFormula.CalculateChargenScore</c> —
|
||||
/// <c>skillId * 10</c> so tests can assert an exact, unambiguous value
|
||||
/// without needing a real SkillTable.</summary>
|
||||
private static uint GetSkillScore(
|
||||
uint skillId,
|
||||
ChargenAttributeValues attributes,
|
||||
ChargenSkillAdvancementClass level) => skillId * 10u;
|
||||
|
||||
public FakeView View { get; }
|
||||
public CharacterCreationRuntimeBindings Bindings { get; }
|
||||
public bool ProvideView { get; set; } = true;
|
||||
|
|
|
|||
|
|
@ -668,6 +668,18 @@ public sealed class RuntimeCharacterCreationStateTests
|
|||
[InlineData(CharGenVerificationResponse.Code.Corrupt)]
|
||||
[InlineData(CharGenVerificationResponse.Code.DatabaseDown)]
|
||||
[InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)]
|
||||
// CC5 review-fix round F2 (2026-08-16): Pending/Undef used to be
|
||||
// asserted as a SILENT reset producing no rejection at all
|
||||
// (ApplyCreationResponse_PendingOrUndef_IsASilentResetWithNoRejection,
|
||||
// now deleted) — that assertion was wrong. Byte-decoded
|
||||
// gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030:
|
||||
// Pending is an explicit switch case landing on the SAME
|
||||
// "ID_Character_Err_NameDBDown" label as Corrupt/DatabaseDown, and
|
||||
// Undef falls through that function's own unsigned-underflow default
|
||||
// arm to the identical label — retail's dispatch has no silent branch.
|
||||
// Both now belong in this same "produces a rejection" theory.
|
||||
[InlineData(CharGenVerificationResponse.Code.Pending)]
|
||||
[InlineData(CharGenVerificationResponse.Code.Undef)]
|
||||
public void ApplyCreationResponse_EachRejectionCode_RecordsTheMappingAndAttemptedName(
|
||||
CharGenVerificationResponse.Code code)
|
||||
{
|
||||
|
|
@ -685,24 +697,6 @@ public sealed class RuntimeCharacterCreationStateTests
|
|||
Assert.Null(state.Snapshot.LastCreated);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CharGenVerificationResponse.Code.Pending)]
|
||||
[InlineData(CharGenVerificationResponse.Code.Undef)]
|
||||
public void ApplyCreationResponse_PendingOrUndef_IsASilentResetWithNoRejection(
|
||||
CharGenVerificationResponse.Code code)
|
||||
{
|
||||
// ACE sends Pending for a disabled-Olthoi rejection — retail shows
|
||||
// no dialog. Port as-is.
|
||||
RuntimeCharacterCreationState state = PendingState(out _);
|
||||
|
||||
state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed(
|
||||
(uint)code, null, null, null));
|
||||
|
||||
Assert.False(state.Snapshot.VerificationPending);
|
||||
Assert.Null(state.Snapshot.LastRejection);
|
||||
Assert.Null(state.Snapshot.LastCreated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyCreationResponse_DuplicateReplyWhileNotPending_IsIgnored()
|
||||
{
|
||||
|
|
@ -791,6 +785,54 @@ public sealed class RuntimeCharacterCreationStateTests
|
|||
// RandomizeTemplate @ 0x005c6500's RandInt(count-1,...)+1 shape.
|
||||
Assert.NotEqual(0u, snapshot.Template);
|
||||
Assert.True(snapshot.StartArea is 0 or 1);
|
||||
|
||||
// F8 (2026-08-16): every shade is RollShadeLocked's 32768-point
|
||||
// lattice on [0.0, 1.0] INCLUSIVE (rand() in [0, 32767] * (1/32767)),
|
||||
// never System.Random.NextDouble()'s continuous [0, 1).
|
||||
Assert.InRange(snapshot.Appearance.SkinShade, 0.0, 1.0);
|
||||
Assert.InRange(snapshot.Appearance.HairShade, 0.0, 1.0);
|
||||
Assert.InRange(snapshot.Appearance.HeadgearShade, 0.0, 1.0);
|
||||
Assert.InRange(snapshot.Appearance.ShirtShade, 0.0, 1.0);
|
||||
Assert.InRange(snapshot.Appearance.TrousersShade, 0.0, 1.0);
|
||||
Assert.InRange(snapshot.Appearance.FootwearShade, 0.0, 1.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F8 (2026-08-16): pins <see cref="RuntimeCharacterCreationState"/>'s
|
||||
/// private <c>RollShadeLocked</c> lattice (<c>rand() in [0,32767] *
|
||||
/// (1.0/32767.0)</c>) deterministically via a fixed <see cref="Random"/>
|
||||
/// double that always returns its <c>maxValue - 1</c>, confirming the
|
||||
/// lattice's upper endpoint is EXACTLY reachable as 1.0 — a continuous
|
||||
/// <see cref="Random.NextDouble"/>-style roll ([0, 1)) could never
|
||||
/// produce that value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryRandomizeCharacter_ShadeLattice_ReachesExactlyOneAtRandomMax()
|
||||
{
|
||||
var state = new RuntimeCharacterCreationState(
|
||||
RuntimeCharacterCreationStateFixture.Build(),
|
||||
new MaxValueRandom());
|
||||
state.Begin(new RuntimeGenerationToken(1));
|
||||
|
||||
Assert.True(state.TryRandomizeCharacter());
|
||||
|
||||
RuntimeCharacterCreationAppearance a = state.Snapshot.Appearance;
|
||||
Assert.Equal(1.0, a.SkinShade);
|
||||
Assert.Equal(1.0, a.HairShade);
|
||||
Assert.Equal(1.0, a.HeadgearShade);
|
||||
Assert.Equal(1.0, a.ShirtShade);
|
||||
Assert.Equal(1.0, a.TrousersShade);
|
||||
Assert.Equal(1.0, a.FootwearShade);
|
||||
}
|
||||
|
||||
/// <summary>Always returns <c>maxValue - 1</c> — the highest value
|
||||
/// <see cref="Random.Next(int)"/>'s contract permits for any bound, so
|
||||
/// every ordinary index pick in the randomize chain stays in-bounds
|
||||
/// while the shade rolls (<c>Next(32768)</c>) land on 32767, the shade
|
||||
/// lattice's top rung.</summary>
|
||||
private sealed class MaxValueRandom : Random
|
||||
{
|
||||
public override int Next(int maxValue) => maxValue - 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -894,4 +936,66 @@ public sealed class RuntimeCharacterCreationStateTests
|
|||
|
||||
Assert.Equal(0u, state.Snapshot.Appearance.ShirtStyle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F12(a) (CC5 review-fix round, 2026-08-16): pins
|
||||
/// <c>RandomizeIndexExcludingLocked</c>'s exclude-current re-roll
|
||||
/// property DETERMINISTICALLY. The fixture's <c>HairStyles</c>/
|
||||
/// <c>HairColors</c>/<c>EyeColors</c> lists are all COUNT 2
|
||||
/// (<see cref="RuntimeCharacterCreationStateFixture.Build"/>'s shared
|
||||
/// gender record), so excluding the current index leaves exactly ONE
|
||||
/// possible outcome — a second <see cref="RuntimeCharacterCreationState.TryRandomizeAppearance"/>
|
||||
/// call must flip every one of these three fields to the OTHER index,
|
||||
/// regardless of which <see cref="Random"/> seed drives the roll. Two
|
||||
/// different seeds both proving the flip is what makes this a property
|
||||
/// pin rather than a single-seed coincidence.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(999)]
|
||||
public void TryRandomizeAppearance_ExcludeCurrent_OnCountTwoLists_AlwaysFlips(int seed)
|
||||
{
|
||||
var state = new RuntimeCharacterCreationState(
|
||||
RuntimeCharacterCreationStateFixture.Build(),
|
||||
new Random(seed));
|
||||
state.Begin(new RuntimeGenerationToken(1));
|
||||
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
||||
|
||||
Assert.True(state.TryRandomizeAppearance());
|
||||
RuntimeCharacterCreationAppearance first = state.Snapshot.Appearance;
|
||||
|
||||
Assert.True(state.TryRandomizeAppearance());
|
||||
RuntimeCharacterCreationAppearance second = state.Snapshot.Appearance;
|
||||
|
||||
Assert.True(first.HairStyle is 0u or 1u);
|
||||
Assert.True(first.HairColor is 0u or 1u);
|
||||
Assert.True(first.EyeColor is 0u or 1u);
|
||||
Assert.NotEqual(first.HairStyle, second.HairStyle);
|
||||
Assert.NotEqual(first.HairColor, second.HairColor);
|
||||
Assert.NotEqual(first.EyeColor, second.EyeColor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F12(b) (CC5 review-fix round, 2026-08-16): pins
|
||||
/// <c>RandomizeCharacterLocked</c>'s own <c>ClearSessionState</c>
|
||||
/// prologue (retail's own <c>Reset()</c> call) at the RUNTIME layer —
|
||||
/// the Summary page's Random button must clear a committed name, which
|
||||
/// is exactly the state transition <c>CharacterCreationSummaryPage</c>'s
|
||||
/// F1 fix (the field-sync <c>_suppressNextFieldEvent</c> removal) has to
|
||||
/// coexist with correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryRandomizeCharacter_ClearsAPreviouslyCommittedName()
|
||||
{
|
||||
RuntimeCharacterCreationState state = CreateActive();
|
||||
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
||||
Assert.True(state.TrySetName("Bob"));
|
||||
Assert.Equal("Bob", state.Snapshot.Name);
|
||||
|
||||
Assert.True(state.TryRandomizeCharacter());
|
||||
|
||||
Assert.Equal(string.Empty, state.Snapshot.Name);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue