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