fix(runtime): Campaign CC slice CC3 review-fix round — F1-F16

Opus dual-lens review of CC3's RuntimeCharacterCreationState passed on
retail fidelity but failed the controller integration: the post-create
log-straight-in indexed the CACHED wire CharacterList, which ACE never
resends after a create (it only appends server-side and replies Ok) —
with zero pre-existing characters this throws, with N it can silently
enter the WRONG character. The same stale-index problem corrupted every
pre-existing character's delete slot on roster re-sort. Fixes all four
blocking findings plus a credit-gate correctness bug (retail warns and
lets the user confirm through unspent credits; it does not force a full
spend) and eight lower-severity findings from the same review round.

F1 (blocking): WorldSession gained a guid-based EnterWorld(uint,string,
TimeSpan?) overload sharing EnterWorldCore with the index-based one;
ILiveSessionOperations gained a default EnterWorldByGuid method.
LiveSessionController factored EnterSelectedCore/the new
EnterCreatedCharacterCore through a shared EnterHighlightedCore so the
post-create enter sends by the exact guid the 0xF643 Ok reply carried,
never by a roster index.

F2 (blocking): RuntimeCharacterSelectionState gained a real
AppendCreatedCharacter primitive that preserves every existing entry's
ActiveIndex (a wire contract — SendDeleteCharacter sends it as the
CharacterSet slot) and assigns the new entry's from the pre-create wire
roster count, instead of round-tripping the post-create roster through
ApplyRoster's name-sort-and-renumber.

F3 (blocking): retail's DoFinish(this, arg2) gate is
"arg2 != 0 && remainingAtrbCredits > 0" — the ordinary click warns and
refuses, but the warning dialog's own confirm re-invokes DoFinish(this,
0), which sends anyway with credits unspent (ACE accepts this).
TryBeginFinish/Finish gained a confirmedUnspentCredits parameter; the
plan doc's "retail FORCES full spend" line is corrected in the same
commit.

F4 (blocking): a stale out-of-range template index surviving a heritage
switch to a heritage with fewer templates now clears to TemplateUnset,
matching ConstrainAllByHeritage's clamp.

F5/F9/F10: three register-row/doc citation corrections (AP-207's real
FitTemplateToCharacter call sites — a fourth one the original filing
also missed; the Slot field's real retail assignment source; AP-209's
classID branch table for Olthoi/OlthoiAcid). F6: ApplyCreationResponse
no longer publishes from inside the owner lock. F7: two new tests pin
BalanceAttributes' persistent donor cursor (successive-overspend
advance, Self-to-Strength wrap). F8: ResetSkillLevels' doc corrected to
retail's real both-costs->=0 gate. F11: the integration test fixture
captures guid-based enter calls and uses two pre-existing characters
whose wire order differs from alphabetical order, so the roster
assertion actually exercises F2 instead of coinciding with it by
accident. F12: filed register row AP-211 for the client-side RosterFull
slot-cap refusal (no retail DoFinish-layer counterpart). F13: narrowed
Finish's bare catch to InvalidOperationException/SocketException and
bound _scope to a local. F15: RandomizeStartAreaLocked leaves the start
area unchanged on an empty list instead of forcing -1, matching retail.

Runtime 1706/0 (was 1701), Core.Net unchanged at 994/0, full solution
Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 15:10:43 +02:00
parent 9a84230c4f
commit 397ccd62cd
9 changed files with 578 additions and 68 deletions

View file

