# Campaign CC — retail character creation **Status:** ACTIVE (started 2026-08-15) **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`) — 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 retail’s own skills listbox — NOTE FOR CC3 if any path ever exposes the 16 uncostable ids); R2 list downcast-mutability and R3 field-walking in the leak guard CLOSED at the merge-closeout commit (Array.AsReadOnly at every projection seam; GetFields walk added). Decomp fact for CC4: ApplyTemplate force-sets template_=0 for heritage 0xc/0xd — both Olthoi variants are hard-locked to Custom/template 0. | | CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL | | CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | | CC4 | 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.InstallSpellMetadata`→`Spellbook.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`/`InstallChargenOptions` — `ChargenTableReader.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 alternative** — `HeadlessSessionHost` 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 `` 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<=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 (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), 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<=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` 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 `` 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 ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `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 | — | | | | | 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. |