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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1149,12 +1149,54 @@ public sealed class WorldSession : IDisposable
{ {
if (Characters is null || Characters.Characters.Count == 0) if (Characters is null || Characters.Characters.Count == 0)
throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList"); throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList");
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
EnterWorldSelection selection = SelectCharacterForEnterWorld( EnterWorldSelection selection = SelectCharacterForEnterWorld(
Characters, Characters,
characterIndex); characterIndex);
CharacterList.Character chosen = selection.Character; EnterWorldCore(selection.Character.Id, selection.EnterWorldBody, timeout);
_activeCharacterId = chosen.Id; }
/// <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); Transition(State.EnteringWorld);
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody()); SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
@ -1220,7 +1262,7 @@ public sealed class WorldSession : IDisposable
// CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account // CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account
// populated by CharacterSet::UnPack, not the spelling supplied to the // populated by CharacterSet::UnPack, not the spelling supplied to the
// login form. ACE validates this canonical account value. // login form. ACE validates this canonical account value.
SendGameMessage(selection.EnterWorldBody); SendGameMessage(enterWorldBody);
// LoginComplete is emitted by the host only after the accepted local // LoginComplete is emitted by the host only after the accepted local
// Create has completed its canonical first placement. Sending it at // 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 @ /// <summary>Retail's Finish button (<c>gmCharGenMainUI::DoFinish @
/// 0x004E9170</c>). On acceptance the request is already on the wire; /// 0x004E9170</c>). On acceptance the request is already on the wire;
/// the Ok/rejection reply arrives asynchronously as a status delta — /// 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( RuntimeCommandResult Finish(
RuntimeGenerationToken expectedGeneration); RuntimeGenerationToken expectedGeneration,
bool confirmUnspentCredits = false);
RuntimeCommandResult AcknowledgeRejection( RuntimeCommandResult AcknowledgeRejection(
RuntimeGenerationToken expectedGeneration); RuntimeGenerationToken expectedGeneration);

View file