@ -1149,12 +1149,54 @@ public sealed class WorldSession : IDisposable
{
if (Characters is null || Characters.Characters.Count == 0)
throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList");
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
EnterWorldSelection selection = SelectCharacterForEnterWorld(
Characters,
characterIndex);
CharacterList.Character chosen = selection.Character;
_activeCharacterId = chosen.Id;
EnterWorldCore(selection.Character.Id, selection.EnterWorldBody, timeout);
}
/// <summary>
/// Send CharacterEnterWorldRequest and CharacterEnterWorld for the exact
/// (guid, accountName) identity the caller supplies, bypassing the
/// cached <see cref="Characters"/> roster entirely. Campaign CC slice
/// CC3 review-fix round (F1): the index-based overload above assumes
/// <paramref name="characterIndex"/> refers to a slot in
/// <see cref="Characters"/> — true for ordinary character-select entry,
/// but FALSE immediately after a character create. ACE never resends
/// <see cref="CharacterList"/> post-create (it only appends server-side
/// and replies with the <c>0xF643</c> Ok identity —
/// <c>references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:170-172</c>),
/// so entering the newly created character by a re-derived index can
/// throw (zero pre-existing characters) or silently enter the WRONG
/// character (N pre-existing characters, since the caller's display
/// order need not match the wire order). Retail's own
/// <c>CPlayerSystem::LogOnCharacter(gid)</c> is itself guid-based, so
/// this is a more direct port of the same entry point — not a
/// deviation from retail — for the one caller (enter-straight-in after
/// create) that has an exact identity in hand and no reliable index.
///
/// <para>
/// Retail's own fallback when the freshly created name never appears in
/// its per-frame roster poll (<c>gmCharGenMainUI::Update @
/// 0x004E8460</c>) bounces the UI back to character management
/// (<c>QueueUIMode(0x1000000a) @ 0x004E85D7</c>). acdream has no
/// analogous fallback here because this entry point is driven directly
/// by the identity carried on the SAME reply that confirms the create
/// succeeded — there is no polling step that could fail to find the
/// name, so there is nothing for a fallback to catch.
/// </para>
/// </summary>
public void EnterWorld(uint characterGuid, string accountName, TimeSpan? timeout = null)
{
ArgumentNullException.ThrowIfNull(accountName);
byte[] enterWorldBody = CharacterEnterWorld.BuildEnterWorldBody(characterGuid, accountName);
EnterWorldCore(characterGuid, enterWorldBody, timeout);
}
private void EnterWorldCore(uint characterGuid, byte[] enterWorldBody, TimeSpan? timeout)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
_activeCharacterId = characterGuid;
Transition(State.EnteringWorld);
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
@ -1220,7 +1262,7 @@ public sealed class WorldSession : IDisposable
// CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account
// populated by CharacterSet::UnPack, not the spelling supplied to the
// login form. ACE validates this canonical account value.
SendGameMessage(selection.EnterWorldBody);
SendGameMessage(enterWorldBody);
// LoginComplete is emitted by the host only after the accepted local
// Create has completed its canonical first placement. Sending it at

View file

@ -446,9 +446,17 @@ public interface IRuntimeCharacterCreationCommands
/// <summary>Retail's Finish button (<c>gmCharGenMainUI::DoFinish @
/// 0x004E9170</c>). On acceptance the request is already on the wire;
/// the Ok/rejection reply arrives asynchronously as a status delta —
/// see <see cref="Session.RuntimeCharacterCreationState"/>.</summary>
/// see <see cref="Session.RuntimeCharacterCreationState"/>.
/// <paramref name="confirmUnspentCredits"/> is retail's <c>arg2 == 0</c>
/// — the credit-warning dialog's own confirm click — skipping the
/// unspent-attribute-credits gate; the ordinary caller passes
/// <c>false</c> (retail's <c>arg2 = 1</c> button click), which shows
/// that warning instead of sending when credits remain. See
/// <see cref="Session.RuntimeCharacterCreationState.TryBeginFinish"/>'s
/// doc comment for the full citation.</summary>
RuntimeCommandResult Finish(
RuntimeGenerationToken expectedGeneration);
RuntimeGenerationToken expectedGeneration,
bool confirmUnspentCredits = false);
RuntimeCommandResult AcknowledgeRejection(
RuntimeGenerationToken expectedGeneration);

