diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md
index f65b5179..2019c2ff 100644
--- a/docs/plans/2026-08-15-character-creation-campaign.md
+++ b/docs/plans/2026-08-15-character-creation-campaign.md
@@ -219,7 +219,19 @@ worktrees). CC4 ∥ CC6a after CC3. CC5 last before CC7.
acdream sent the byte-correct 0xF656 body. If CC7's gate hits this,
it is an ACE-side bug reproduced from its own source, NOT an acdream
wire or math defect — do not "fix" acdream's cost math to match ACE's
- over-deduction. Register: file an AD row if CC7 needs a documented
+ over-deduction. **MEASURED 2026-08-15 (user-prompted — downgrades this
+ landmine to LATENT):** dumping the installed EoR DAT shows every one of
+ the 13 heritages' single override is skill 14 (Arcane Lore) at
+ NormalCost=0 / PrimaryCost=2, versus global TrainedCost=4 /
+ SpecializedCost=6. ACE's over-deduction equals NormalCost — which is
+ ZERO for the only heritage-priced skill — so ACE charges 0+2=2 and
+ retail's client computes 2: they AGREE, and no character build can
+ trigger the rejection with end-of-retail data. The formula bug in ACE's
+ heritage-override branch is real but unfireable here; it only matters
+ if a custom server ships a DAT whose heritage override has a nonzero
+ NormalCost. The earlier "may be REJECTED" inference was made from code
+ without measuring the data — the C4 closeout's observe-don't-infer
+ lesson, again. Register: file an AD row if CC7 needs a documented
workaround (e.g. picking a Specialized skill combination that avoids
the heritage-priced skill for the connected gate) rather than silently
adjusting acdream's send.
@@ -238,7 +250,7 @@ the user gate.
|---|---|---|---|---|
| CC1 | REVIEW-CLOSED 2026-08-15 | `04450041`, `cb4703e8` | CLOSED (fix round + narrow re-review; every citation independently re-derived) | Core model (no Chorizite leak) + Content projector; 31 math units + 6 installed-DAT gates (13 heritages). FINDING for CC3: each human heritage's "Adventurer" template IS retail's Custom entry point — attributes at the 10-floor (60/330), a real TemplateCG row, not a UI special case. **Review fix round (`cb4703e8`):** F1 doc corrected — Custom IS template index 0 (the Adventurer row), per `gmCGProfessionPage::UpdateProfession @ 0x004821b0` (case 0 → button 0x100003d9 / `ID_CharGen_CustomText`) and `CharGenState::SetTemplate @ 0x005C5A60` (commits via `CharGenState::ApplyTemplate @ 0x005C5080`, i.e. selecting Custom resets sliders to the floor spread, it does not bypass templates); F2 two-tier skill-cost fallback implemented (`ChargenOptions.GlobalSkillCostsBySkillId` from portal.dat 0x0E000004, `ChargenSkillCreditMath` checks heritage list then global list) + installed-DAT completeness assertion recording reality: the global SkillTable prices 38/54 advancement skill ids, every one of the 13 heritages ships EXACTLY one heritage-specific override (always also present in the global table), and 16 skill ids are genuinely uncostable in both tiers (retail's -1 case) — see `ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet`; F3 every `ChargenTableReader` collection is now frozen at projection (`ToFrozenDictionary`/`ToArray`, matching `MagicCatalog`'s pattern) including both `ChargenOptions.Empty` dictionaries; F4 a reflection guard test (`ChargenNoChoriziteLeakTests`) pins the no-Chorizite-leak contract by walking every public `AcDream.Core.CharGen` member; F5 `HasAnyAppearanceOptions`'s doc reworded to state precisely what it proves (an OR across eight lists, omitting the three color lists) + a new installed-DAT gate records per-list reality — found COMPLETE, every gender of every heritage has non-empty lists across all eight plus the three color lists, even the sparse Gear Knight/Olthoi variants; F6 `TryGetHeritage`/`TryGetStarterArea` annotated `[MaybeNullWhen(false)]` (matching the house `EmptyDatReaderWriter` pattern), all affected call sites (more than the originally estimated five) fixed across both test projects. Filed CC7 risk item 8: ACE's `PlayerFactory` heritage-override branch over-deducts skill credits when specializing a heritage-priced skill (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) — a retail-legal build may be rejected by local ACE at the CC7 connected gate; this is an ACE bug, not an acdream defect. **Narrow re-review CLOSED:** the reviewer retro-graded F2 to HIGH (under the base commit 37 of 38 costable skills were charged zero) and confirmed the SkillBase.SpecializedCost->PrimaryCost mapping dodged the UpgradeCostFromTrainedToSpecialized trap. Residuals: R1 retail refunds +1 credit on a both-tier miss (port charges 0; unreachable via retail’s own skills listbox — NOTE FOR CC3 if any path ever exposes the 16 uncostable ids); R2 list downcast-mutability and R3 field-walking in the leak guard CLOSED at the merge-closeout commit (Array.AsReadOnly at every projection seam; GetFields walk added). Decomp fact for CC4: ApplyTemplate force-sets template_=0 for heritage 0xc/0xd — both Olthoi variants are hard-locked to Custom/template 0. |
| CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL |
-| CC3 | FIX ROUND COMPLETE 2026-08-15 (Opus dual-lens review found blocking findings F1-F4 on the controller integration, retail fidelity PASS; fix round addressed F1-F16; narrow re-review owed) | `9a84230c` (implementation), this fix round (see git log — sha unknowable pre-commit) | Blocking findings fixed (see fix-round summary below); narrow re-review owed | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation 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 (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. |
+| CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation 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 (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. |
| CC4 | — | | | |
| CC5 | — | | | |
| CC6a | — | | | |
diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs
index c169fce8..4eba5e94 100644
--- a/src/AcDream.Runtime/Session/LiveSessionController.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionController.cs
@@ -433,6 +433,16 @@ public sealed class LiveSessionController
private bool _disposed;
private ulong _generation;
private RuntimeTeardownStage _lastTeardownStages;
+
+ ///
+ /// CC3 re-review R1: creates accepted since the last wire
+ /// CharacterList was applied. ACE appends each created character to
+ /// its own session list but never resends the list, so the cached
+ /// wire count under-counts by exactly this number — the equivalent
+ /// of retail's own CharacterSet growing via AddIdentity per create.
+ /// Guarded by _gate like every sibling field.
+ ///
+ private int _createsSinceCharacterList;
private LiveSessionCharacterSelection? _activeSelection;
private Action? _autoSaveTickHook;
private Action? _preLogoffFlushHook;
@@ -745,6 +755,7 @@ public sealed class LiveSessionController
RuntimeGenerationToken activeGeneration = new(generation);
CharacterSelectionState.Reset(activeGeneration);
CharacterCreationState.Reset(activeGeneration);
+ _createsSinceCharacterList = 0;
try
{
DrainRetiredScope();
@@ -818,6 +829,7 @@ public sealed class LiveSessionController
CharacterList.Parsed? characters = _operations.GetCharacters(session);
if (characters is not null)
{
+ _createsSinceCharacterList = 0;
LiveSessionRosterReport roster = BuildRosterReport(characters);
CharacterSelectionState.ApplyRoster(roster);
host.ReportRoster(roster);
@@ -947,6 +959,7 @@ public sealed class LiveSessionController
{
if (!IsCurrent(scope, generation))
return;
+ _createsSinceCharacterList = 0;
LiveSessionRosterReport report = BuildRosterReport(roster);
CharacterSelectionState.ApplyRoster(report);
scope.Host.ReportRoster(report);
@@ -1337,9 +1350,22 @@ public sealed class LiveSessionController
// 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.
+ // CC3 re-review R1: the cached count is stale by the number of
+ // creates since the last CharacterList (ACE never resends one
+ // post-create), so a SECOND create in the same session must add
+ // the creates the cache hasn't seen — retail's own CharacterSet
+ // grows via AddIdentity per create, keeping GetSlot correct the
+ // same way. The counter applies ONLY to the cached-wire branch:
+ // the display-roster fallback already contains every prior
+ // create (AppendCreatedCharacter added them), so adding the
+ // counter there would double-count. Resets whenever a fresh wire
+ // CharacterList is applied and at generation reset.
int wireIndex =
_operations.GetCharacters(scope.Session)?.Characters.Count
- ?? before.RosterCount;
+ is int cachedWireCount
+ ? cachedWireCount + _createsSinceCharacterList
+ : before.RosterCount;
+ _createsSinceCharacterList++;
CharacterSelectionState.AppendCreatedCharacter(
identity.Guid,
identity.Name,
@@ -1662,6 +1688,7 @@ public sealed class LiveSessionController
_activeSelection = null;
CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation));
CharacterCreationState.Reset(new RuntimeGenerationToken(_generation));
+ _createsSinceCharacterList = 0;
if (_scope is { } scope)
{
_scope = null;
diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs
index d6048d67..91ebbdb4 100644
--- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs
@@ -92,11 +92,25 @@ public sealed class LiveSessionControllerCharacterCreationTests
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
EnterWorldCount++;
+ /// R1: when positive, the next guid-enter throws retail's
+ /// server-rejection shape (the transport-valid path that returns
+ /// the controller to selection), decrementing per call — lets a
+ /// test reach the create-again-after-rejected-enter flow.
+ public int EnterWorldByGuidRejectionsRemaining { get; set; }
+
public void EnterWorldByGuid(
WorldSession session,
uint characterGuid,
- string accountName) =>
+ string accountName)
+ {
EnterWorldByGuidCalls.Add((characterGuid, accountName));
+ if (EnterWorldByGuidRejectionsRemaining > 0)
+ {
+ EnterWorldByGuidRejectionsRemaining--;
+ throw new CharacterSelectionRejectedException(
+ new CharacterError.Parsed(0x0000000Bu));
+ }
+ }
public void Tick(WorldSession session) { }
@@ -252,6 +266,57 @@ public sealed class LiveSessionControllerCharacterCreationTests
Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId);
}
+ ///
+ /// CC3 re-review R1: a SECOND create in the same session must get wire
+ /// slot N+1, not N. ACE never resends CharacterList post-create, so the
+ /// cached wire count alone under-counts by the creates it hasn't seen;
+ /// the controller's creates-since-list counter (reset on every fresh
+ /// wire CharacterList) supplies the difference — the equivalent of
+ /// retail's own CharacterSet growing via AddIdentity per create. The
+ /// create-again path is reached exactly as the re-review described:
+ /// first create Ok, guid-enter rejected by the server
+ /// (CharacterSelectionRejectedException → ReturnToSelection), then a
+ /// second create.
+ ///
+ [Fact]
+ public void SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot()
+ {
+ (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
+ StartAwaitingSelection();
+ BuildReadyCharacter(controller, generation);
+ WorldSession session = operations.Sessions[0];
+ session.GameMessageCapture = (_, _) => { };
+ operations.EnterWorldByGuidRejectionsRemaining = 1;
+
+ Assert.True(controller.Finish(generation).Accepted);
+ InvokeProcessDatagram(session, BuildResponsePacket(
+ (uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar"));
+
+ // The rejected enter left us back at selection with the first
+ // created character appended at the true wire slot 2.
+ Assert.False(controller.IsInWorld);
+ Assert.Single(operations.EnterWorldByGuidCalls);
+ Assert.True(controller.CharacterSelectionState.View.TryGet(0x50001234u, out RuntimeCharacterSelectionEntry firstCreated));
+ Assert.Equal(2, firstCreated.ActiveIndex);
+
+ // Second create in the same session: ACE's own list now holds
+ // Zed(0), Amy(1), NewChar(2) — the cached wire list still only
+ // holds Zed and Amy. The second character's slot must be 3.
+ Assert.True(controller.SetName(generation, "SecondChar").Accepted);
+ Assert.True(controller.Finish(generation).Accepted);
+ InvokeProcessDatagram(session, BuildResponsePacket(
+ (uint)CharGenVerificationResponse.Code.Ok, 0x50005678u, "SecondChar"));
+
+ Assert.True(controller.CharacterSelectionState.View.TryGet(0x50005678u, out RuntimeCharacterSelectionEntry secondCreated));
+ Assert.Equal(3, secondCreated.ActiveIndex);
+ // Pre-existing wire indices still intact after both appends.
+ 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.Equal(2, host.Created.Count);
+ }
+
[Fact]
public void Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection()
{