@ -202,6 +202,21 @@ public interface ILiveSessionOperations
void StartCharacterSelectionReceive(WorldSession session) => void StartCharacterSelectionReceive(WorldSession session) =>
session.StartCharacterSelectionReceive(); session.StartCharacterSelectionReceive();
void EnterWorld(WorldSession session, int activeCharacterIndex); 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( void DeleteCharacter(
WorldSession session, WorldSession session,
string accountName, 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!; SessionScope scope = _scope!;
ulong generation = _generation; ulong generation = _generation;
@ -1141,7 +1182,7 @@ public sealed class LiveSessionController
if (!IsCurrent(scope, generation)) if (!IsCurrent(scope, generation))
return CharacterSelectionResult(RuntimeCommandStatus.Inactive); return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
_operations.EnterWorld(scope.Session, character.ActiveIndex); sendEnterWorld(_operations, scope.Session, character, snapshot.AccountName);
if (!IsCurrent(scope, generation)) if (!IsCurrent(scope, generation))
return CharacterSelectionResult(RuntimeCommandStatus.Inactive); return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
@ -1231,11 +1272,36 @@ public sealed class LiveSessionController
/// (we already have the exact created guid/name from the SAME reply /// (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 /// 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 /// it the way retail's per-frame poll does — an equivalent, not a
/// divergent, substitution). Reuses <see cref="RuntimeCharacterSelectionState.ApplyRoster"/> /// divergent, substitution).
/// for the append (there is no single-entry append primitive to ///
/// duplicate) and <see cref="EnterSelectedCore"/> for the log-straight-in /// <para>
/// (no second enter route). A non-Ok reply only needs the state-machine /// Campaign CC slice CC3 review-fix round (F1/F2): the roster append no
/// update already performed by <see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/> /// 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. /// — no roster/enter side effects.
/// </summary> /// </summary>
private void HandleCharacterCreationResponse( private void HandleCharacterCreationResponse(
@ -1265,7 +1331,19 @@ public sealed class LiveSessionController
before.AccountName, before.AccountName,
before.SlotCount, before.SlotCount,
entries); 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); scope.Host.ReportRoster(report);
if (!IsCurrent(scope, generation)) if (!IsCurrent(scope, generation))
return; return;
@ -1279,7 +1357,7 @@ public sealed class LiveSessionController
// running inside Tick()'s top-level operation (this handler fires // running inside Tick()'s top-level operation (this handler fires
// synchronously from _operations.Tick's inbound processing), exactly // synchronously from _operations.Tick's inbound processing), exactly
// the same calling convention StartCore's own inline enter uses. // the same calling convention StartCore's own inline enter uses.
_ = EnterSelectedCore(); _ = EnterCreatedCharacterCore(identity);
} }
public RuntimeCommandResult SelectHeritage( public RuntimeCommandResult SelectHeritage(
@ -1463,17 +1541,25 @@ public sealed class LiveSessionController
} }
/// <summary> /// <summary>
/// Ports <c>gmCharGenMainUI::DoFinish @ 0x004E9170</c>'s send half: the /// Ports <c>gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170</c>'s
/// local gates live in <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>; /// send half: the local gates live in
/// this method supplies the roster/slot-cap inputs from /// <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>; this
/// method supplies the roster/slot-cap inputs from
/// <see cref="CharacterSelectionState"/> and, on acceptance, sends the /// <see cref="CharacterSelectionState"/> and, on acceptance, sends the
/// wire request via <c>Proto_UI::SendCharGenResult</c>'s port /// wire request via <c>Proto_UI::SendCharGenResult</c>'s port
/// (<see cref="ILiveSessionOperations.CreateCharacter"/>). A transport /// (<see cref="ILiveSessionOperations.CreateCharacter"/>).
/// failure resets the verification latch the same way an unsolicited /// <paramref name="confirmUnspentCredits"/> is retail's <c>arg2 == 0</c>
/// Undef/Pending reply does (<see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>) /// 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. /// rather than leaving it stuck Pending forever.
/// </summary> /// </summary>
public RuntimeCommandResult Finish(RuntimeGenerationToken expectedGeneration) public RuntimeCommandResult Finish(
RuntimeGenerationToken expectedGeneration,
bool confirmUnspentCredits = false)
{ {
lock (_gate) lock (_gate)
{ {
@ -1481,13 +1567,15 @@ public sealed class LiveSessionController
if (gate != RuntimeCommandStatus.Accepted) if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate); return CharacterCreationResult(gate);
SessionScope scope = _scope!;
RuntimeCharacterSelectionSnapshot selection = CharacterSelectionState.Snapshot; RuntimeCharacterSelectionSnapshot selection = CharacterSelectionState.Snapshot;
if (!CharacterCreationState.TryBeginFinish( if (!CharacterCreationState.TryBeginFinish(
selection.RosterCount, selection.RosterCount,
selection.SlotCount, selection.SlotCount,
out CharacterCreate.Request request, out CharacterCreate.Request request,
out uint[] skillAdvancementClasses, out uint[] skillAdvancementClasses,
out _)) out _,
confirmUnspentCredits))
{ {
return CharacterCreationResult(RuntimeCommandStatus.Rejected); return CharacterCreationResult(RuntimeCommandStatus.Rejected);
} }
@ -1495,13 +1583,22 @@ public sealed class LiveSessionController
try try
{ {
_operations.CreateCharacter( _operations.CreateCharacter(
_scope!.Session, scope.Session,
selection.AccountName, selection.AccountName,
request, request,
skillAdvancementClasses); skillAdvancementClasses);
return CharacterCreationResult(RuntimeCommandStatus.Accepted); 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( CharacterCreationState.ApplyCreationResponse(
new CharGenVerificationResponse.Parsed( 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 /// row's six attributes verbatim and re-derives the skill array (baseline
/// reset, then the row's Normal skills trained and Primary skills /// reset, then the row's Normal skills trained and Primary skills
/// specialized). /// 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> /// </summary>
private void ApplyTemplateLocked(ChargenHeritageOptions heritage) private void ApplyTemplateLocked(ChargenHeritageOptions heritage)
{ {
@ -519,7 +533,10 @@ public sealed class RuntimeCharacterCreationState : IDisposable
if (_heritageId == 0 || _genderKey == 0 || _template == RuntimeCharacterCreationSnapshot.TemplateUnset) if (_heritageId == 0 || _genderKey == 0 || _template == RuntimeCharacterCreationSnapshot.TemplateUnset)
return; return;
if (_template >= (uint)heritage.Templates.Count) if (_template >= (uint)heritage.Templates.Count)
{
_template = RuntimeCharacterCreationSnapshot.TemplateUnset;
return; return;
}
ChargenTemplate row = heritage.Templates[(int)_template]; ChargenTemplate row = heritage.Templates[(int)_template];
_attributes = row.Attributes; _attributes = row.Attributes;
@ -580,13 +597,34 @@ public sealed class RuntimeCharacterCreationState : IDisposable
/// <summary> /// <summary>
/// Ports <c>CharGenState::ResetSkillLevels @ 0x005C43B0</c>'s baseline /// Ports <c>CharGenState::ResetSkillLevels @ 0x005C43B0</c>'s baseline
/// derivation: resets the credit counter to the full budget, then for /// derivation: resets the credit counter to the full budget, then for
/// every skill id costable in EITHER tier picks the state that costs /// every skill id classifies TrainedCost&gt;0 → Untrained (must be paid
/// nothing yet — TrainedCost&gt;0 → Untrained (must be paid for); /// for); TrainedCost==0 &amp;&amp; SpecializedCost&lt;=0 → Specialized
/// TrainedCost==0 &amp;&amp; SpecializedCost&lt;=0 → Specialized (free and /// (free and pre-specialized, e.g. an innate skill); TrainedCost==0
/// pre-specialized, e.g. an innate skill); TrainedCost==0 &amp;&amp; /// &amp;&amp; SpecializedCost&gt;0 → Trained (free to train, costs to
/// SpecializedCost&gt;0 → Trained (free to train, costs to specialize). A /// specialize). A skill uncostable in both tiers is left untouched
/// skill uncostable in both tiers is left untouched (stays Inactive on a /// (stays Inactive on a fresh set).
/// 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> /// </summary>
private void ResetSkillLevelsLocked(ChargenHeritageOptions heritage) private void ResetSkillLevelsLocked(ChargenHeritageOptions heritage)
{ {
@ -644,14 +682,17 @@ public sealed class RuntimeCharacterCreationState : IDisposable
/// picks a uniformly random entry from the heritage's /// picks a uniformly random entry from the heritage's
/// <c>PrimaryStartAreaIndices</c> (never <c>SecondaryStartAreaIndices</c>) /// <c>PrimaryStartAreaIndices</c> (never <c>SecondaryStartAreaIndices</c>)
/// and adopts it as the default starting area, bounds-checked against /// 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) private void RandomizeStartAreaLocked(ChargenHeritageOptions heritage)
{ {
if (heritage.PrimaryStartAreaIndices.Count == 0) if (heritage.PrimaryStartAreaIndices.Count == 0)
{
_startArea = -1;
return; return;
}
int candidate = heritage.PrimaryStartAreaIndices[ int candidate = heritage.PrimaryStartAreaIndices[
_random.Next(heritage.PrimaryStartAreaIndices.Count)]; _random.Next(heritage.PrimaryStartAreaIndices.Count)];
_startArea = candidate >= 0 && candidate < _options.StarterAreas.Count _startArea = candidate >= 0 && candidate < _options.StarterAreas.Count
@ -1097,13 +1138,22 @@ public sealed class RuntimeCharacterCreationState : IDisposable
return true; return true;
} }
/// <summary>The CharacterSet slot this create targets. Retail resets /// <summary>The CharacterSet slot this create targets. Retail DOES
/// this to <c>0xFFFFFFFF</c> on every rejection /// assign it — but only as a side effect of char-select, not chargen:
/// (<c>Handle_CharGenVerificationResponse</c>'s case 3/4/5/6/7) and the /// <c>gmCharacterManagementUI::SelectCharacter @ 0x004EC160</c> calls
/// decomp does not show which caller assigns a real value before the /// <c>CharGenState::SetSlot(CharacterSet::GetSlot(...)) @
/// first Finish — ACE itself never reads the field /// 0x004EC22A</c> with the slot of whichever EXISTING character the
/// (<c>PlayerFactory.cs:154</c>, commented out), so 0 is a safe /// player last clicked in the character-select list, reset to
/// placeholder until a slot-aware caller (CC4/CC7) sets one.</summary> /// <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) internal bool TrySetSlot(uint slot)
{ {
lock (_gate) lock (_gate)
@ -1120,10 +1170,25 @@ public sealed class RuntimeCharacterCreationState : IDisposable
// ── Finish / response ─────────────────────────────────────────────── // ── Finish / response ───────────────────────────────────────────────
/// <summary> /// <summary>
/// Ports <c>gmCharGenMainUI::DoFinish @ 0x004E9170</c>'s complete gate /// Ports <c>gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170</c>'s
/// sequence: trim+commit the name (empty → refuse), require /// complete gate sequence: trim+commit the name (empty → refuse), then
/// <c>remainingAtrbCredits == 0</c> (retail forces a full attribute /// — ONLY when <paramref name="confirmedUnspentCredits"/> is false,
/// spend), require <see cref="RuntimeCharacterCreationSnapshot.VerificationPending"/> /// 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 /// 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> /// cap (risk item 3 — retail's char-select UI, not <c>DoFinish</c>
/// itself, refuses when the roster is already full; ACE never checks /// itself, refuses when the roster is already full; ACE never checks
@ -1139,7 +1204,8 @@ public sealed class RuntimeCharacterCreationState : IDisposable
int slotCount, int slotCount,
out CharacterCreate.Request request, out CharacterCreate.Request request,
out uint[] skillAdvancementClasses, out uint[] skillAdvancementClasses,
out RuntimeCharacterCreationLocalRefusal refusal) out RuntimeCharacterCreationLocalRefusal refusal,
bool confirmedUnspentCredits = false)
{ {
request = default; request = default;
skillAdvancementClasses = []; skillAdvancementClasses = [];
@ -1158,7 +1224,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
refusal = trimmed.Length == 0 refusal = trimmed.Length == 0
? new RuntimeCharacterCreationLocalRefusal( ? new RuntimeCharacterCreationLocalRefusal(
NoName: true, false, false, false) NoName: true, false, false, false)
: _remainingAttributeCredits > 0 : !confirmedUnspentCredits && _remainingAttributeCredits > 0
? new RuntimeCharacterCreationLocalRefusal( ? new RuntimeCharacterCreationLocalRefusal(
false, AttributeCreditsUnspent: true, false, false) false, AttributeCreditsUnspent: true, false, false)
: _verificationPending : _verificationPending
@ -1245,6 +1311,19 @@ public sealed class RuntimeCharacterCreationState : IDisposable
/// review F3) — a call that arrives while /// review F3) — a call that arrives while
/// <see cref="RuntimeCharacterCreationSnapshot.VerificationPending"/> is /// <see cref="RuntimeCharacterCreationSnapshot.VerificationPending"/> is
/// already false is a no-op rather than a second event. /// 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> /// </summary>
internal void ApplyCreationResponse(CharGenVerificationResponse.Parsed response) internal void ApplyCreationResponse(CharGenVerificationResponse.Parsed response)
{ {
@ -1268,9 +1347,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
{ {
// Silent state reset — retail shows no dialog (ACE sends // Silent state reset — retail shows no dialog (ACE sends
// Pending for a disabled-Olthoi rejection; port as-is). // Pending for a disabled-Olthoi rejection; port as-is).
_revision++; kind = RuntimeCharacterCreationDeltaKind.StateChanged;
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
return;
} }
else else
{ {

View file

@ -334,6 +334,53 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
selected); 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> /// <summary>
/// Campaign LA gate round 2 finding 3: retail's <c>UpdateWorldName</c> /// Campaign LA gate round 2 finding 3: retail's <c>UpdateWorldName</c>
/// (<c>0x004ec120</c>) / <c>RecvNotice_WorldName</c> (<c>0x004ec360</c>) /// (<c>0x004ec120</c>) / <c>RecvNotice_WorldName</c> (<c>0x004ec360</c>)

View file

@ -165,6 +165,33 @@ public sealed class RuntimeCharacterCreationStateTests
Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), state.Snapshot.Attributes); Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), state.Snapshot.Attributes);
} }
/// <summary>
/// F4 acceptance gate: <c>CharGenState::ConstrainAllByHeritage @
/// 0x005C65CC</c> clamps a stale template index to <c>0xffffffff</c>
/// when it no longer fits the newly selected heritage's template list.
/// Without this, a high template index chosen against a
/// many-templates heritage would survive a switch to a heritage with
/// fewer templates and reach the wire via <c>BuildRequestLocked</c>.
/// </summary>
[Fact]
public void TrySelectHeritage_TemplateOutOfRangeForNewHeritage_ClearsToUnset()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
// Index 1 — valid for Aluvian's two templates (Custom=0, Preset=1).
Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex));
Assert.Equal(1u, state.Snapshot.Template);
// Impoverished has only ONE template (index 0) — index 1 no longer
// fits; gender (Male) stays valid for Impoverished too, so the
// clamp branch (not the "no gender yet" no-op branch) is the one
// under test.
Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.ImpoverishedId));
Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, state.Snapshot.Template);
}
// ── Attributes ────────────────────────────────────────────────────── // ── Attributes ──────────────────────────────────────────────────────
[Fact] [Fact]
@ -230,6 +257,95 @@ public sealed class RuntimeCharacterCreationStateTests
Assert.Equal(16, state.Snapshot.Attributes.Strength); Assert.Equal(16, state.Snapshot.Attributes.Strength);
} }
/// <summary>
/// F7 acceptance gate: <c>CharGenState::BalanceAttributes @
/// 0x005C3DF0</c>'s persistent cursor (ported as the instance field
/// <c>_attributeBalanceCursor</c>) advances past whichever attribute
/// last absorbed an overspend, so a SECOND overspend in a LATER call
/// does not re-drain the SAME donor the first call already emptied.
/// </summary>
[Fact]
public void TrySetAttribute_SuccessiveOverspends_AbsorbFromDifferentAttributes()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
// Str=16, everyone else at the 10 floor, fully spent (66/66) — the
// fixture's only above-floor attribute at the start.
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex);
// First overspend: raising Endurance by 1 forces a 1-point
// donation. The cursor starts at Strength (the only above-floor
// attribute), so Strength donates.
Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 11));
Assert.Equal(15, state.Snapshot.Attributes.Strength);
Assert.Equal(11, state.Snapshot.Attributes.Endurance);
// Second overspend: raising Coordination by 1 forces another
// 1-point donation. If the cursor had reset to Strength, Strength
// (still above floor at 15) would donate again — it doesn't: the
// cursor advanced past Strength after the first call, so THIS
// donation comes from Endurance (the attribute the FIRST call just
// raised) instead.
Assert.True(state.TrySetAttribute(ChargenAttributeId.Coordination, 11));
Assert.Equal(15, state.Snapshot.Attributes.Strength); // untouched this time
Assert.Equal(10, state.Snapshot.Attributes.Endurance); // donated
Assert.Equal(11, state.Snapshot.Attributes.Coordination);
}
/// <summary>
/// F7 acceptance gate, the wrap case: when the donor found in one pass
/// is the LAST entry in the fixed round-robin order (Self — see
/// <c>BalanceOrder</c>'s own doc comment: Strength, Endurance,
/// Coordination, Quickness, Focus, Self), the cursor wraps back to the
/// FIRST entry (Strength) rather than falling off the end.
/// </summary>
[Fact]
public void TrySetAttribute_BalanceCursor_WrapsFromSelfBackToStrength()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
// Str=16, everyone else at the 10 floor, fully spent (66/66).
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex);
// Lock every attribute except Strength and Self: they stay in the
// budget total but are excluded from donation, isolating the wrap
// behavior to exactly the two attributes under test.
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Endurance, true));
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Coordination, true));
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Quickness, true));
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Focus, true));
// Move all 6 spare points from Strength to Self — Strength is the
// sole eligible donor (everything else is locked or is the raise
// target), so it donates all 6. The cursor lands just past
// Strength (index 0 → Endurance).
Assert.True(state.TrySetAttribute(ChargenAttributeId.Self, 16));
Assert.Equal(10, state.Snapshot.Attributes.Strength);
Assert.Equal(16, state.Snapshot.Attributes.Self);
// Raise Strength by 1: every locked attribute is skipped, so the
// search reaches Self (the only remaining eligible donor). Self is
// the LAST entry in the round-robin order, so this absorption
// wraps the cursor back to Strength (the FIRST entry) afterward.
Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 11));
Assert.Equal(11, state.Snapshot.Attributes.Strength);
Assert.Equal(15, state.Snapshot.Attributes.Self);
// Raise the (locked) Endurance attribute by 1 — locking only
// excludes an attribute from AUTOMATIC donation, not from being set
// directly. If the cursor wrapped correctly, the donor search
// starts at Strength again and Strength (still above floor at 11)
// donates FIRST — not Self (also still above floor at 15), which is
// what an un-wrapped cursor stuck past Self would have picked
// instead.
Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 11));
Assert.Equal(10, state.Snapshot.Attributes.Strength);
Assert.Equal(15, state.Snapshot.Attributes.Self); // unchanged — proves the wrap
Assert.Equal(11, state.Snapshot.Attributes.Endurance);
}
// ── Skills ────────────────────────────────────────────────────────── // ── Skills ──────────────────────────────────────────────────────────
[Fact] [Fact]
@ -342,6 +458,40 @@ public sealed class RuntimeCharacterCreationStateTests
Assert.True(refusal.AttributeCreditsUnspent); Assert.True(refusal.AttributeCreditsUnspent);
} }
/// <summary>
/// F3 acceptance gate: <c>gmCharGenMainUI::DoFinish(this, arg2) @
/// 0x004E9170</c>'s credit gate is <c>arg2 != 0 &amp;&amp;
/// remainingAtrbCredits &gt; 0</c> — retail does NOT force a full
/// spend. The ordinary click warns and refuses
/// (<c>arg2 = 1 @ 0x004E9579</c>, tested above); the credit-warning
/// dialog's own confirm handler re-invokes <c>DoFinish(this, 0)</c>
/// (@0x004E98BB), which skips the check entirely and sends with the
/// credits still unspent. <c>confirmedUnspentCredits: true</c> is that
/// <c>arg2 == 0</c> case.
/// </summary>
[Fact]
public void TryBeginFinish_UnspentAttributeCreditsConfirmed_IsAccepted()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); // 6 unspent
state.TrySetName("Adventurer");
Assert.Equal(6, state.Snapshot.RemainingAttributeCredits);
bool accepted = state.TryBeginFinish(
0, 11, out CharacterCreate.Request request, out _,
out RuntimeCharacterCreationLocalRefusal refusal,
confirmedUnspentCredits: true);
Assert.True(accepted);
Assert.False(refusal.Any);
Assert.True(state.Snapshot.VerificationPending);
// The wire request carries the credits AS UNSPENT — confirming does
// not force-spend them, it only skips the local refusal.
Assert.Equal(10u, request.Attributes.Strength);
}
[Fact] [Fact]
public void TryBeginFinish_SecondCallWhilePending_IsRefused() public void TryBeginFinish_SecondCallWhilePending_IsRefused()
{ {

View file

@ -49,6 +49,13 @@ public sealed class LiveSessionControllerCharacterCreationTests
public List<WorldSession> Sessions { get; } = []; public List<WorldSession> Sessions { get; } = [];
public int EnterWorldCount { get; private set; } public int EnterWorldCount { get; private set; }
/// <summary>F1/F11: captures every guid-based enter call so the
/// post-create log-straight-in can be asserted against the EXACT
/// identity sent, instead of a bare counter that cannot tell an
/// index-based call from a guid-based one, or a right character
/// from a wrong one.</summary>
public List<(uint Guid, string AccountName)> EnterWorldByGuidCalls { get; } = [];
public IPEndPoint ResolveEndpoint(string host, int port) => public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port); new(IPAddress.Loopback, port);
@ -63,9 +70,19 @@ public sealed class LiveSessionControllerCharacterCreationTests
public void StartCharacterSelectionReceive(WorldSession session) { } public void StartCharacterSelectionReceive(WorldSession session) { }
/// <summary>Two pre-existing characters whose WIRE order (array
/// position — "Zed" slot 0, "Amy" slot 1) deliberately differs from
/// their ALPHABETICAL display order ("Amy" &lt; "Zed") — F2's
/// regression gate. A single-character roster cannot distinguish a
/// correct wire-index-preserving append from a buggy
/// re-sort-and-renumber, because with only one entry the two orders
/// coincide.</summary>
public CharacterList.Parsed? GetCharacters(WorldSession session) => new( public CharacterList.Parsed? GetCharacters(WorldSession session) => new(
0u, 0u,
[new CharacterList.Character(0x50000001u, "Existing", 0u)], [
new CharacterList.Character(0x50000002u, "Zed", 0u),
new CharacterList.Character(0x50000003u, "Amy", 0u),
],
[], [],
SlotCount: 11, SlotCount: 11,
AccountName: "testaccount", AccountName: "testaccount",
@ -75,6 +92,12 @@ public sealed class LiveSessionControllerCharacterCreationTests
public void EnterWorld(WorldSession session, int activeCharacterIndex) => public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
EnterWorldCount++; EnterWorldCount++;
public void EnterWorldByGuid(
WorldSession session,
uint characterGuid,
string accountName) =>
EnterWorldByGuidCalls.Add((characterGuid, accountName));
public void Tick(WorldSession session) { } public void Tick(WorldSession session) { }
public void DisposeSession(WorldSession session) { } public void DisposeSession(WorldSession session) { }
@ -195,17 +218,36 @@ public sealed class LiveSessionControllerCharacterCreationTests
Assert.Equal("NewChar", host.Created[0].Name); Assert.Equal("NewChar", host.Created[0].Name);
Assert.Empty(host.Failed); Assert.Empty(host.Failed);
// The roster report following the Ok reply has BOTH the pre-existing // The roster report following the Ok reply has EVERY pre-existing
// character and the newly created one. // character and the newly created one.
LiveSessionRosterReport lastReport = host.Rosters[^1]; LiveSessionRosterReport lastReport = host.Rosters[^1];
Assert.Contains(lastReport.Entries, e => e.Id == 0x50001234u && e.Name == "NewChar"); Assert.Contains(lastReport.Entries, e => e.Id == 0x50001234u && e.Name == "NewChar");
Assert.Contains(lastReport.Entries, e => e.Id == 0x50000001u); Assert.Contains(lastReport.Entries, e => e.Id == 0x50000002u && e.Name == "Zed");
Assert.Contains(lastReport.Entries, e => e.Id == 0x50000003u && e.Name == "Amy");
// gmCharGenMainUI::Update @ 0x004E8460's log-straight-in, reused via // F2 acceptance gate: the append preserves every PRE-EXISTING
// EnterSelectedCore — the controller is now in-world as the new // character's original wire ActiveIndex ("Zed" slot 0, "Amy" slot
// character, no second selection/EnterWorld call needed. // 1 — deliberately NOT alphabetical order) and assigns the NEW
// character the true wire count (2, 0-based, after Zed and Amy). A
// round-trip through ApplyRoster's name-sort-and-renumber would
// have swapped Zed/Amy to 1/0 instead.
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000002u, out RuntimeCharacterSelectionEntry zed));
Assert.Equal(0, zed.ActiveIndex);
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000003u, out RuntimeCharacterSelectionEntry amy));
Assert.Equal(1, amy.ActiveIndex);
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50001234u, out RuntimeCharacterSelectionEntry newChar));
Assert.Equal(2, newChar.ActiveIndex);
// F1 acceptance gate: gmCharGenMainUI::Update @ 0x004E8460's
// log-straight-in, reused via EnterCreatedCharacterCore — the
// controller is now in-world as the new character, entered by the
// EXACT guid the Ok reply carried (NOT the roster-index path, which
// the cached wire roster is by-design stale for post-create).
Assert.True(controller.IsInWorld); Assert.True(controller.IsInWorld);
Assert.Equal(1, operations.EnterWorldCount); Assert.Equal(0, operations.EnterWorldCount);
Assert.Single(operations.EnterWorldByGuidCalls);
Assert.Equal(0x50001234u, operations.EnterWorldByGuidCalls[0].Guid);
Assert.Equal("testaccount", operations.EnterWorldByGuidCalls[0].AccountName);
Assert.Single(host.EnteredWorld); Assert.Single(host.EnteredWorld);
Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId); Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId);
} }
@ -230,6 +272,7 @@ public sealed class LiveSessionControllerCharacterCreationTests
Assert.Empty(host.Created); Assert.Empty(host.Created);
Assert.False(controller.IsInWorld); Assert.False(controller.IsInWorld);
Assert.Equal(0, operations.EnterWorldCount); Assert.Equal(0, operations.EnterWorldCount);
Assert.Empty(operations.EnterWorldByGuidCalls);
Assert.Empty(host.EnteredWorld); Assert.Empty(host.EnteredWorld);
// No roster append on a rejection. // No roster append on a rejection.
Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar")); Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar"));
@ -254,6 +297,43 @@ public sealed class LiveSessionControllerCharacterCreationTests
Assert.False(sent); Assert.False(sent);
} }
/// <summary>
/// F3 acceptance gate: retail does NOT force a full attribute spend —
/// <c>gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170</c> only warns
/// (<c>arg2 != 0 &amp;&amp; remainingAtrbCredits &gt; 0</c>) and the
/// credit-warning dialog's own confirm handler re-invokes
/// <c>DoFinish(this, 0)</c> (@0x004E98BB), which skips the check
/// entirely and sends. <see cref="LiveSessionController.Finish"/>'s
/// <c>confirmUnspentCredits</c> parameter is that <c>arg2 == 0</c> case.
/// </summary>
[Fact]
public void Finish_WithUnspentCreditsAndConfirmed_SendsAnyway()
{
(LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) =
StartAwaitingSelection();
Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted);
Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted);
Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.CustomTemplateIndex).Accepted);
Assert.True(controller.SetName(generation, "NewChar").Accepted);
WorldSession session = operations.Sessions[0];
byte[]? captured = null;
session.GameMessageCapture = (body, _) => captured = body;
// Unconfirmed: refused, matching the sibling test above — the
// warning-dialog gate.
Assert.False(controller.Finish(generation).Accepted);
Assert.Null(captured);
// Confirmed: sends anyway with the credits still unspent.
RuntimeCommandResult confirmed = controller.Finish(generation, confirmUnspentCredits: true);
Assert.True(confirmed.Accepted);
Assert.NotNull(captured);
CapturedCreateRequest decoded = DecodeCreateRequest(captured!);
Assert.Equal("NewChar", decoded.Name);
Assert.Equal(10u, decoded.Strength); // Custom template sits at the floor — unspent, unchanged.
}
private static void InvokeProcessDatagram(WorldSession session, byte[] datagram) private static void InvokeProcessDatagram(WorldSession session, byte[] datagram)
{ {
MethodInfo method = typeof(WorldSession).GetMethod( MethodInfo method = typeof(WorldSession).GetMethod(