View file

@ -202,6 +202,21 @@ public interface ILiveSessionOperations
void StartCharacterSelectionReceive(WorldSession session) =>
session.StartCharacterSelectionReceive();
void EnterWorld(WorldSession session, int activeCharacterIndex);
/// <summary>
/// Campaign CC slice CC3 review-fix round (F1): mirrors
/// <see cref="EnterWorld(WorldSession,int)"/> but enters by the exact
/// guid the <c>0xF643</c> Ok reply carried, bypassing the (by-design
/// post-create-stale) cached roster — see
/// <see cref="WorldSession.EnterWorld(uint,string,TimeSpan?)"/>'s doc
/// comment for the full retail citation.
/// </summary>
void EnterWorldByGuid(
WorldSession session,
uint characterGuid,
string accountName) =>
session.EnterWorld(characterGuid, accountName);
void DeleteCharacter(
WorldSession session,
string accountName,
@ -1118,7 +1133,33 @@ public sealed class LiveSessionController
}
}
private RuntimeCommandResult EnterSelectedCore()
private RuntimeCommandResult EnterSelectedCore() =>
EnterHighlightedCore(static (operations, session, character, _) =>
operations.EnterWorld(session, character.ActiveIndex));
/// <summary>
/// Campaign CC slice CC3 review-fix round (F1): the log-straight-in
/// half of <see cref="HandleCharacterCreationResponse"/> — identical
/// transaction shape to <see cref="EnterSelectedCore"/>, but sends the
/// EnterWorld wire request by the exact created guid rather than by a
/// roster index (see <see cref="ILiveSessionOperations.EnterWorldByGuid"/>).
/// </summary>
private RuntimeCommandResult EnterCreatedCharacterCore(
RuntimeCharacterCreationIdentity identity) =>
EnterHighlightedCore((operations, session, _, accountName) =>
operations.EnterWorldByGuid(session, identity.Guid, accountName));
/// <summary>
/// Shared transaction for "the highlighted character is about to enter
/// the world": select it, send the caller-supplied EnterWorld wire
/// request, activate commands, and publish the entered-world state.
/// <paramref name="sendEnterWorld"/> is the only thing that differs
/// between the ordinary index-based selection flow
/// (<see cref="EnterSelectedCore"/>) and the post-create guid-based flow
/// (<see cref="EnterCreatedCharacterCore"/>).
/// </summary>
private RuntimeCommandResult EnterHighlightedCore(
Action<ILiveSessionOperations, WorldSession, RuntimeCharacterSelectionEntry, string> sendEnterWorld)
{
SessionScope scope = _scope!;
ulong generation = _generation;
@ -1141,7 +1182,7 @@ public sealed class LiveSessionController
if (!IsCurrent(scope, generation))
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
_operations.EnterWorld(scope.Session, character.ActiveIndex);
sendEnterWorld(_operations, scope.Session, character, snapshot.AccountName);
if (!IsCurrent(scope, generation))
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
@ -1231,11 +1272,36 @@ public sealed class LiveSessionController
/// (we already have the exact created guid/name from the SAME reply
/// that triggered the roster append, so there is no need to re-scan for
/// it the way retail's per-frame poll does — an equivalent, not a
/// divergent, substitution). Reuses <see cref="RuntimeCharacterSelectionState.ApplyRoster"/>
/// for the append (there is no single-entry append primitive to
/// duplicate) and <see cref="EnterSelectedCore"/> for the log-straight-in
/// (no second enter route). A non-Ok reply only needs the state-machine
/// update already performed by <see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>
/// divergent, substitution).
///
/// <para>
/// Campaign CC slice CC3 review-fix round (F1/F2): the roster append no
/// longer round-trips through <see cref="RuntimeCharacterSelectionState.ApplyRoster"/>
/// — that re-derives EVERY entry's <c>ActiveIndex</c> from display
/// (name-sorted) order, and <c>ActiveIndex</c> is a wire contract
/// (<c>SendDeleteCharacter</c> sends it as the CharacterSet slot; ACE
/// indexes <c>session.Characters[(int)characterSlot]</c> —
/// <c>references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:297</c>),
/// so a full re-sort would silently retarget every PRE-EXISTING
/// character's delete slot to its alphabetical rank. Instead
/// <see cref="RuntimeCharacterSelectionState.AppendCreatedCharacter"/>
/// preserves every existing entry's <c>ActiveIndex</c> and assigns the
/// new entry's from the wire count BEFORE this create (ACE appends to
/// <c>session.Characters</c>, so the new character's slot equals that
/// pre-create count, 0-based) — read from the cached wire list
/// (<see cref="ILiveSessionOperations.GetCharacters"/>), the SAME source
/// the ordinary index-enter path reads, not the sorted display mirror.
/// The subsequent log-straight-in also no longer goes through
/// <see cref="EnterSelectedCore"/>'s roster-index EnterWorld send — that
/// cached wire list is BY DESIGN stale for the just-created character
/// (ACE never resends CharacterList post-create), so it uses
/// <see cref="EnterCreatedCharacterCore"/>'s guid-based send instead
/// (see that method's and <see cref="ILiveSessionOperations.EnterWorldByGuid"/>'s
/// doc comments).
/// </para>
///
/// A non-Ok reply only needs the state-machine update already performed
/// by <see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>
/// — no roster/enter side effects.
/// </summary>
private void HandleCharacterCreationResponse(
@ -1265,7 +1331,19 @@ public sealed class LiveSessionController
before.AccountName,
before.SlotCount,
entries);
CharacterSelectionState.ApplyRoster(report);
// F2: the new character's wire slot is the pre-create count of the
// cached wire roster (ACE appends; that cached list is stale for
// THIS character by design, but its COUNT is still exactly the
// 0-based slot ACE assigned). Falls back to the display roster
// count only if the cached wire list is unexpectedly unavailable.
int wireIndex =
_operations.GetCharacters(scope.Session)?.Characters.Count
?? before.RosterCount;
CharacterSelectionState.AppendCreatedCharacter(
identity.Guid,
identity.Name,
wireIndex);
scope.Host.ReportRoster(report);
if (!IsCurrent(scope, generation))
return;
@ -1279,7 +1357,7 @@ public sealed class LiveSessionController
// running inside Tick()'s top-level operation (this handler fires
// synchronously from _operations.Tick's inbound processing), exactly
// the same calling convention StartCore's own inline enter uses.
_ = EnterSelectedCore();
_ = EnterCreatedCharacterCore(identity);
}
public RuntimeCommandResult SelectHeritage(
@ -1463,17 +1541,25 @@ public sealed class LiveSessionController
}
/// <summary>
/// Ports <c>gmCharGenMainUI::DoFinish @ 0x004E9170</c>'s send half: the
/// local gates live in <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>;
/// this method supplies the roster/slot-cap inputs from
/// Ports <c>gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170</c>'s
/// send half: the local gates live in
/// <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>; this
/// method supplies the roster/slot-cap inputs from
/// <see cref="CharacterSelectionState"/> and, on acceptance, sends the
/// wire request via <c>Proto_UI::SendCharGenResult</c>'s port
/// (<see cref="ILiveSessionOperations.CreateCharacter"/>). A transport
/// failure resets the verification latch the same way an unsolicited
/// Undef/Pending reply does (<see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>)
/// (<see cref="ILiveSessionOperations.CreateCharacter"/>).
/// <paramref name="confirmUnspentCredits"/> is retail's <c>arg2 == 0</c>
/// case — see <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>'s
/// doc comment for the full credit-warning-dialog citation; the ordinary
/// caller passes <c>false</c> (retail's <c>arg2 = 1</c> button click). A
/// transport failure resets the verification latch the same way an
/// unsolicited Undef/Pending reply does
/// (<see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>)
/// rather than leaving it stuck Pending forever.
/// </summary>
public RuntimeCommandResult Finish(RuntimeGenerationToken expectedGeneration)
public RuntimeCommandResult Finish(
RuntimeGenerationToken expectedGeneration,
bool confirmUnspentCredits = false)
{
lock (_gate)
{
@ -1481,13 +1567,15 @@ public sealed class LiveSessionController
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
SessionScope scope = _scope!;
RuntimeCharacterSelectionSnapshot selection = CharacterSelectionState.Snapshot;
if (!CharacterCreationState.TryBeginFinish(
selection.RosterCount,
selection.SlotCount,
out CharacterCreate.Request request,
out uint[] skillAdvancementClasses,
out _))
out _,
confirmUnspentCredits))
{
return CharacterCreationResult(RuntimeCommandStatus.Rejected);
}
@ -1495,13 +1583,22 @@ public sealed class LiveSessionController
try
{
_operations.CreateCharacter(
_scope!.Session,
scope.Session,
selection.AccountName,
request,
skillAdvancementClasses);
return CharacterCreationResult(RuntimeCommandStatus.Accepted);
}
catch
// F13: narrowed to what SendCharacterCreation's send path
// actually throws — WorldSession.SendGameMessage's own
// InvalidOperationException (transport not yet negotiated) and
// whatever the underlying UDP send raises (SocketException).
// Anything else is a genuine bug, not a transport hiccup, and
// should propagate rather than being silently swallowed into a
// rejection.
catch (Exception error) when (
error is InvalidOperationException
or System.Net.Sockets.SocketException)
{
CharacterCreationState.ApplyCreationResponse(
new CharGenVerificationResponse.Parsed(

View file

@ -506,6 +506,20 @@ public sealed class RuntimeCharacterCreationState : IDisposable
/// row's six attributes verbatim and re-derives the skill array (baseline
/// reset, then the row's Normal skills trained and Primary skills
/// specialized).
///
/// <para>
/// Campaign CC slice CC3 review-fix round (F4): when the currently
/// selected <see cref="_template"/> index is out of range for THIS
/// heritage's template list (a heritage switch left a stale index from
/// a previous, richer heritage), this clears it to
/// <see cref="RuntimeCharacterCreationSnapshot.TemplateUnset"/> instead
/// of merely returning — mirroring the clamp
/// <c>CharGenState::ConstrainAllByHeritage @ 0x005C65CC</c> performs
/// right after <c>ApplyTemplate</c> in <c>SetHeritageGroup</c>
/// (<c>if (template_ &gt;= count) template_ = 0xffffffff;</c>). Without
/// this, the stale out-of-range index would survive unchanged and reach
/// the wire via <see cref="BuildRequestLocked"/>.
/// </para>
/// </summary>
private void ApplyTemplateLocked(ChargenHeritageOptions heritage)
{
@ -519,7 +533,10 @@ public sealed class RuntimeCharacterCreationState : IDisposable
if (_heritageId == 0 || _genderKey == 0 || _template == RuntimeCharacterCreationSnapshot.TemplateUnset)
return;
if (_template >= (uint)heritage.Templates.Count)
{
_template = RuntimeCharacterCreationSnapshot.TemplateUnset;
return;
}
ChargenTemplate row = heritage.Templates[(int)_template];
_attributes = row.Attributes;
@ -580,13 +597,34 @@ public sealed class RuntimeCharacterCreationState : IDisposable
/// <summary>
/// Ports <c>CharGenState::ResetSkillLevels @ 0x005C43B0</c>'s baseline
/// derivation: resets the credit counter to the full budget, then for
/// every skill id costable in EITHER tier picks the state that costs
/// nothing yet — TrainedCost&gt;0 → Untrained (must be paid for);
/// TrainedCost==0 &amp;&amp; SpecializedCost&lt;=0 → Specialized (free and
/// pre-specialized, e.g. an innate skill); TrainedCost==0 &amp;&amp;
/// SpecializedCost&gt;0 → Trained (free to train, costs to specialize). A
/// skill uncostable in both tiers is left untouched (stays Inactive on a
/// fresh set).
/// every skill id classifies TrainedCost&gt;0 → Untrained (must be paid
/// for); TrainedCost==0 &amp;&amp; SpecializedCost&lt;=0 → Specialized
/// (free and pre-specialized, e.g. an innate skill); TrainedCost==0
/// &amp;&amp; SpecializedCost&gt;0 → Trained (free to train, costs to
/// specialize). A skill uncostable in both tiers is left untouched
/// (stays Inactive on a fresh set).
///
/// <para>
/// Campaign CC slice CC3 review-fix round (F8): retail's real gate at
/// <c>0x005C4487</c> is <c>if (trainedCost &gt;= 0 &amp;&amp;
/// specializedCost &gt;= 0)</c> — BOTH tiers non-negative, not "either
/// tier costable" as this comment previously said. This port's
/// <see cref="TryGetSkillCost"/> instead gates on DICTIONARY PRESENCE
/// (found in the heritage's own list or the global SkillTable), which
/// is equivalent ONLY because of a CC1-established, installed-DAT-gated
/// invariant: costs are stored VERBATIM (never filtered), and for every
/// entry the reader ever produces, both <c>NormalCost</c> and
/// <c>PrimaryCost</c> are non-negative
/// (<c>ChargenTableReaderInstalledDatTests</c>'s
/// <c>NormalCost &gt;= 0</c>/<c>PrimaryCost &gt;= 0</c> assertions); a
/// skill uncostable in either tier is simply ABSENT from both the
/// heritage and global dictionaries in the installed DAT (retail's -1
/// case), not present with a negative value. If that installed-DAT
/// shape ever changed (a partially-negative entry, one tier &lt;0 and
/// the other &gt;=0), this dictionary-presence gate would diverge from
/// retail's real per-value check — the regression test above is what
/// would catch that drift.
/// </para>
/// </summary>
private void ResetSkillLevelsLocked(ChargenHeritageOptions heritage)
{
@ -644,14 +682,17 @@ public sealed class RuntimeCharacterCreationState : IDisposable
/// picks a uniformly random entry from the heritage's
/// <c>PrimaryStartAreaIndices</c> (never <c>SecondaryStartAreaIndices</c>)
/// and adopts it as the default starting area, bounds-checked against
/// the shared starter-area list.</summary>
/// the shared starter-area list. Campaign CC slice CC3 review-fix round
/// (F15): retail (<c>0x005C5A0A</c>, the <c>if (var_9c &gt; 0)</c>
/// guard) touches <c>startArea</c> ONLY inside that branch — an empty
/// <c>PrimaryStartAreaIndices</c> leaves the field COMPLETELY
/// UNTOUCHED, not reset to <c>-1</c>. Unreachable through the installed
/// DAT (every heritage ships a non-empty primary list), but aligned
/// here for exactness.</summary>
private void RandomizeStartAreaLocked(ChargenHeritageOptions heritage)
{
if (heritage.PrimaryStartAreaIndices.Count == 0)
{
_startArea = -1;
return;
}
int candidate = heritage.PrimaryStartAreaIndices[
_random.Next(heritage.PrimaryStartAreaIndices.Count)];
_startArea = candidate >= 0 && candidate < _options.StarterAreas.Count
@ -1097,13 +1138,22 @@ public sealed class RuntimeCharacterCreationState : IDisposable
return true;
}
/// <summary>The CharacterSet slot this create targets. Retail resets
/// this to <c>0xFFFFFFFF</c> on every rejection
/// (<c>Handle_CharGenVerificationResponse</c>'s case 3/4/5/6/7) and the
/// decomp does not show which caller assigns a real value before the
/// first Finish — ACE itself never reads the field
/// (<c>PlayerFactory.cs:154</c>, commented out), so 0 is a safe
/// placeholder until a slot-aware caller (CC4/CC7) sets one.</summary>
/// <summary>The CharacterSet slot this create targets. Retail DOES
/// assign it — but only as a side effect of char-select, not chargen:
/// <c>gmCharacterManagementUI::SelectCharacter @ 0x004EC160</c> calls
/// <c>CharGenState::SetSlot(CharacterSet::GetSlot(...)) @
/// 0x004EC22A</c> with the slot of whichever EXISTING character the
/// player last clicked in the character-select list, reset to
/// <c>0xFFFFFFFF</c> on entering chargen (<c>0x004EC074</c>,
/// <c>0x0055EA9A</c>) and again on every rejection
/// (<c>Handle_CharGenVerificationResponse</c>'s case 3/4/5/6/7). The
/// value retail actually sends on Finish is therefore semantically
/// STALE — the last-selected PRE-EXISTING character's own slot, not
/// anything about the character being created — and ACE never reads
/// the field regardless (<c>PlayerFactory.cs:154</c>, commented out).
/// <c>0</c> is a safe placeholder for the same reason it is safe in
/// retail: it is exactly as meaningless to ACE as retail's own stale
/// value.</summary>
internal bool TrySetSlot(uint slot)
{
lock (_gate)
@ -1120,10 +1170,25 @@ public sealed class RuntimeCharacterCreationState : IDisposable
// ── Finish / response ───────────────────────────────────────────────
/// <summary>
/// Ports <c>gmCharGenMainUI::DoFinish @ 0x004E9170</c>'s complete gate
/// sequence: trim+commit the name (empty → refuse), require
/// <c>remainingAtrbCredits == 0</c> (retail forces a full attribute
/// spend), require <see cref="RuntimeCharacterCreationSnapshot.VerificationPending"/>
/// Ports <c>gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170</c>'s
/// complete gate sequence: trim+commit the name (empty → refuse), then
/// — ONLY when <paramref name="confirmedUnspentCredits"/> is false,
/// mirroring retail's <c>arg2 != 0</c> half of
/// <c>arg2 != 0 &amp;&amp; remainingAtrbCredits &gt; 0</c> — require
/// <c>remainingAtrbCredits == 0</c>. Retail does NOT force a full
/// attribute spend: the ordinary Finish-button click passes
/// <c>arg2 = 1</c> (<c>0x004E9579</c>) and, on unspent credits, shows a
/// WARNING dialog and returns WITHOUT sending
/// (<c>MakeCreditWarningDialog @ 0x004E91F6</c>); that dialog's own
/// confirm handler re-invokes <c>DoFinish(this, 0)</c>
/// (<c>0x004E98BB</c>), which skips the credit check entirely and
/// sends with the credits still unspent. ACE accepts this
/// (<c>ValidateAttributeCredits</c> only rejects a total that EXCEEDS
/// the max, never an under-spend). <paramref name="confirmedUnspentCredits"/>
/// is retail's <c>arg2 == 0</c> case: pass <c>true</c> only from the
/// warning dialog's own confirm path (CC4) or an equivalent headless
/// caller that has already decided to proceed with unspent credits.
/// Then requires <see cref="RuntimeCharacterCreationSnapshot.VerificationPending"/>
/// to be false (no double submit), then the campaign's client-side slot
/// cap (risk item 3 — retail's char-select UI, not <c>DoFinish</c>
/// itself, refuses when the roster is already full; ACE never checks
@ -1139,7 +1204,8 @@ public sealed class RuntimeCharacterCreationState : IDisposable
int slotCount,
out CharacterCreate.Request request,
out uint[] skillAdvancementClasses,
out RuntimeCharacterCreationLocalRefusal refusal)
out RuntimeCharacterCreationLocalRefusal refusal,
bool confirmedUnspentCredits = false)
{
request = default;
skillAdvancementClasses = [];
@ -1158,7 +1224,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
refusal = trimmed.Length == 0
? new RuntimeCharacterCreationLocalRefusal(
NoName: true, false, false, false)
: _remainingAttributeCredits > 0
: !confirmedUnspentCredits && _remainingAttributeCredits > 0
? new RuntimeCharacterCreationLocalRefusal(
false, AttributeCreditsUnspent: true, false, false)
: _verificationPending
@ -1245,6 +1311,19 @@ public sealed class RuntimeCharacterCreationState : IDisposable
/// review F3) — a call that arrives while
/// <see cref="RuntimeCharacterCreationSnapshot.VerificationPending"/> is
/// already false is a no-op rather than a second event.
///
/// <para>
/// 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).
/// </para>
/// </summary>
internal void ApplyCreationResponse(CharGenVerificationResponse.Parsed response)
{
@ -1268,9 +1347,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
{
// Silent state reset — retail shows no dialog (ACE sends
// Pending for a disabled-Olthoi rejection; port as-is).
_revision++;
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
return;
kind = RuntimeCharacterCreationDeltaKind.StateChanged;
}
else
{

View file

@ -334,6 +334,53 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
selected);
}
/// <summary>
/// Campaign CC slice CC3 review-fix round (F2): appends ONE freshly
/// created character without re-deriving every entry's
/// <see cref="RuntimeCharacterSelectionEntry.ActiveIndex"/> from display
/// order the way <see cref="ApplyRoster"/> does. <c>ActiveIndex</c> is a
/// WIRE CONTRACT — <c>SendDeleteCharacter</c> sends it as the
/// CharacterSet slot and ACE indexes
/// <c>session.Characters[(int)characterSlot]</c>
/// (<c>references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:297</c>)
/// — so round-tripping the post-create roster through
/// <see cref="ApplyRoster"/>'s name-sort would silently retarget every
/// PRE-EXISTING character's delete slot to its alphabetical rank.
/// <paramref name="wireIndex"/> is the caller-supplied wire slot for the
/// NEW entry only (ACE appends to <c>session.Characters</c>, so the new
/// character's slot equals the wire roster's count BEFORE this create,
/// 0-based — the caller must read that from the cached wire source, not
/// from the sorted display mirror this class exposes). Every existing
/// entry's <see cref="RuntimeCharacterSelectionEntry.ActiveIndex"/> is
/// copied through untouched; only the array's DISPLAY order (name sort,
/// greyed-to-tail) is recomputed, exactly like <see cref="ApplyRoster"/>'s
/// own sort. Does not touch highlight — callers that want the new entry
/// selected still call <see cref="TryHighlight"/> afterward.
/// </summary>
internal void AppendCreatedCharacter(uint characterId, string name, int wireIndex)
{
ArgumentNullException.ThrowIfNull(name);
lock (_gate)
{
ThrowIfDisposed();
var appended = new RuntimeCharacterSelectionEntry[_entries.Length + 1];
Array.Copy(_entries, appended, _entries.Length);
appended[^1] = new RuntimeCharacterSelectionEntry(
wireIndex,
characterId,
name,
SecondsGreyedOut: 0u);
Array.Sort(
appended,
static (left, right) =>
string.CompareOrdinal(left.Name, right.Name));
_entries = StablePartitionGreyedToTail(appended);
_revision++;
}
Publish(RuntimeCharacterSelectionDeltaKind.RosterChanged, characterId);
}
/// <summary>
/// Campaign LA gate round 2 finding 3: retail's <c>UpdateWorldName</c>
/// (<c>0x004ec120</c>) / <c>RecvNotice_WorldName</c> (<c>0x004ec360</c>)