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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue