acdream/docs/plans/2026-08-15-character-creation-campaign.md
Erik ddcbf1fbf2 docs: Campaign CC slice CC7 ledger row + campaign status — all slices code-complete
Records CC7's implementation (Create-button un-ghosting, the full-flow
wire tests, the launcher payload cycle verification, the 4 pre-existing
LiveSessionControllerTests fixes, the AP-211 register update, the
connected checklist doc) against commit 9cf6c522, and flips the plan's
header Status line to reflect all seven slices (CC1-CC7) code-complete
pending CC7's own dual-lens review and the user's connected gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 02:36:35 +02:00

118 KiB
Raw Blame History

Campaign CC — retail character creation

Status: All seven slices (CC1-CC7) are CODE-COMPLETE. CC7 (this slice) closed out the campaign's implementation: the Create button un-ghosts and opens chargen for real, the full 0xF656/0xF643 flow is proven end-to-end against a real WorldSession, the launcher status-payload cycle is proven end-to-end against the real Launcher.Core tailer, and the connected-gate script is written. CC7's own dual-lens review is OWED, and the user's connected gate (docs/research/2026-08-16-campaign-cc-test-script.md) is the campaign's remaining acceptance step — no automated live character creation has been run against ACE (see the script's own §CC-Not-Automated). Goal (user-set): the full retail creation flow against local ACE — Create button through a new character entering the world, 3D preview live, rejections showing retail's dialogs — then stop for the user gate. Branch: claude/acdream-launcher-credentials-4d2f7c Process: Campaign LA's, binding (Sonnet implements, Opus dual-lens reviews per slice, retail decomp is the oracle, register rows with deviations, build+test green per slice, commits tagged Campaign CC).

This plan embeds the 2026-08-15 recon facts (three parallel sweeps: retail gmCG UI, chargen data+wire, acdream seams) so slices and future sessions need no transcript access. references/ACE and references/holtburger are NOT in this worktree (gitignored) — read them from the main checkout at C:\Users\erikn\source\repos\acdream\references\.

Retail ground truth (recon summary — cite these in code)

Flow. Create button (0x100003A0) → QueueUIMode(0x1000000b)gmCharGenMainUI (acclient.h:56232): ONE root layout, enum 0x10000039 via GetDIDByEnum table 5 (our generic RetailDataIdResolver handles this), pages as children. ECGProgress: Heritage=1 → Profession=2 → Skills=3 → Appearance=4 → Town=5 → Summary=6. Nav dispatch gmCharGenMainUI::ListenToElementMessage@237025: Back 0x100003c6 (at Heritage → DoExit), Next 0x100003c7, Finish 0x100003c8 (Summary only), Help 0x100003c9, Exit 0x100003ca (→ ID_CharGen_ExitWarning confirm), Random 0x100003cb (on Summary → randomize warning first). Tab buttons 0x100003ef..f4 jump pages freely (not validation-gated). Page roots: Heritage 0x100003d1, Profession 0x100003d2, Skills 0x100003d3, Appearance 0x100003d4, Town 0x100003d5, Summary 0x100003d6; progress bar 0x100003ce, master page 0x100003d0. Per-page child ids are in the recon-cited ctors: Heritage InitializePage@143731 (13 race buttons + text 0x100003c4), Profession @143010 (6 attribute sliders 0x100003e6..eb, avail/health/stam/mana 0x100003e2..e5, template buttons resolved in UpdateProfession@142180: Custom 0x100003d9, Bowhunter/Swashbuckler/ Lifecaster/Warmage/Wayfarer/Soldier 0x100003da..df), Skills @141911 (listbox 0x100003f7, credits 0x100002f3, info 0x100003fb/fc), Appearance @140032 (gender 0x100003a7/a8, spins hair/eyes/nose/mouth/skin 0x100003af..b3, headgear/shirt/trousers/footwear 0x100003b5..b8, zoom 0x10000325/26, rotate 0x10000323/24, color wheel family 0x1000030e..0x10000321, viewport 0x100003bb), Town @137120 (Sanamar 0x1000040b, Holtburg 0x1000040d, Yaraq 0x1000040e, Shoushi 0x1000040f), Summary @136566 (list 0x10000400, name text 0x10000402 with NameInputFilter, viewport 0x10000406).

CharGenState (acclient.h:40074): the model our Runtime owner mirrors — heritage/gender, appearance strips+styles+colors+shades (f64 shades), template + 6 attributes + credit budgets + per-attribute locks, 55-slot skill advancement array + skill credits, name[33], startArea, setupID, verificationState. Writers per page in the recon (SetHeritageGroup recomputes budgets + ApplyTemplate + RandomizeStartArea; SetGender reapplies clothing and UpdateTrueFacePal).

Finish (DoFinish(this, arg2)@236864): trim+set name → empty name → ID_CharGen_NoNameWarning, abort. CORRECTED at the CC3 review-fix round (F3) — the original line here (remainingAtrbCredits > 0 → abort, "retail FORCES full spend") was WRONG; retail does NOT force a full spend. The real gate is arg2 != 0 && remainingAtrbCredits > 0: the ordinary Finish-button click passes arg2 = 1 (@0x004E9579), and on unspent credits shows MakeCreditWarningDialog and returns WITHOUT sending (@0x004E91F2-0x004E9210) — but that dialog's own confirm handler re-invokes DoFinish(this, 0) (@0x004E98BB), which SKIPS the credit check entirely (arg2 == 0) and sends with the credits still unspent. ACE accepts this — ValidateAttributeCredits only rejects a total that EXCEEDS the max, never an under-spend. Then: verification state must be UNDEF (no double submit) → set PENDING → Proto_UI::SendCharGenResult@0x00546A70.

Wire 0xF656 (ACCharGenResult::CG_Pack@0x005C7200, byte-identical to ACE's CharacterCreateInfo.Unpack): account String16L FIRST (outside the body), then u32 constant 1, u32 heritage, u32 gender, u32×3 eyes/nose/mouth strips, u32×2 hairColor/eyeColor, u32 hairStyle, u32×2 headgearStyle/Color, u32×2 shirt, u32×2 trousers, u32×2 footwear, f64×6 skin/hair/headgear/shirt/ trousers/footwear shades, u32 templateNum, u32×6 attributes (str/end/coord/quick/focus/self), u32 slot, u32 classID, u32 numSkills + numSkills×u32 advancement classes (MUST be exactly 55 — ACE TERMINATES the session on mismatch), String16L name, u32 startArea, u32 isAdmin, u32 isEnvoy(=ACE IsSentinel), u32 trailing checksum = sum of heritage+gender+strips(3)+hairColor+eyeColor+hairStyle+headgearStyle+ shirtStyle+trousersStyle+footwearStyle+template+6 attributes (ACE never reads it; we send it for byte fidelity). holtburger cross-check: character/types.rs:236 (stops before the checksum).

Response 0xF643 (shared opcode with restore — LA7a's conditional parse is reusable): codes Undef=0 Ok=1 Pending=2 NameInUse=3 NameBanned=4 Corrupt=5 DatabaseDown=6 AdminPrivilegeDenied=7. On Ok the payload is a CharacterIdentity (guid, String16L name, u32 secondsGreyedOut) and NOBODY sends a fresh CharacterList — retail appends the identity to its local roster (Handle_CharGenVerificationResponse@0x0055E8B0 case 1 → CharacterSet::AddIdentity) and gmCharGenMainUI::Update@236161 then watches the set and calls CPlayerSystem::LogOnCharacter DIRECTLY when the new name appears (logs straight in; only falls back to char management if it never appears). Error dialogs, byte-decoded from gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030's switch + its (arg2-1) > 6 unsigned-underflow guard and jump table @0x004e9150 (CC5 review-fix round F2, 2026-08-16 — corrects this paragraph's earlier "Pending/Undef→silent state reset, retail swallows it" claim, which was WRONG): Ok(1)→no dialog (closes any open dialog, marks success, returns); Pending(2)→ID_Character_Err_NameDBDown (explicit switch case, same label as Corrupt/DatabaseDown — NOT a silent reset); NameInUse(3)→ID_Character_Err_NameReserved; NameBanned(4)→ ID_Character_Err_NameBanned; Corrupt(5)/DatabaseDown(6)→ ID_Character_Err_NameDBDown; AdminPrivilegeDenied(7)→ ID_Character_Err_NameAdminDenied; Undef(0) and any code outside 1..7 fall through the unsigned-underflow default arm to the SAME ID_Character_Err_NameDBDown dialog (the switch never has a genuinely silent branch — every non-Ok code shows a dialog). ACE sends Pending for a disabled-Olthoi rejection (CharacterHandler.CharacterCreateEx, olthoi_play_disabled branch); ported faithfully this now means that rejection surfaces a visible NameDBDown dialog, which IS retail's actual behavior — the previous "swallows it" reading made Finish a silent no-op forever for that case instead.

Chargen DAT table 0x0E000002: readable TODAY via the Chorizite.DatReaderWriter package (dats.Get<CharGen>) — zero in-tree readers exist. ACE loaders (ACE.DatLoader.FileTypes.CharGen + HeritageGroupCG/SexCG/TemplateCG) and retail serializers (ACCharGenData::Serialize@0x005C36D0, HeritageGroup_CG@0x005C2100, Sex_CG@0x005C1600, Template_CG@0x005C0450) define the shape: per heritage → name/icon/setup/EnvironmentSetup/attribute+skill credits/start areas/skills(costs)/templates(attrs+skills)/genders; per sex → scale, setup, base palette, skin palset, base ObjDesc, and the option LISTS (hair styles/ colors, eye colors, eye/nose/mouth strips, headgear/shirt/pants/footwear, clothing colors).

3D preview (gmCG3DView, Appearance 0x100003bb + Summary 0x10000406 ONLY — the other four pages have no viewport): preview body CPhysicsObj::makeObject(setupId) (fallback HUMAN_SETUP_ID), rebuild on change via ObjDesc (ClothingTable::BuildObjDesc per clothing slot + strips

  • PalSet skin/hair/eye subpalettes) applied with DoObjDescChangesFromDefault@242308, one DISTANT_LIGHT (intensity 2.0), idle animation loop at 30fps (set_sequence_animation), rest-pose freeze on zoom-in, BUTTON-toggled continuous rotation (DoRotation@137337, 3.0 s/revolution, per-frame global-message-3 tick), zoom tween between per-heritage camera positions (Update@138974 hard-codes Olthoi vs human-form camera offsets).

acdream seams (build on these, do not reinvent)

  • Layout mount: RetailDataIdResolver.Resolve(dats, 0x10000039, 5) + LayoutImporter — fully generic. DatWidgetFactory already maps dat type 0xD → UiViewport. The char-management controller REFUSES viewports by local policy (:212) — chargen gets its OWN controller; clone CharacterManagementUiMountCoordinator + the bindings-record pattern.
  • Fixed canvas: chargen is the same 800×600 flow screen — mount at authored extent, UiRoot.FixedCanvasSize on activate (AD-98), dialogs center on EffectiveCanvasSize. Live-DAT probe tests sweep ALL media ids (CharacterManagementLiveDatTests pattern) and pin authored justify/anchors.
  • Preview pipeline: PrivateEntityViewportRenderer (offscreen target → texture table → UiViewport sprite) is proven by paperdoll + appraisal; cameras there are FIXED — chargen needs a heading-capable camera. NOTE: GlGpuDevice.RegisterExternalColorTexture is a DELETED API that survives only in stale doc comments — do not cite it. Appearance building: DollEntityBuilder.Build is index-agnostic and pure (setup + resolved palette/part ids), but the only existing factory reads a LIVE entity — chargen needs a new index→dat→ObjDesc factory (SexCG.BaseObjDesc + strip overlays + PalSet.GetPaletteID hues). Pose: paperdoll holds a static final frame; retail chargen plays a live idle loop — see slice CC6 for the staged approach.
  • Runtime owner: mirror RuntimeCharacterSelectionState exactly (lifecycle/ snapshot/delta records, borrow-only view, generation-gated commands, one mutable owner, no App types). Command family lands beside IGameRuntimeCommands.CharacterSelection. Enter-after-create hooks the existing LiveSessionController.BeginEnter/CompleteEnter.
  • Wire plumbing: WorldSession's dispatch chain routes EVERY 0xF643 through CharacterRestore.Parse today with no request correlation — the KNOWN LANDMINE. Creation requires an awaiting-request latch (create vs restore) BEFORE its response arm lands. Outbound mirrors SendRestoreCharacter@2223. Status writer: add characterCreated / creationFailed events (update the pinned §LA1 contract text + the Launcher.Core tailer + tests in lockstep).

Slices

Slice Deliverable Depends
CC1 Chargen data layer: CharGen table reader → typed options model (heritages/sexes/appearance lists/templates/skills+costs/budgets/towns), Content/Core, live-DAT probes
CC2 Wire: CharacterCreate 0xF656 builder (byte-exact incl. checksum), shared verification-response type (refactor from CharacterRestore), WorldSession request-correlation for 0xF643, send seam, status events + contract/tailer update
CC3 RuntimeCharacterCreationState: full CharGenState mirror, per-page commands, retail client gates (full-spend, name, 55-slot invariant, client-side slot cap), verification latch, Ok → roster append + retail log-straight-in CC1, CC2
CC4 Screen shell + form pages (App): mount (enum 0x10000039), master nav/tabs/progress, dialogs, Heritage + Profession + Skills + Town pages CC1, CC3
CC5 Summary page: name input (NameInputFilter, ID_CharGen_NameTooLong), summary listbox, static summary viewport, Finish gates + full response/dialog handling. CC6b-MOUNT review fix round F12 amendment (2026-08-15): Finish gates MUST add a heritage/gender refusal to RuntimeCharacterCreationState.TryBeginFinish — with AD-101 retired, a caller can hold _genderKey == 0 (or, before a real heritage/gender selection, _heritageId == 0) all the way to Finish, and TryBeginFinish's current four refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) have no gate for either — see AP-214's own noted latent-interaction risk. This slice MUST ALSO land a real RandomizeCharacter port (the shared AP-214/AP-212 primitive gap) BEFORE the connected user gate opens Finish for real use — the reviewer's requirement, not optional polish: retail's gmCharGenMainUI ctor rolls a full character before any page constructs (AP-214), so a heritage/gender check alone does not reproduce retail's actual guarantee that Finish is never reachable with an unset heritage/gender; only porting RandomizeCharacter closes that gap the way retail's own architecture does. CC3, CC4
CC6 Appearance page + preview: index→ObjDesc factory, chargen preview renderer (offscreen, heading camera, rotate/zoom buttons), spin controls + color wheels; staged: CC6a static-pose preview (paperdoll-style held frame, register row for the missing idle loop), CC6b idle animation + zoom rest-freeze (retire the row) CC1, CC4
CC7 End-to-end: Create button un-ghosts, full flow vs ACE shapes in tests, launcher payload cycle, connected checklist doc all

Parallelism: CC1 ∥ CC2 (disjoint: Content/Core vs Core.Net; separate worktrees). CC4 ∥ CC6a after CC3. CC5 last before CC7.

Risks / open items (from recon Unknowns)

  1. 0xF643 create/restore correlation (CC2's first job; the restore doc comment already warns).
  2. 55-slot skill array: ACE terminates the session on mismatch — CC2/CC3 must make it structurally impossible to send anything else.
  3. Slot cap is client-enforced only (ACE never checks on create) — honor slotCount like retail's UI did.
  4. Color-wheel/gradient widgets (tagColorWheel, GradCircle 0x1000030e, shade scroll) may need new widget types in DatWidgetFactory — CC6 scouts the authored layout first.
  5. Retail unknowns to resolve during slices, never guess: the chargen please-wait dialog context (decompiler-mislabeled field), the AppearancePage gender-flip-on-init oddity (@140355 — verify live before porting), Method_CG enums are empty in the header, ZoomIn tween duration constant is decompiler-garbled (measure against retail if it matters).
  6. Viewport inside the fixed canvas: the offscreen target's pixel size vs the canvas-scaled on-screen rect (render at scaled size for crispness or authored size for fidelity) — decide in CC6a with the user gate as arbiter.
  7. references/* absent in worktrees (except WorldBuilder, uninitialized submodule) — agents read ACE/holtburger from the MAIN checkout path.
  8. CC7 landmine (found in the CC1 review fix round, 2026-08-15): ACE's PlayerFactory.CreatePlayer heritage-override branch (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) over-deducts skill credits when specializing a skill the active heritage's own list prices. For a skill priced ONLY by the global SkillTable, ACE correctly computes the incremental specialize cost via SkillBase.UpgradeCostFromTrainedToSpecialized (= SpecializedCost - TrainedCost) and charges TrainSkill(trainedCost) + SpecializeSkill(incrementalCost) = the field's TOTAL, matching retail. But when the heritage's own list has an entry, ACE sets specializedCost = skillGroup.PrimaryCost directly — PrimaryCost is already the TOTAL cost to reach Specialized (acdream's own ChargenSkillCost.PrimaryCost convention, confirmed against retail) — and then still charges TrainSkill(NormalCost) + SpecializeSkill(PrimaryCost), over-deducting by an extra NormalCost credits versus what retail's client computed and what the player agreed to spend. Practical impact for CC7's connected gate: a retail-legal character build that specializes a skill the ACTIVE HERITAGE prices (every one of the 13 installed heritages has exactly one such skill — see ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet) may be REJECTED by local ACE with FailedToSpecializeSkill even though 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. 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.

Review protocol

Per slice: implement → Opus dual-lens (architectural + retail fidelity — this campaign is retail-heavy everywhere) → fixes → narrow re-review → DONE in ledger. CC2's review adds wire-byte scrutiny (the LA7a precedent: the reviewer decodes the binary); CC6's adds the visual-fidelity lens ahead of the user gate.

Ledger

Slice Status Commits Review Notes
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 retails 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 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 @ 0x004EC160SetSlot(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 REVIEW-CLOSED 2026-08-15 0e71d3b8, ec854db0, 8add0667, + the R5 closeout commit CLOSED after two fix rounds + final re-review (R1 arbiter CLOSED; R5 — the chargen root extent pinned 800x600 by live-DAT observation in the closeout commit, closing the mismatch-throw crash premise). Original verdict: architectural FAIL (F1, F6) + retail-fidelity PASS-with-reservations (F2, F3, F4) + LOW findings F5/F7-F12 (F13 is a merge-mechanics note for the orchestrator, not an acdream defect). Fix round applied same-session (see the "Review fix round" paragraph at the end of this row); re-review status owed to the orchestrator. Screen shell + form pages (App layer). Mount: CharacterCreationUiController/CharacterCreationUiMountCoordinator (src/AcDream.App/UI/Layout/) clone CharacterManagementUiController's recipe — enum 0x10000039 via RetailDataIdResolver.Resolve(dats, ..., 5u), root 0x100003CC (decomp-verified: gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0, NOT the plan doc's earlier 0x100003cc-adjacent guesses — confirmed live against the installed DAT, [CC4-DAT] enum=0x10000039 -> DID=0x21000038), fixed-canvas AD-98 treatment shared with char-management. CORRECTED at the review fix round (2026-08-15, F1) — the original claim above was FALSE: CharacterManagementUiController does NOT do a per-tick set; it writes UiRoot.FixedCanvasSize ONCE on its own activation edge and NULLS it in both Deactivate() and Dispose(). This controller now matches that exact shape: Open() sets the canvas once, Close()/Deactivate()/Dispose() null it symmetrically. The un-nulled canvas was a real bug: RuntimeCharacterCreationState had no CompleteEnter() analogue to RuntimeCharacterSelectionState's (added this round, wired at both LiveSessionController in-world edges), so the chargen view reported IsActive=true for an entire in-world session, and since RetailUiRuntime.Tick ticks char-management BEFORE chargen, chargen's un-nulled canvas would silently re-pin an 800x600 scale over the in-world UI forever once the screen had ever been opened (dormant at defaults, armed under ACDREAM_OPEN_CHARGEN=1). Master shell: progress bar 0x100003ce, master page 0x100003d0 (state 0x10000025+page-1), 6 page roots, 6 free-navigation tabs (0x100003ef..f4), nav buttons 0x100003c6..cb — full decomp port of gmCharGenMainUI::ListenToElementMessage @ 0x004e9450 (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and SetProgressState @ 0x004e7a10 (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via RetailDialogFactory.MakeConfirmation + ID_CharGen_ExitWarning (table 0x23000002, matching DoExit @ 0x004e8650); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting gmEpilogueUI. Heritage page (CharacterCreationHeritagePage.cs, decomp InitializePage @ 0x00483a10 + the EXACT button-id→heritage-id map read off ListenToElementMessage @ 0x00483860, which is NOT numeric-order — e.g. 0x100005e8→Tumerok(7)): all 13 buttons, composed description text (ID_CharGen_Heritage_StartingSkills_Header/Body, ID_CharGen_Heritage_BonusSkills_Trained_Header + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's case 5: case 0xa:; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). Profession page (CharacterCreationProfessionPage.cs, InitializePage @ 0x00482d50 + UpdateProfession @ 0x004821b0's template map, cited already on ChargenTemplate): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's DynamicCast calls don't predict: the slider's value display (0x100002ef) imports as UiField not UiText (retail's NumberInputFilter, @0x00482e36) — wired for direct numeric entry via OnSubmit, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as UIElement_Button whose Type-12 value child is swallowed by UiButton.ConsumesDatChildren before ever becoming an addressable widget — substituted with the button's own .Label (AD-103). Health/Stamina/Mana formulas ported from UpdateAttributeValues @ 0x00482450: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at _ftol2 @0x0048262b, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=RemainingAttributeCredits directly (UpdateCreditsMeter-style, no formula). Skills page (CharacterCreationSkillsPage.cs, InitializePage @ 0x00481dd0): ONE flat listbox (AP-213, retail's four-bucket sorted InsertEntrySorted/UpdateSkillEntry model not ported) driven by CC3's TrainSkill/SpecializeSkill/UntrainSkill + the SAME two-tier TryGetSkillCost presence gate RuntimeCharacterCreationState uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes 0x100003fb/fc unbound (no info-pane content source this round). Town page (CharacterCreationTownPage.cs, InitializePage @ 0x0047c6d0 + SetTown @ 0x0047c360's literal index map): the four buttons map to LITERAL startArea indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. Random (0x100003cb, DoRandom @ 0x004e7d70): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no RandomizeHeritageGroup/RandomizeTemplate primitives exist); disabled outright on Skills (no RandomizeSkills primitive), Appearance (placeholder), Summary (CC5's warning dialog). Options threading: RuntimeCharacterCreationState.InstallOptions(ChargenOptions) (new, mirrors RuntimeCharacterState.InstallSpellMetadataSpellbook.InstallMetadata's "install immutable DAT metadata after construction, throw if already active" pattern) called from ContentEffectsAudioCompositionPhase.Compose (new ChargenOptionsInstalled composition point, right after SpellMetadataInstalled) via IContentEffectsAudioCompositionFactory.LoadChargenOptions/InstallChargenOptionsChargenTableReader.Load(dats) threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's Begin(). CORRECTED at the review fix round (2026-08-15, F6): the original claim that headless was unaffected left a dead end — HeadlessSessionHost wired the CharacterCreated/CreationFailed status hooks (closing CC3's F14) but never installed ChargenOptions, so a content-bearing headless host could observe a create but never actually issue one (every chargen command silently refused against ChargenOptions.Empty). Fixed by installing options directly beside the existing InstallSpellMetadata call, off the same HeadlessProcessContentLease.Dats, whenever contentLease is non-null; a content-less headless host (a validated-legal configuration — see the R9 note near _contentLease's other reads) still cannot issue chargen commands, matching its existing inability to resolve spell/collision data either. Status hooks: LiveSessionLifecycleBindings gained optional CharacterCreated/CreationFailed delegates (default null — every pre-CC4 construction site keeps compiling); LiveSessionLifecycleHost now overrides both ILiveSessionLifecycleHost methods to forward them; LiveSessionHostBindings gained matching optional fields threaded through LiveSessionHost's constructor; both LiveSessionRuntimeFactory.Create (App/graphical) and HeadlessSessionHost wire them to SessionStatusWriter.CharacterCreated/CreationFailed, closing CC3's F14 (zero call sites). Deferred command seam: IGameRuntimeView.CharacterCreation (new default-throw member, mirrors CharacterSelection), GameRuntime.CharacterCreation (passthrough to Session.CharacterCreation), CurrentGameRuntimeAdapter's new CharacterCreationProjection (IsActive-gated view+command wrapper, mirrors CharacterSelectionProjection), DeferredGameRuntimeStateCommands's new CharacterCreation view getter + 9 generation-capturing wrapper methods, and CharacterCreationRuntimeBindings wired in InteractionRetainedUiComposition.cs (CharacterCreation: sibling of CharacterSelection:, ResolveText backed by a DatStringResolver cached once per composition (characterCreationStrings, review fix round F12 — a fresh resolver per call was allocating + re-locking on every Heritage/Town description lookup, several times per page switch) and locked under d.DatLock only around each .Resolve call, OpenOnStart from the new RuntimeOptions.OpenCharacterCreationOnStart / ACDREAM_OPEN_CHARGEN=1 env flag — the interim open seam since Create stays ghosted). Widget types added to DatWidgetFactory: NONE — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. Register rows filed (same commit): AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). Tests: tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs (7 cases, ACDREAM_PROBE_LIVE_MOUNT=1-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + CharacterCreationUiControllerTests.cs (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs (+4 InstallOptions cases) + tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. OPEN for CC5/CC6a/CC6b: the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes 0x100003fb/fc have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. Review fix round (this commit, 2026-08-15): F1 (HIGH, blocking, architectural) — see the corrected FixedCanvasSize paragraph above; added RuntimeCharacterCreationState.CompleteEnter() (mirrors RuntimeCharacterSelectionState's own, wired at both LiveSessionController in-world edges: StartCore and the shared EnterHighlightedCore) and made CharacterCreationUiController.Open/Close/Deactivate/Dispose set/null UiRoot.FixedCanvasSize symmetrically with CharacterManagementUiController's real (not per-tick) shape; added FixedCanvasSize coverage to CharacterCreationUiControllerTests. F2 (MEDIUM-HIGH, blocking, fidelity) — the attribute-slider scalar mapping was NOT retail's: fixed the display scalar to value/100f (UpdateAttributeValues @ 0x0048251d) and the drag inverse to Math.Max(10, (int)(scalar*100f)) — truncate, clamp low only, no rescale (ListenToElementMessage @ 0x004829c0's scrollbar-drag case, independently re-derived against the decomp and confirmed byte-for-byte); added tests at scalar 0.5 and 0.0 (the previous single scalar=1f test coincidentally agreed with both the old wrong formula and the new correct one). F3 (MEDIUM, blocking, fidelity) — ported ListenToElementMessage @ 0x004e9450's heritage-button tab-restore arm (independently re-derived from the decomp: SHOW ids 0x100003bf/c1/c2/c3/10000590/91/100005a9/bf/c4/e8, HIDE ids 0x100005c7/c8, with Lugian 0x100005f1 genuinely absent from both switch cases — a real retail quirk, reproduced faithfully) as CharacterCreationUiController.ApplyHeritageTabRestore, invoked synchronously from a new CharacterCreationHeritagePage ctor callback on every button click; added restore-after-Olthoi-hide and Lugian-no-restore tests. F4 (MEDIUM, fidelity, blocks the user gate) — gmCGTownPage::SetTown @ 0x0047c360 also sets the TOWN PAGE's own retail state (a separate literal map from the master page's per-page-index cycling: Holtburg->0x10000034, Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035, re-asserted directly at the Sanamar-click site @0x0047c518) — independently re-derived from the decomp's tail-merged-branch pattern and ported to CharacterCreationTownPage.Refresh via the existing IUiDatStateful.TrySetRetailState seam; added a test. F5 (MEDIUM) — AD-103's "composited pixel result unchanged" claim was asserted, not measured; softened to state the equivalence is unverified rather than building a rect/justify comparison probe this round. F6 (MEDIUM, blocking, architectural) — decision: install ChargenOptions in the headless content path (option (a) of the two offered), not the deferred/out-of-scope alternativeHeadlessSessionHost now calls RuntimeCharacterCreationState.InstallOptions(ChargenTableReader.Load(content.Dats)) beside the existing InstallSpellMetadata call whenever contentLease is non-null, closing the gap where CC3's F14 status hooks were wired but no content-bearing headless host could ever produce a create to observe. F7 (LOW-MEDIUM) — AP-213 already named the label format and the click/double-click substitution explicitly on inspection; no row edit needed. F8 (LOW) — AP-212 now names all SIX of DoRandom's decompiled primitives (added the three the original row omitted: RandomizeAppearance @ 0x005c4f10, RandomizeClothing @ 0x005c6770, RandomizeCharacter @ 0x005c6d80, independently verified against the decomp alongside the three already-cited ones) and states the known landing site (Runtime, beside CC3's CharGenState ports). F9 (LOW) — AD-101's retirement condition corrected: must happen before CC5's Finish un-ghosts, not merely "at CC6b" (CC5 precedes CC6b in the slice order; shipping Finish first would let a create complete on an implicit gender default). F10 (LOW) — merged ItemAppraisalTextFormatter.SkillName's two consecutive <summary> blocks into one. F11 (LOW) — TS-82's "see AP-211's sibling gate" cross-reference was wrong (AP-211 is the unrelated roster-slot-cap refusal); corrected to point at TS-82's own CC5 dependency. F12 (LOW) — cached the chargen DatStringResolver once per composition (characterCreationStrings in InteractionRetainedUiComposition.CreateRetainedUi) instead of constructing + DAT-locking fresh on every ResolveText call; the LinesProvider per-Refresh closure allocation already matched the house pattern used throughout CharacterStatController.cs and elsewhere, so it was left as-is. F13 is a merge-mechanics note (TS-82 collides with campaign-cc6a's TS-82/83) for the orchestrator at merge time — no acdream-side action taken. CC4 re-review round (ec854db0's own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above): the F1 fix's raw _host.FixedCanvasSize = null in Close() was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. Fix — the root-cause shape, no workaround: UiRoot gained a single arbiter, DeclareFixedCanvas(object owner, Vector2 size)/RevokeFixedCanvas(object owner) (see AD-98's own register row for the mechanism detail); both CharacterCreationUiController and CharacterManagementUiController now declare on their activation edge and revoke on close/deactivate/dispose instead of writing FixedCanvasSize directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for UiRootFixedCanvasTests' isolated scale-math coverage. Test (reviewer-specified): tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs — two controllers sharing ONE UiRoot, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). R3 (LOW): tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs's new ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted proves F6's install actually opens the gate — a HeadlessSessionHost built with a content lease carrying a REAL hand-built DatCharGen heritage (not ChargenOptions.Empty) has that heritage present in CharacterCreationState.Options, and TrySelectHeritage for it succeeds once Begin is called (both called directly via this project's existing InternalsVisibleTo on AcDream.Runtime, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). R2 (LOW): filed docs/ISSUES.md #402 for the pre-existing Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched 82f8d4f8 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. R4 (LOW): fixed the "unchached" → "uncached" typo in InteractionRetainedUiComposition.cs's F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 CharacterScreensFixedCanvasArbiterTests cases), Headless 166/0 (+1 new: R3's test), full solution Release build green.
CC5 REVIEW-CLOSED 2026-08-16 34e3a534, a975efd1 (ledger), 0c8e1e7d (fix round), 2d4168f9 (ledger), residual round 356545c5 CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F14 fix round 0c8e1e7d → narrow re-review: all code fixes oracle-verified, residuals R1-R5 all test/doc → this commit; re-reviewer pre-authorized lead diff-check close) Summary page (CharacterCreationSummaryPage, src/AcDream.App/UI/Layout/) fills TS-82's placeholder: name field (0x10000402, UiField) with NameInputFilter @ 0x004663b0 ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (ListenToElementMessage @ 0x0047bf40) mapped onto UiField.OnFocusLost/OnSubmit; a >32-char commit reverts the field and shows ID_CharGen_NameTooLong (DoNameLimitDialog @ 0x0047bd80) — the field's own UiField.MaxCharacters is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by SummaryNameField_TooLong_... failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (0x10000400, UiTemplateListBox) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (SetSummaryText @ 0x0047b1d0's three AddItemFromTemplateList indices: template 0 = one UiText line at child 0x100002f9, template 1 = a category-header UiText at 0x100000fe, template 2 = a key/value UiText PAIR at 0x100002fc/0x100002fd — all three CONFIRMED present with those exact child types by CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching SetSummaryText's own 0..9 loop — Health/Stamina/Mana reuse CharacterCreationProfessionPage.Refresh's own already-cited UpdateAttributeValues @ 0x00482450 formulas rather than this page's OWN decompiler-ambiguous GetAttribute(2)/GetAttribute(2) pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (0x10000406) is its OWN gmCG3DView instance — decomp-confirmed a SEPARATE instance from the Appearance page's (InitializePage @ 0x0047bbf0's own gmCG3DView::gmCG3DView/SetCamera/SetPlayerHeading(180)/StartAnimation calls, matching the plan's own citation) — wired through a SECOND, independent ChargenPreviewRenderer/ChargenPreviewController pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: LivePresentationResult/LivePresentationComposition.Compose (a new RetailSummaryPreviewPageVisibility sibling class), FrameRootComposition's PrivateEntityViewportFrameGroup (4th member), GameWindow/GameWindowLifetime guard fields + RenderShutdownRoots disposal entries, and RetailUiRuntime's SummaryPreviewViewportWidget/SummaryPreviewControl/IsSummaryPreviewPageVisible — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). RandomizeCharacter port (the F12 amendment's own explicit requirement, RuntimeCharacterCreationState.cs): CharGenState::RandomizeCharacter @ 0x005c6d80 and its six sub-primitives (RandomizeAppearance @0x005c4f10, RandomizeHeadgear @0x005c5e10, RandomizeShirt @0x005c5ef0, RandomizeTrousers @0x005c5fb0, RandomizeFootwear @0x005c6070, RandomizeClothing @0x005c6770, RandomizeTemplate @0x005c6500) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of RandInt(int) @0x00684400 (uniform [0,count)) and RandInt(int,int) @0x00684420 (re-roll until different from the excluded value, short-circuiting to 0 for count&lt;=1 to avoid an infinite loop), AND acclient.h's own CharGenStateVtbl struct, whose ___u1 member is literally a union of GetRandomInt(this,int,int)/GetRandomInt(this,int) — confirming RandomizeAppearance's vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap feedback_bn_decomp_field_names.md warns about). The heritage roll (RollDice(1, hasToD?4:3)) is confirmed to pick ONLY among the four HUMAN heritage groups (ChargenHeritageGroup.Aluvian..Viamontian, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. RandomizeTemplate's Olthoi branch (template_=1 then ApplyTemplate force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through RandomizeCharacter specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (TryRandomizeCharacter/TryRandomizeAppearance/TryRandomizeClothing) thread through the full stack (IRuntimeCharacterCreationCommandsLiveSessionControllerCurrentGameRuntimeAdapter.CharacterCreationProjectionDeferredGameRuntimeStateCommandsCharacterCreationRuntimeBindings), consumed by three call sites: (a) CharacterCreationUiController.Open's new RollOpeningCharacter — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring gmCharGenMainUI's ctor-time call, and then reproduces gmCGAppearancePage::InitializePage's own gender-read-and-FLIP-to-the-opposite (~0x004802da-0x00480303, decomp-confirmed mGender==1→SetGender(2)/mGender==2→SetGender(1)) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, Open() (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind MakeRandomizeWarningDialog @ 0x004e8a90's ID_CharGen_RandomizeWarning confirmation (gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400's own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside DoRandom itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (DoRandom @0x004e7d70 case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). Finish flow: _finish.OnClick wired to OnFinish/TryFinish (previously null — retail enables Finish on Summary only, ListenToElementMessage's own m_eProgressState != ECG_SUMMARY no-op guard now reproduced via ApplyProgressState's _finish.Enabled gate instead); on a local NoName refusal shows ID_CharGen_NoNameWarning (plain message dialog); on AttributeCreditsUnspent shows ID_CharGen_CreditWarning (MakeCreditWarningDialog @ 0x004e8870), whose confirm re-invokes TryFinish(confirmedUnspentCredits: true) — retail's DoFinish(this,0) call at RecvNotice_CloseDialog @0x004e98bb, already CC3-built (TryBeginFinish's confirmedUnspentCredits parameter existed since the CC3 review-fix round, this slice is its first UI consumer). F12 amendment — RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset (register AP-223): a NEW acdream-only local refusal in TryBeginFinish, checked right after the empty-name check — retail's own DoFinish has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. 0xF643 rejection dialogs (ReconcileDialogs, dedup'd against the last-shown rejection instance since Tick/ReconcileDialogs runs every frame, not just on revision change): NameInUse→ID_Character_Err_NameReserved, NameBanned→ID_Character_Err_NameBanned, Pending/Corrupt/DatabaseDown→ID_Character_Err_NameDBDown, AdminPrivilegeDenied→ID_Character_Err_NameAdminDenied, Undef/any unrecognized code→ID_Character_Err_NameDBDown (default arm) — corrected at the CC5 review-fix round, F2 (2026-08-16): the original CC5 claim that "Pending/Undef never reach this dialog — CC3's ApplyCreationResponse treats them as a silent reset" was WRONG. Byte-decoded gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030 shows Pending is an explicit switch case landing on the SAME NameDBDown label as Corrupt/DatabaseDown, and Undef falls through the function's (arg2-1) > 6 unsigned-underflow default arm to that same label — there is no silent branch in retail's dispatch at all. ApplyCreationResponse now produces a real RuntimeCharacterCreationRejection for Pending/Undef instead of a silent state reset, so ACE's disabled-Olthoi Pending rejection (which used to make Finish a silent no-op forever) now correctly surfaces the NameDBDown dialog; dismiss calls the already-existing AcknowledgeRejection command (now finally wired to a UI consumer via a new SetName/AcknowledgeRejection pair on CharacterCreationRuntimeBindings, both of which existed on IRuntimeCharacterCreationCommands since CC3 but had no App-layer binding until this slice). Register bookkeeping this commit: TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. Tests: tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning RandInt's count&lt;=1 short-circuit) — the fixture (RuntimeCharacterCreationStateFixture.cs) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real RandomizeCharacter roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — Finish_GhostedExceptOnSummary, Random_IsDisabledOnSkillsPageOnly); tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs's scratch structure probe replaced by a permanent SummaryPage_HasNameFieldListboxTemplatesAndViewport gate. Counts (Release, ACDREAM_PROBE_LIVE_MOUNT=1 + ACDREAM_DAT_DIR set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing SocialPanelLiveMountProbeTests.ProbeLiveMountShapes passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the IRuntimeCharacterCreationCommands interface addition needed no Headless-side changes), full solution Release build green. OPEN for CC6/CC7: the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); RandomizeSkills/the Skills-page Random stays hard-disabled; the Summary "How To" text (0x10000404) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that RandomizeTemplate's Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). Re-review residual round (2026-08-16, this commit): the narrow re-review of 0c8e1e7d found every code fix oracle-verified but returned NOT CLOSED on five test/doc residuals plus nits. R1 — added the missing App-layer regression test (CharacterCreationUiControllerTests.SummaryNameField_RealCommitAfterExternalRefreshWhileUnfocused_StillReachesSetName) that actually drives the F1 bug shape (external Refresh-driven SetText while unfocused, THEN a real SetText+Submit user commit), since the claimed coverage never touched the page. R2 — added direct RetailSkillFormula.CalculateChargenScore/ChargenSkillScoreResolver coverage (tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs: a Untrained/Trained/Specialized theory, the divisor-zero skip path, and a six-way AttributeId theory), replacing the F12(d) test's skillId * 10 substitute as the ONLY prior coverage. R3 — MEASURED (not assumed) the installed DAT's SkillTable MinLevel distribution (CharacterCreationLiveDatTests.SkillTable_MinLevelDistribution_NeverExceedsTrained: 23 skills at MinLevel 1, 15 at MinLevel 2, of 38 priced skills, zero above 2) and restated RetailSkillFormula.cs's doc comment around the measured fact instead of the unverified "no skill exceeds Untrained=1" claim — ACE's own hedge (// 1-2?) was right; the structural "gate holds for any MinLevel in {1,2}" argument is now the load-bearing one, not the data claim. R4 — filed AP-228 (the Summary/Skills skill-row KEY sourcing from ItemAppraisalTextFormatter.SkillName's hardcoded English switch, where retail's own key is DAT-sourced — same class as AP-226, reversed polarity, also present at CC4's Skills page) and softened AP-224's "ported exactly" claim to note it only ever covered the row's VALUE/template, never its KEY. R5 — this commit's message corrects 0c8e1e7d's false "Release build zero warnings" gate claim (18 pre-existing warnings, all in the unrelated AcDream.Core.Tests project, none in any project this campaign touched). Plus three nits: the ChargenPreviewController ctor doc now also cites gmCGSummaryPage::Update @0x0047baa0 (the per-heritage re-derive site, not just the one-shot InitializePage seed); the F2 inline comment's "Finish becoming a permanent no-op" reworded (_verificationPending was already cleared pre-fix too — Finish was never blocked, only the RESPONSE feedback vanished); and #404 filed for ChargenSkillScoreResolver's own independent SkillTable read alongside ChargenTableReader's (cleanup, not urgent — not this round's scope).
CC6a CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) 55bfd9ca (foundation), 1774d8b2 (same-session review fix round, F1-F12) Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) Index→ObjDesc factory (ChargenAppearanceFactory.TryCompose, src/AcDream.Core/CharGen/, pure — no Chorizite types on its public surface, verified by the existing ChargenNoChoriziteLeakTests reflection guard, which walks the whole AcDream.Core.CharGen namespace and now covers these new types too): ports gmCG3DView::Update @ 0x004EE9D0's ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → Headgear → Trousers → Shirt → Footwear (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: ChargenPalSet/ChargenPalSetMath (shade→index), ChargenClothingTable/ChargenClothingBaseEffect/ChargenClothingPaletteTemplate/ChargenClothingSubPaletteChoice (pure ClothingTable projection), IChargenPalSetSource/IChargenClothingTableSource (DAT-touching work pushed behind these, implemented by the new Content-layer ChargenAppearanceCatalog, src/AcDream.Content/CharGen/, a cached dat reader mirroring ChargenTableReader's discipline), ChargenAppearanceSelection (mirrors RuntimeCharacterCreationAppearance's 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below): PalSet::GetPaletteID's FPU-elided body ((int)((count - 0.000001) * shade), clamped) is corroborated by ACE's PaletteSet.GetPaletteID (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the >= 0.0 gate at 0x005AC5A0). ACViewer's ClothingTableList.xaml.cs:97 does NOT corroborate this — it computes a different expression (Shades.Maximum - 0.000001, i.e. count-1, not count) for a different problem (mapping a shade back to a UI slider position), and references/ACViewer's vendored PaletteSet.cs is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use PalSet+shade indirection (skin: sex.SkinPalSet; hair: sex.HairColors[i] is ITSELF a PalSet id — confirmed against PlayerFactory.cs:96); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against PlayerFactory.cs:100's EyesPalette = sex.EyeColorList[eyeColor], no GetPaletteID call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against PaletteOverride's pre-existing *8 packing doc comment. Clothing dye resolution, installed-DAT-verified: CharGenState::GetHeadgearPaletteTemplateID/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single Sex_CG::ClothingColors dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male ClothingColors = {9,6,4,8,7,5,2,3,13} and the "Cloth Cap" headgear's ClothingSubPalEffects keys include every one of those values directly. Chargen preview renderer (ChargenPreviewRenderer, ChargenPreviewCamera/ChargenPreviewViewportCamera, ChargenPreviewEntityBuilder, all new files under src/AcDream.App/Rendering/): follows PrivateEntityViewportRenderer's exact architecture (offscreen target → texture table → UiViewport sprite later), a THIRD facade beside PaperdollViewportRenderer/CreatureAppraisalViewportRenderer — no existing file touched. ChargenPreviewEntityBuilder.TryBuild resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as DatLiveEntityProjectionMaterializer (surface-override resolution ported verbatim) and RetailPaperdollPoseApplicator (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (m_didAnimationRest: enum 0x10000005 for every standard heritage — the SAME id the paperdoll's own pose reads — 0x10000011 for Olthoi, 0x10000013 for OlthoiAcid, all resolved through master-map slot 7). Camera (gmCGAppearancePage::Update @ 0x0047E8F0, cross-checked against the identical literals in ZoomIn/ZoomOut @ 0x0047CF00/0x0047D050): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention DollCamera already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (CPhysicsObj::set_heading), not the camera — CC6b's heading parameter belongs on the entity builder. Constants recovered, not just cited (deliverable #4): RotationSecondsPerRevolution = 3.0 (clean in the decomp, no reconstruction needed) and ZoomTweenDurationSeconds = 0.6 — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at DoZoomAnimation's reset-default site (→ 0.6) AND independently at ZoomIn/ZoomOut's -0.1 invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). Register rows filed (same commit): TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-84 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss ClothingBaseEffects coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). Tests (final, post-fix-round counts): ChargenPalSetMathTests (10 cases, the shade-index formula), ChargenAppearanceFactoryTests (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), ChargenAppearanceCatalogInstalledDatTests (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-84 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), ChargenPreviewCameraTests (17 cases, every per-heritage literal + the two recovered constants), ChargenPreviewEntityBuilderTests (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 datLock parameter).

Review fix round (F1-F12, same session): F1 (BLOCKING) — hairStyle.AlternateSetup != 0 / setupId == 0 tested the wrong sentinel; retail's Setup "unset" is INVALID_DID (0xFFFFFFFF — CharGenState::GetSetupID @0x005C5B22), not 0, so an AlternateSetup field storing that value would have been ADOPTED as a literal Setup id, nulling Get<Setup> and killing the whole preview. Fixed at both sites (ChargenAppearanceFactory.cs, new InvalidDid constant); two new hand-built tests plus a new installed-DAT sweep (EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, ChargenClothingTable.cs's doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in ChargenPalSetMath.cs's doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — ChargenPreviewEntityBuilder.TryBuild did unlocked dat reads; DatCollection is not thread-safe and every sibling dat-touching resolver in this layer takes a shared object datLock. Added a required datLock parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one lock, mirroring RetailPaperdollPoseApplicator.Apply's "resolve under lock, process after" shape. F5 (LOW) — Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — ChargenPreviewCamera.cs's rotation doc cited a nonexistent RotationDegreesPerSecond identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (DoRotation @0x0047CAC7: deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses Assert.Skip, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — [0x10000009, 0x100000F9, 0x10000001, 0x10000007], same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (ClothingTable::BuildObjDesc ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — continue changed to break, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling <see cref="ChargenAppearanceFactory.Compose"/> doc-comment references (the method is TryCompose) fixed. F10 (LOW) — the packed (byte)(range.Offset/8)/(byte)(range.NumColors/8) narrowing on dat-sourced data was unchecked (a real NumColors of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit PackOffset/PackNumColors helpers that document the 2048→0 equivalence deliberately and throw ArgumentOutOfRangeException on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second m_alternateSetupID override source (the appearance-page option checkbox — Penumbraen crown @0x004DFB3F, Undead no-flame @0x004E0C54, precedence at @0x004EEA51) is unmodelled; a shared RetailHeldPose helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15): the two cited write sites are gmBarberUI's, not gmCGAppearancePage's — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. Test counts after the fix round (measured, not projected): Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | | CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | 8dfee111 (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real InitializePage evidence) | Idle animation loop, TS-83 RETIRED: decomp re-read of gmCGAppearancePage::Update's own trailing gate (~0x0047EF01-0x0047EF12: if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — gmCGAppearancePage::InitializePage @0x0047FDD0 writes an explicit m_bZoomedIn = 0 at 0x004802C3, right after setting the camera to the zoomed-IN per-heritage eye at 0x00480286-0x0048029E (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose — the rest pose only appears once the user presses Zoom In, which retail's own ZoomIn/ZoomOut (0x0047CF00/0x0047D050) call gmCG3DView::StopAnimation/StartAnimation for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive RetailAnimationCyclePlayback (src/AcDream.Core/Physics/, pure, unit-tested) ports CPhysicsObj::set_sequence_animation @ 0x0050F6F0's effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-AnimationSequencer NPC idle path (LiveEntityAnimationPresenter.Present's legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type ChargenPreviewAnimator (src/AcDream.App/Rendering/) owns the per-tick idle-frame advance / rest-pose freeze swap; ChargenPreviewEntityBuilder gained TryBuildAnimated (returns a ChargenPreviewAnimatedBuild: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL TryBuild (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — ResolveIdleAnimEnum resolves m_didAnimation's enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing ResolveRestPoseEnum (0x10000005/0x10000011/0x10000013) — Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). Rotation controller: new ChargenPreviewRotationController (src/AcDream.App/Rendering/) ports gmCGAppearancePage::Rotate/DoRotation (0x0047CB50/0x0047CA80) verbatim — toggle-to-stop-same-direction, deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the -1.0 sentinel Rotate() writes to invalidate m_dLastRotateTime (bit-confirmed: high dword 0xbff00000 + zero low dword). ECG_ROTATE_CLOCKWISE=1/ECG_ROTATE_COUNTERCLOCKWISE=2 confirmed from acclient.h:6848-6852 — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via MoveToMath.SetHeading (the exact existing CPhysicsObj::set_heading port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. Zoom tween: new ChargenPreviewZoomController ports ZoomIn/ZoomOut/DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) — a LINEAR (not eased — the decomp shows a straight (targ-start)*t+start per axis with no easing curve anywhere in the function) 0.6s tween between ChargenPreviewCamera's already-recorded default/zoomed-out eye profiles, using the same -0.1 invalidation-sentinel idiom as rotation; ZoomIn/ZoomOut call into ChargenPreviewAnimator.SetZoomedIn IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. Fix round F2: the controller and the animator originally kept two INDEPENDENT IsZoomedIn bools synced only through a nullable animator argument on ZoomIn/ZoomOut — a null pass, or a direct ChargenPreviewAnimator.SetZoomedIn call bypassing the controller, could desync the camera target from the animation pose. Retail's m_bZoomedIn is a SINGLE field gating both, so ChargenPreviewZoomController now takes its ChargenPreviewAnimator as a required constructor dependency and IsZoomedIn reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. m_alternateSetupID (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port: re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE m_alternateSetupID write sites — including the two the CC6a review fix round cited, Penumbraen crown @0x004DFB3F and Undead no-flame @0x004E0C54 — belong to gmBarberUI, not gmCGAppearancePage. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): @0x004DFB5B sits inside gmBarberUI::ListenToElementMessage (sibling evidence: gmBarberUI::SetSelection/gmBarberUI::Rotate calls in the same body, which ends in a CM_Character::Event_FinishBarber wire call — a barber-shop-only message); @0x004E0C54 (Penumbraen crown), @0x004E0D42, and @0x004E0DB1 all sit inside the SAME gmBarberUI::InitializePage (sibling evidence: m_pOption1Checkbox reads and UIElement_Text::SetStringInfoWithFont calls on barber-specific string ids in that body); the ONLY thing gmCGAppearancePage itself ever does with the field is READ it generically through the shared gmCG3DView ctor/::Update (every gmCG3DView owner does this) — gmCGAppearancePage's own field list (acclient.h:56373-56428, checked exhaustively) has NO m_pOption1Checkbox-equivalent member and none of its own methods write m_alternateSetupID. gmBarberUI is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's gmCGAppearancePage. For character creation, m_alternateSetupID is therefore ALWAYS INVALID_DID in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all, and is out of this campaign's scope entirely. Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there. ChargenAppearanceFactory.TryCompose still gained a real, decomp-cited alternateSetupIdOverride parameter (default InvalidDid, i.e. no-op for every existing caller) implementing gmCG3DView::Update's own generic precedence exactly (~0x004EEA46-0x004EEA53: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the INVALID_DID sentinel discipline. RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction: new src/AcDream.App/Rendering/RetailHeldPose.cs shares ResolvePoseDid (master-map-slot-7 DID lookup) and ComposePartTransform (Scale*Rotate*Translate) between RetailPaperdollPoseApplicator.Apply (paperdoll, refactored to call the shared helper, behavior byte-identical) and ChargenPreviewEntityBuilder (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered WorldEntity.MeshRefs; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. Bookkeeping: TS-83 retired in docs/architecture/retail-divergence-register.md (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (55bfd9ca, 1774d8b2) instead of "HEAD of campaign-cc6a". Tests: RetailAnimationCyclePlaybackTests (10, Core), ChargenAppearanceFactoryTests (+4, the override precedence/sentinel), ChargenPreviewRotationControllerTests (10, +1 this fix round — F7's clockwise-past-360 clamp case), ChargenPreviewZoomControllerTests (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), ChargenPreviewAnimatorTests (7, hand-built fixtures — no dat needed since a ChargenPreviewAnimatedBuild is constructible entirely in memory), ChargenPreviewEntityBuilderTests (+5, installed-DAT-gated — TryBuildAnimated resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge (randomized-loss-injection timing, zero files under src/AcDream.Core.Net/ touched) and AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce (a concurrency race under full-solution parallel load, zero files under src/AcDream.Content/ touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. OWED (CC6b page-mount half, separate follow-up): the Appearance/Summary viewport mount (0x100003bb/0x10000406), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to ChargenPreviewZoomController.ZoomIn/ZoomOut (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and ChargenPreviewRotationController.Toggle/Tick, spin controls, color wheels, and the INITIAL HEADING: gmCGAppearancePage::InitializePage @0x0047FDD0 sets m_fCurHeading = 180f at 0x00480235 and pushes it via SetPlayerHeading at 0x0048023F (overriding the ctors 0°; cross-confirmed at gmBarberUI::PostInit @0x004DE330 and the summary pages 0x0047BD54) — the mount half must seed ChargenPreviewRotationController.HeadingDegrees = 180f or the character faces AWAY from the camera at the user gate. Explicitly NOT owed: an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; gmCGAppearancePage never had one, so CC6b-mount must not invent one. | | CC7 | CODE-COMPLETE 2026-08-16 | 9cf6c522 | OWED (dual-lens review not yet run) | Create button un-ghosts (CharacterManagementUiController.cs): retail's exact enable/ghost gate — gmCharacterManagementUI::UpdateButtons @ 0x004ec240 (~0x004ec319-0x004ec32e, _charSet.set_.m_num < _charSet.numAllowedCharacters_, unconditional on selection, unlike Enter/Delete/Restore above it) — is now a real Runtime-owned field, RuntimeCharacterSelectionButtons.CanCreate, computed in RuntimeCharacterSelectionState.BuildButtons from _entries.Length < _slotCount and threaded through every one of that method's return branches (including the delete-in-flight .None-shaped ones, which retail's own gate does not couple to). The button's OnClick (new RequestCreate private method) is wired ONCE in the constructor and calls _bindings.RequestCreate?.Invoke(); a new optional Action? RequestCreate field on CharacterSelectionRuntimeBindings carries the seam. Cross-controller wiring lives inside RetailUiRuntime.ConfigureCharacterManagement (src/AcDream.App/UI/RetailUiRuntime.cs) rather than in the externally-composed bindings record: RetailUiRuntime is the one object holding BOTH CharacterManagementController and CharacterCreationController, so it supplies bindings with { RequestCreate = () => CharacterCreationController?.Open() } — a lazily-resolved lambda closing over this, safe even though ConfigureCharacterCreation() (which populates the creation controller) runs immediately AFTER, not before, ConfigureCharacterManagement() in RetailUiRuntime's own mount sequence. CharacterCreationUiController.Open() is the SAME entry point the CC4-era ACDREAM_OPEN_CHARGEN=1 dev seam already called — one code path, two ways to reach it (the seam itself is untouched and remains available for a create-only dev loop). The chargen-exit return path needed no new code: character-management is never hidden while chargen is open on top of it (both controllers tick independently, per CC4's own FixedCanvas-arbiter work), so chargen's Close() — hiding only its own root — is sufficient; this was PROVEN, not just claimed, by a new cross-controller test (CharacterScreensFixedCanvasArbiterTests.CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement) that reorders its shared fixture (chargen constructs first, so its Open method exists to wire into management's bindings — the same ordering constraint production code has) and drives the full click→open→exit-confirm→close round trip, asserting management's root stays Visible throughout. A second new test (CharacterManagementUiControllerTests.CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt) proves the retail gate itself: a 5-character roster against the fixture's SlotCount=5 ghosts Create, dropping to 4 characters un-ghosts it on the next Tick. Full-flow tests vs ACE shapes (tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs, extending CC3's existing harness rather than duplicating it — same TestTransport/TestOperations/TestHost/BuildResponsePacket/InvokeProcessDatagram fixtures, zero new helper classes beyond a decode record): Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape builds a character touching EVERY 0xF656 field (heritage/gender/all fourteen appearance style-color slots/all six shades/template/an EXPLICIT TrainSkill beyond what the template alone applies/an explicit SelectStartArea/name), decodes the full body via a new DecodeCreateRequestFull (reusing CharacterCreate.Request/Appearance/Attributes directly rather than a second hand-rolled shape) and asserts every field including the trailing checksum — recomputed via the production CharacterCreate.ComputeChecksum, not re-derived by hand a second time in the test, closing the one field (Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName's pre-existing test never touched: ~15 fields plus the checksum were previously unverified). Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect ([Theory], 6 cases: Pending/NameBanned/Corrupt/DatabaseDown/AdminPrivilegeDenied/Undef — NameInUse excluded, already covered by the pre-existing dedicated Fact) proves CC5's F2 fix (Pending/Undef produce a real rejection, not a silent reset) holds over the REAL wire byte-decode path, not just the isolated RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_... state-machine Theory that already covered all 7 codes at the ApplyCreationResponse level directly. Launcher payload cycle (item 3): TestHost gained an optional SessionStatusWriter? Writer + SessionId, forwarded from ApplyCharacterCreated/ApplyCreationFailed EXACTLY the way LiveSessionRuntimeFactory.Create (App) and HeadlessSessionHost wire it in production (verified by reading both call sites, not assumed) — two new tests (Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer, its NameInUse sibling) drive a REAL Runtime create/reject through a REAL SessionStatusWriter writing to a real temp file, then read it back with the REAL Launcher.Core StatusFileTailer/StatusEventParser (added as a test-only AcDream.Runtime.Tests project reference — AcDream.Runtime itself gained no new dependency), asserting the parsed CharacterCreatedStatusEvent/CreationFailedStatusEvent match §LA1's pinned contract fields exactly. No gap was found: GameWindow's constructor already builds a real, non-disabled SessionStatusWriter(options.StatusFilePath) and SessionPlayerComposition.cs already threads it into LiveSessionRuntimeFactory's constructor alongside the session id — the writer was ALREADY correctly wired on the graphical App host's real create path before this slice; CC7's tests close the missing cross-project VERIFICATION (Runtime's own state transition through the writer's bytes to the tailer's parser), not a functional hole. Pre-existing test breakage found and fixed (loudly, per the task's own instruction): adding CanCreate to the RuntimeCharacterSelectionButtons record broke 4 UNRELATED tests in LiveSessionControllerTests.cs (RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck ×2, RestoreTimeoutDuringConfirmedDelete_PreservesDeleteUntilAck ×2) whose hand-built expected values used RuntimeCharacterSelectionButtons.None — a real regression the App-layer and Runtime.Tests standalone runs would not have caught in isolation (each project's own suite is green independently; only the combined change surfaced it). Fixed by threading with { CanCreate = true } into all 5 affected Assert.Equal expectations (that fixture's roster of 2 sits below its SlotCount of 11 throughout), with an inline comment explaining CanCreate's independence from the delete-in-flight buttons those tests actually pin. Register bookkeeping this commit: AP-211 (filed at CC3, explicitly predicted "if CC4 later adds the ghosted Create button... revisit whether to keep both or retire this one") updated, not retired — both TryBeginFinish's RosterFull local refusal AND the new Create-button gate are intentionally kept as retail-matching enforcement (the button) plus defense-in-depth (Finish's own refusal, for any caller that bypasses the UI). Connected checklist doc (docs/research/2026-08-16-campaign-cc-test-script.md, following the FA/OP pattern): §CC1 reaching the screen (both the launcher's GUI — character select flow and the ACDREAM_RETAIL_UI=1/ACDREAM_OPEN_CHARGEN=1 dev shortcut) plus Create's enable state and the Exit/Back return path; §CC2 the six-page flow per page (the AP-214-retired opening roll + its gender-flip quirk, Random on each page, the nine known Appearance-page cosmetic gaps called out by number so they aren't mis-filed as new bugs); §CC3 every Finish outcome (happy path, NameInUse + the AD-100 double-send log note, the credit-warning confirm flow, the randomize-warning flow, the exit-warning flow, NameTooLong); §CC4 the two ACE-side landmines (the Arcane Lore over-deduction, MEASURED latent per the plan's risk item 8; disabled-Olthoi → Pending → NameDBDown, retail-correct); §CC-Not-Automated stating plainly that no automated create has touched a live ACE server — this gate is the first one. Test deltas (Release): Runtime 1735/0 (was 1726/0, +9: the full-field decode test, the 6-case rejection-code Theory, 2 launcher-payload tests), App 5256/3 skips (was 5254/3, +2: the Create-ghosting test, the cross-controller round-trip test), Headless 166/0 (unchanged), Launcher.Core 324/0, Launcher.Tests 67/0 (one earlier standalone run hit a Fail:1 Avalonia headless-platform-initialization failure that reproduced on no other run including a full-solution pass — a pre-existing environment flake, zero files under src/AcDream.Launcher/tests/AcDream.Launcher.Tests touched this slice), full solution 14,426 passed / 4 skipped / 0 failed in one complete pass across every project (Core.Net's NakEmission flake and Content's DecodedTextureCache flake did not reproduce this run either). | | CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice); REVIEW-CLOSED 2026-08-15 (dual-lens re-review of the F1-F13 fix round returned NOT CLOSED with residuals R1-R3 + 2 nits, all fixed this round, re-reviewer pre-authorized a diff-check-only close) | 34c6fceab0bc300ab638339b88c5e5f98ae4d724, d2a71152, (this commit — the R1-R3+nits closeout) | CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F13 fix round d2a71152 → narrow re-review: F1-F13 verified against the decomp, residuals R1-R3 + 2 nits → this commit; re-reviewer pre-authorized diff-check-only close) | Appearance page (CharacterCreationAppearancePage, src/AcDream.App/UI/Layout/, wired into CharacterCreationUiController beside the four sibling pages): gender buttons (0x100003a7/a8 -> SelectGender(2)/SelectGender(1), decomp ListenToElementMessage cases 0x9d/0x9e); Face/Clothes sub-tabs (0x100003a9/aa, cases 0x9f/0xa0) toggling the 0x100003ae/b4 choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin 0x100003af-b3, headgear/shirt/trousers/footwear 0x100003b5-b8) reproducing retail's two-arrow-plus-body-click composite through UiButton.OnClickAt's local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases 0xa5-0xa9 and their headgear/shirt/trousers/footwear mirrors) — since DatWidgetFactory consumes each spin's two locally-reused arrow children (0x1000030a/0x1000030b) into ONE flat UiButton with no separate addressable arrow widget; nine color swatches (0x1000030f-0x10000317 -> SetColor(0..8), gated on the current part's own color-list length exactly like retail's iNumColors > N check); the shade scrollbar (0x10000321) bound via ScalarChanged; zoom/rotate buttons delegating to a late-bound IChargenPreviewControl seam. Per-part routing table (StyleSlotFor/ColorSlotFor/ShadeSlotFor), decomp-derived from SetColor @0x0047DD50 and SetShade @0x0047C860: Hair has its own color AND shade; Eyes has color but NO shade (retail's SetShade switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. Wrap semantics (CharacterCreationAppearancePage.CycleIndex, internal static, unit-tested via 10 [Theory] cases): plain [0,count) modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived (count+1)-position RING including the Unset ("no headgear") position — CharGenState::SetHeadgearStyle's literal signed-int32 comparison shape (0x0047F4B5-0x0047F530 decrement, 0x0047F7D8 increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of count+1 positions, not a plain wrap. Review fix round F1 correction (2026-08-15): every OTHER style spin ALSO has a decomp-observable Unset-cycling case, in the SAME switch the headgear ring was ported from — the shared decrement tail (label_47f065/label_47f6d9, reached from Hair's own decrement case @0x0047f465-0x0047f486 and inlined per-part for Eyes/Nose/Mouth/Shirt/Trousers/Footwear) computes new = cur - 1 on the raw signed int32 (Unset = -1), giving new = -2, which wraps to count - 1 — the SAME "wrap to the last index" shape headgear's own ring uses. Incrementing from Unset (new = -1 + 1 = 0) was already correct in acdream. The original claim here ("no decomp-observable Unset-cycling case... starts at style 0 for BOTH directions") is WRONG for decrement; fixed in CharacterCreationAppearancePage.CycleIndex and its own corrected doc comment. Heritage 6/0xc/0xd gate (gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (_eyesArrowsDisabled, since Olthoi/Gearknight forms have fixed eyes); review fix round F3 correction (2026-08-15): forces SetChoice(FACE)/SetSelection(HAIR) UNCONDITIONALLY whenever the gate engages (@0x0047eac6/0x0047eacf Gearknight, @0x0047ee32/0x0047ee3b Olthoi/OlthoiAcid) — NOT only when Clothes happened to be showing, the original (wrong) framing here. A conditional gate left Nose/Mouth as the current part when the Face tab was already active, stranding the shade control on a now-hidden part; retail always snaps back to Hair. Preview wiring (ChargenPreviewController, src/AcDream.App/Rendering/, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — ChargenPreviewRenderer only ever built its OWN private ChargenPreviewCamera with no injection seam, but ChargenPreviewZoomController needs a SETTABLE camera to tween. Fixed at the root: ChargenPreviewViewportCamera gained a ChargenPreviewCamera-accepting constructor overload, ChargenPreviewRenderer gained an optional camera parameter using it, and ChargenPreviewController owns the ONE shared ChargenPreviewCamera instance handed to both. ChargenPreviewController consolidates the per-frame IPrivateEntityViewportFrame owner role (mirrors PaperdollFramePresenter, self-timing via Stopwatch rather than touching the shared frame-phase interface) with the IChargenPreviewControl seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by LivePresentationComposition, exactly mirroring the paperdoll's own late viewport.Renderer = ... assignment). Rebuild recomposes via ChargenAppearanceFactory.TryCompose + ChargenPreviewEntityBuilder.TryBuildAnimated on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: gmCGAppearancePage::Update's only two confirmed direct call sites are InitializePage and the two gender-button handlers; spin/color/shade changes call the narrower SetSelection/SetColor/SetShade, none of which touch m_vectCurPosition) — a fresh ChargenPreviewAnimator is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via SetZoomedIn, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's m_bZoomedIn/m_fCurHeading both living on the PAGE and surviving Update. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: RetailUiRuntime gained ChargenPreviewViewportWidget/ChargenPreviewControl/IsChargenPreviewPageVisible (computed through CharacterCreationUiController's new AppearanceViewport/AppearancePreviewControl/IsAppearancePageVisible, the last one gating on BOTH the page root's own Visible AND the whole screen's Root.Visible since Close() only ever hides the latter); LivePresentationComposition constructs the renderer+catalog+controller and wires viewport.Renderer/page.PreviewControl through the same lease/AdoptRelease pattern paperdoll uses; FrameRootComposition's PrivateEntityViewportFrameGroup gained the controller as its third member; GameWindow/GameWindowLifetime gained the matching guard fields and RenderShutdownRoots disposal entries. Testability seam: IChargenPreviewRenderer/IChargenPreviewFrameView (mirroring IPaperdollDollRenderer/IPaperdollFrameView) let ChargenPreviewControllerTests (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL ChargenAppearanceFactory/ChargenPreviewEntityBuilder composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's Rotation after Render(), and the invisible-page render skip. Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport/AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins): NO new DatWidgetFactory widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> UiButton; their nine Type-3 companion "selected"-ring overlays (0x10000318-0x10000320) and the GradCircle (0x1000030e) author Type 3 -> the generic UiDatElement fallback; the shade scrollbar (0x10000321) authors Type 0xB -> UiScrollbar, matching the decomp's own DynamicCast(0xb). The nine spin containers and their two locally-reused arrow children all author Type 1 -> UiButton. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own .Selected highlight instead of toggling the separate companion overlay (retail's SetColor's m_tColorWheel[...]->SetVisible mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's ChargenHairStyle/ChargenEyeStrip/ChargenFaceStrip carry only an IconId, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real ChargenGearOption.Name. The @140355 gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed): gmCGAppearancePage::InitializePage's own gender-read-then-FLIP-to-the-opposite code (~0x004802DA-0x00480303) is real and ALWAYS fires, because gmCharGenMainUI's own constructor (~0x004e81f5-0x004e8218, BEFORE any page constructs) calls CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80 — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (RandomizeHeritageGroup/RandomizeAppearance/RandomizeClothing/RandomizeTemplate/RandomizeStartArea are the SAME six primitives RandomizeCharacter calls) — acdream's chargen screen opens honestly blank instead, by design, this round. AD-101 RETIRED (register §2, 79->78 active rows): CharacterCreationHeritagePage.Select no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated CharacterCreationUiControllerTests's shared fixture (FakeRuntime/BuildOptions) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real BuildAppearancePage() layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME OnClickAt zone math production code uses; the one pre-existing gender-side-effect assertion (HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender) is renamed/corrected to assert NO gender side effect. TS-82 NARROWED (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. Register bookkeeping this commit: AD-101 retired (row deleted, count 79->78); AP-214 filed (the RandomizeCharacter-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). Scope-addendum work (folded into this same commit, not a separate round): ChargenPreviewRotationController.HeadingDegrees's doc comment corrected to name BOTH the ctor's 0f (gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC) and InitializePage's override to 180f (@0x0047FDD0, write at 0x00480235, pushed via SetPlayerHeading at 0x0048023F) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from 0f to a new RetailDefaultHeadingDegrees = 180f constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real gmCG3DView owner (Appearance, Summary @0x0047BD54 — confirmed a SEPARATE gmCG3DView instance/page, CC5's own scope, not touched here — and gmBarberUI) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass 0f explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside ChargenPreviewControllerTests that Render() actually applies that heading to the built entity's Rotation. Tests: CharacterCreationLiveDatTests (+2 permanent structural/geometry tests replacing the temporary scouting probe), CharacterCreationUiControllerTests (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case CycleIndex wrap-semantics theory, the renamed AD-101 test), ChargenPreviewControllerTests (+6, new file, installed-DAT-gated), ChargenPreviewRotationControllerTests (+1, the 180°-default pin). Counts (Release, full solution, ACDREAM_PROBE_LIVE_MOUNT=1 + ACDREAM_DAT_DIR set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — SetAppearanceIndex/SetShade command plumbing already existed in IRuntimeCharacterCreationCommands/GameRuntimeCommands.cs from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under src/AcDream.Core.Net/ touched this round either). OWED for CC5+ / future: the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins (AP-215's own icon-label half) and the GradCircle's own retail-driven repaint (review fix round correction 2026-08-15: AP-215 does NOT name the GradCircle — that was this ledger row's own false claim; the GradCircle gap is filed separately as AP-217, REWRITTEN 2026-08-15 at the re-review of d2a71152 (R3) after re-deriving from the decomp: gmCGAppearancePage::ListenToElementMessage's own dispatch switch has NO case for the GradCircle's offset at all, so it is not a click target in retail either — DoGradDisk is a PAINT-only routine that blits the gradient art tinted with the current part's color (or blanks it for Eyes) whenever SetColor/SetSelection run; acdream's gap is that it never repaints the GradCircle at all, a cosmetic paint gap rather than a dead click target, and the nine swatch buttons already provide the full, decomp-cited color-selection INPUT path); a real RandomizeCharacter port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it; the current-part spin highlight itself, newly measured DEAD for all nine spins (AP-222, filed at the re-review of d2a71152, N2) — none of the nine spins author Highlight-state media, so RefreshColorAndShadeControls's TrySetRetailState(Highlight) call silently never changes what's drawn; unresolved whether retail's own spin art has the same gap or uses a different mechanism entirely, needs a decomp read of the real per-frame spin-face renderer before deciding a fix. |