acdream/docs/plans/2026-08-15-character-creation-campaign.md
Erik 8dfee1118f feat(chargen): Campaign CC slice CC6b-PRE — idle loop, rotation, zoom (mount-independent half)
Idle animation loop: decomp re-read of gmCGAppearancePage::Update's trailing
StartAnimation/StopAnimation gate (~0x0047EF01-0x0047EF12) plus the ctor
evidence that m_bZoomedIn is a decompiler-elided bool (never explicitly set
away from its zero default, unlike its two sibling bools) establishes that
retail's chargen preview defaults to the idle loop PLAYING, not the frozen
rest pose CC6a shipped as a deliberate simplification (TS-83) — the rest pose
only appears once Zoom In fires. New Core primitive
RetailAnimationCyclePlayback ports CPhysicsObj::set_sequence_animation's
advance-with-wrap + lerp/slerp effect (the same algorithm
LiveEntityAnimationPresenter's legacy NPC-idle branch already carries inline;
not consolidated this round — out of blast radius for a preview-only
feature, noted in the new type's own doc). New ChargenPreviewAnimator drives
the per-tick swap; ChargenPreviewEntityBuilder gained TryBuildAnimated
alongside the byte-behavior-unchanged TryBuild. Olthoi/OlthoiAcid use the
SAME enum key for idle and rest DIDs (decomp-confirmed quirk). TS-83 retired
in the register (§4 count 50->49).

Rotation controller: ChargenPreviewRotationController ports
Rotate/DoRotation (0x0047CB50/0x0047CA80) verbatim — toggle-to-stop,
deltaDegrees = ((now-last)/RotationSecondsPerRevolution)*360, single-pass
+-360 clamp (not a full modulo, matching retail's own tail), the -1.0
invalidation sentinel. Applies to the entity's heading via the existing
MoveToMath.SetHeading port, not the camera, confirming CC6a's own note.

Zoom tween: ChargenPreviewZoomController ports ZoomIn/ZoomOut/
DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) — a LINEAR 0.6s tween
(no easing curve in the decomp) between the already-recorded camera eye
profiles, calling into the animator's zoom swap IMMEDIATELY at button-press
time, matching retail's call order exactly.

m_alternateSetupID (research correction): re-reading the decomp
function-by-function found all five m_alternateSetupID write sites —
including the two the CC6a review cited — belong to gmBarberUI (the
post-creation barber shop), not gmCGAppearancePage, which has no
m_pOption1Checkbox-equivalent field and never writes the field. For
character creation the field is always INVALID_DID in retail. TryCompose
still gained a real, decomp-cited alternateSetupIdOverride parameter
(default no-op) implementing gmCG3DView::Update's generic override
precedence, for a future non-chargen consumer.

RetailHeldPose extraction: shared ResolvePoseDid/ComposePartTransform
between RetailPaperdollPoseApplicator and ChargenPreviewEntityBuilder — a
clean mechanical extraction, behavior-identical on the paperdoll side.

Bookkeeping: CC6a ledger row now cites its real commit SHAs (55bfd9ca,
1774d8b2); new CC6b-PRE ledger row records scope done + the page-mount half
still owed.

Tests: RetailAnimationCyclePlaybackTests (10, Core), ChargenAppearanceFactoryTests
(+4), ChargenPreviewRotationControllerTests (9), ChargenPreviewZoomControllerTests
(7), ChargenPreviewAnimatorTests (7, hand-built fixtures), ChargenPreviewEntityBuilderTests
(+5, installed-DAT). Core.Tests 4786/1 skip, Content.Tests 147/0, App.Tests
5149/6 skips — zero failures, full solution Release build green. One
pre-existing, unrelated flake noted: Core.Net.Tests' NakEmissionTests loss
soak failed once in the full-suite run, passed 1/1 isolated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 19:05:56 +02:00

51 KiB
Raw Blame History

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: NameInUse→ID_Character_Err_NameReserved, NameBanned→ID_Character_Err_NameBanned, Corrupt/DatabaseDown→ ID_Character_Err_NameDBDown, AdminPrivilegeDenied→ ID_Character_Err_NameAdminDenied, Pending/Undef→silent state reset (ACE sends Pending for a disabled-Olthoi rejection — retail swallows it; port as-is, register-note the quirk).

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

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

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

acdream seams (build on these, do not reinvent)

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

Slices

Slice Deliverable Depends
CC1 Chargen data layer: CharGen table reader → typed options model (heritages/sexes/appearance lists/templates/skills+costs/budgets/towns), Content/Core, live-DAT probes
CC2 Wire: CharacterCreate 0xF656 builder (byte-exact incl. checksum), shared verification-response type (refactor from CharacterRestore), WorldSession request-correlation for 0xF643, send seam, status events + contract/tailer update
CC3 RuntimeCharacterCreationState: full CharGenState mirror, per-page commands, retail client gates (full-spend, name, 55-slot invariant, client-side slot cap), verification latch, Ok → roster append + retail log-straight-in CC1, CC2
CC4 Screen shell + form pages (App): mount (enum 0x10000039), master nav/tabs/progress, dialogs, Heritage + Profession + Skills + Town pages CC1, CC3
CC5 Summary page: name input (NameInputFilter, ID_CharGen_NameTooLong), summary listbox, static summary viewport, Finish gates + full response/dialog handling CC3, CC4
CC6 Appearance page + preview: index→ObjDesc factory, chargen preview renderer (offscreen, heading camera, rotate/zoom buttons), spin controls + color wheels; staged: CC6a static-pose preview (paperdoll-style held frame, register row for the missing idle loop), CC6b idle animation + zoom rest-freeze (retire the row) CC1, CC4
CC7 End-to-end: Create button un-ghosts, full flow vs ACE shapes in tests, launcher payload cycle, connected checklist doc all

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

Risks / open items (from recon Unknowns)

  1. 0xF643 create/restore correlation (CC2's first job; the restore doc comment already warns).
  2. 55-slot skill array: ACE terminates the session on mismatch — CC2/CC3 must make it structurally impossible to send anything else.
  3. Slot cap is client-enforced only (ACE never checks on create) — honor slotCount like retail's UI did.
  4. Color-wheel/gradient widgets (tagColorWheel, GradCircle 0x1000030e, shade scroll) may need new widget types in DatWidgetFactory — CC6 scouts the authored layout first.
  5. Retail unknowns to resolve during slices, never guess: the chargen please-wait dialog context (decompiler-mislabeled field), the AppearancePage gender-flip-on-init oddity (@140355 — verify live before porting), Method_CG enums are empty in the header, ZoomIn tween duration constant is decompiler-garbled (measure against retail if it matters).
  6. Viewport inside the fixed canvas: the offscreen target's pixel size vs the canvas-scaled on-screen rect (render at scaled size for crispness or authored size for fidelity) — decide in CC6a with the user gate as arbiter.
  7. references/* absent in worktrees (except WorldBuilder, uninitialized submodule) — agents read ACE/holtburger from the MAIN checkout path.
  8. CC7 landmine (found in the CC1 review fix round, 2026-08-15): ACE's PlayerFactory.CreatePlayer heritage-override branch (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) over-deducts skill credits when specializing a skill the active heritage's own list prices. For a skill priced ONLY by the global SkillTable, ACE correctly computes the incremental specialize cost via SkillBase.UpgradeCostFromTrainedToSpecialized (= SpecializedCost - TrainedCost) and charges TrainSkill(trainedCost) + SpecializeSkill(incrementalCost) = the field's TOTAL, matching retail. But when the heritage's own list has an entry, ACE sets specializedCost = skillGroup.PrimaryCost directly — PrimaryCost is already the TOTAL cost to reach Specialized (acdream's own ChargenSkillCost.PrimaryCost convention, confirmed against retail) — and then still charges TrainSkill(NormalCost) + SpecializeSkill(PrimaryCost), over-deducting by an extra NormalCost credits versus what retail's client computed and what the player agreed to spend. Practical impact for CC7's connected gate: a retail-legal character build that specializes a skill the ACTIVE HERITAGE prices (every one of the 13 installed heritages has exactly one such skill — see ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet) may be REJECTED by local ACE with FailedToSpecializeSkill even though acdream sent the byte-correct 0xF656 body. If CC7's gate hits this, it is an ACE-side bug reproduced from its own source, NOT an acdream wire or math defect — do not "fix" acdream's cost math to match ACE's over-deduction. MEASURED 2026-08-15 (user-prompted — downgrades this landmine to LATENT): dumping the installed EoR DAT shows every one of the 13 heritages' single override is skill 14 (Arcane Lore) at NormalCost=0 / PrimaryCost=2, versus global TrainedCost=4 / SpecializedCost=6. ACE's over-deduction equals NormalCost — which is ZERO for the only heritage-priced skill — so ACE charges 0+2=2 and retail's client computes 2: they AGREE, and no character build can trigger the rejection with end-of-retail data. The formula bug in ACE's heritage-override branch is real but unfireable here; it only matters if a custom server ships a DAT whose heritage override has a nonzero NormalCost. The earlier "may be REJECTED" inference was made from code without measuring the data — the C4 closeout's observe-don't-infer lesson, again. Register: file an AD row if CC7 needs a documented workaround (e.g. picking a Specialized skill combination that avoids the heritage-priced skill for the connected gate) rather than silently adjusting acdream's send.

Review protocol

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

Ledger

Slice Status Commits Review Notes
CC1 REVIEW-CLOSED 2026-08-15 04450041, cb4703e8 CLOSED (fix round + narrow re-review; every citation independently re-derived) Core model (no Chorizite leak) + Content projector; 31 math units + 6 installed-DAT gates (13 heritages). FINDING for CC3: each human heritage's "Adventurer" template IS retail's Custom entry point — attributes at the 10-floor (60/330), a real TemplateCG row, not a UI special case. Review fix round (cb4703e8): F1 doc corrected — Custom IS template index 0 (the Adventurer row), per gmCGProfessionPage::UpdateProfession @ 0x004821b0 (case 0 → button 0x100003d9 / ID_CharGen_CustomText) and CharGenState::SetTemplate @ 0x005C5A60 (commits via CharGenState::ApplyTemplate @ 0x005C5080, i.e. selecting Custom resets sliders to the floor spread, it does not bypass templates); F2 two-tier skill-cost fallback implemented (ChargenOptions.GlobalSkillCostsBySkillId from portal.dat 0x0E000004, ChargenSkillCreditMath checks heritage list then global list) + installed-DAT completeness assertion recording reality: the global SkillTable prices 38/54 advancement skill ids, every one of the 13 heritages ships EXACTLY one heritage-specific override (always also present in the global table), and 16 skill ids are genuinely uncostable in both tiers (retail's -1 case) — see ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet; F3 every ChargenTableReader collection is now frozen at projection (ToFrozenDictionary/ToArray, matching MagicCatalog's pattern) including both ChargenOptions.Empty dictionaries; F4 a reflection guard test (ChargenNoChoriziteLeakTests) pins the no-Chorizite-leak contract by walking every public AcDream.Core.CharGen member; F5 HasAnyAppearanceOptions's doc reworded to state precisely what it proves (an OR across eight lists, omitting the three color lists) + a new installed-DAT gate records per-list reality — found COMPLETE, every gender of every heritage has non-empty lists across all eight plus the three color lists, even the sparse Gear Knight/Olthoi variants; F6 TryGetHeritage/TryGetStarterArea annotated [MaybeNullWhen(false)] (matching the house EmptyDatReaderWriter pattern), all affected call sites (more than the originally estimated five) fixed across both test projects. Filed CC7 risk item 8: ACE's PlayerFactory heritage-override branch over-deducts skill credits when specializing a heritage-priced skill (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) — a retail-legal build may be rejected by local ACE at the CC7 connected gate; this is an ACE bug, not an acdream defect. Narrow re-review CLOSED: the reviewer retro-graded F2 to HIGH (under the base commit 37 of 38 costable skills were charged zero) and confirmed the SkillBase.SpecializedCost->PrimaryCost mapping dodged the UpgradeCostFromTrainedToSpecialized trap. Residuals: R1 retail refunds +1 credit on a both-tier miss (port charges 0; unreachable via retails own skills listbox — NOTE FOR CC3 if any path ever exposes the 16 uncostable ids); R2 list downcast-mutability and R3 field-walking in the leak guard CLOSED at the merge-closeout commit (Array.AsReadOnly at every projection seam; GetFields walk added). Decomp fact for CC4: ApplyTemplate force-sets template_=0 for heritage 0xc/0xd — both Olthoi variants are hard-locked to Custom/template 0.
CC2 REVIEW-CLOSED, MERGED 2026-08-15 (55fc51ed) 5eaad2c8, e77ebf10, 95e95bb6 PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL
CC3 REVIEW-CLOSED 2026-08-15 9a84230c, 397ccd62, + the R1 closeout commit CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round 397ccd62 → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: LiveSessionController._createsSinceCharacterList (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) RuntimeCharacterCreationState (new, src/AcDream.Runtime/Session/): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring RuntimeCharacterSelectionState's exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated Try* internals). Ports SetHeritageGroup, SetGender, SetTemplate/ApplyTemplate (Custom = template 0, Olthoi force-lock), the six attribute setters + GetAbsRemainingCredits + BalanceAttributes (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), SetSkillLevel + ResetSkillLevels' three-way free-skill baseline (both two-tier cost lookups reuse CC1's ChargenSkillCreditMath/ChargenSkillCost verbatim — no duplicated math), RandomizeStartArea, and DoFinish's complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). LiveSessionController gained a sibling IRuntimeCharacterCreationCommands implementation (command family lands beside IRuntimeCharacterSelectionCommands, IGameRuntimeCommands.CharacterCreation added with the same default-throw shape as CharacterSelection), a CharacterCreationState property, ILiveSessionOperations.CreateCharacter (default method → WorldSession.SendCharacterCreation), and a HandleCharacterCreationResponse wire handler subscribed to WorldSession.CharacterCreateResponseReceived alongside the existing character-selection bindings. ILiveSessionLifecycleHost gained ApplyCharacterCreated/ApplyCreationFailed as DEFAULT interface methods (no-op) so AcDream.App's existing host implementations keep compiling unchanged — wiring them to SessionStatusWriter.CharacterCreated/CreationFailed is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet). Review fix round (this commit): F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — WorldSession gained a guid-based EnterWorld(uint characterGuid, string accountName, TimeSpan?) overload (refactored to share EnterWorldCore with the index-based overload) plus ILiveSessionOperations.EnterWorldByGuid (default method); LiveSessionController factored EnterSelectedCore/the new EnterCreatedCharacterCore through a shared EnterHighlightedCore(sendEnterWorld) — the cached wire CharacterList is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — references/ACE/.../CharacterHandler.cs:170-172), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through ApplyRoster (which re-derives EVERY entry's ActiveIndex — a wire contract ACE indexes for delete, CharacterHandler.cs:297 — from display/name-sort order); RuntimeCharacterSelectionState gained a real AppendCreatedCharacter(characterId, name, wireIndex) primitive that preserves every existing entry's ActiveIndex untouched and assigns the new entry's from the pre-create wire CharacterList.Characters.Count (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — DoFinish(this, arg2)'s real gate is arg2 != 0 && remainingAtrbCredits > 0: the ordinary click (arg2=1) warns-and-refuses, but the warning dialog's own confirm re-invokes DoFinish(this, 0), which skips the check and sends with credits unspent (ACE accepts this). TryBeginFinish/LiveSessionController.Finish/IRuntimeCharacterCreationCommands.Finish gained a confirmedUnspentCredits/confirmUnspentCredits parameter (default false = retail's arg2=1) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to TemplateUnset in ApplyTemplateLocked, mirroring ConstrainAllByHeritage @ 0x005C65CC's template_ >= count → template_ = 0xffffffff clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (SetAttribValue never calls FitTemplateToCharacter) — corrected to the four real call sites, including a fourth the original filing also missed (UpdateToDefaultAttributes @ 0x00482860). F6 (MEDIUM) ApplyCreationResponse's Pending/Undef branch no longer publishes from inside lock(_gate) — every branch now sets kind and a single Publish runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin BalanceAttributes' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) ResetSkillLevels' doc corrected — retail's real gate is BOTH costs >= 0 (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the Slot doc corrected — retail DOES assign it (gmCharacterManagementUI::SelectCharacter @ 0x004EC160SetSlot(GetSlot(...))), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's classID citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs EnterWorld to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side RosterFull slot-cap refusal (acdream-side gate, no retail DoFinish-layer counterpart — same-commit rule). F13 LiveSessionController.Finish's bare catch {} narrowed to InvalidOperationException/SocketException and _scope bound to a local after validation. F15 RandomizeStartAreaLocked now leaves _startArea unchanged on an empty list (matching retail's if (var_9c > 0) guard) instead of forcing -1. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads TemplateOption for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide ClothingColors list — CC1's model has no per-style palette data), AP-209 (classID sent as a placeholder 0 — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (ApplyTemplate's per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the RosterFull client-side slot-cap refusal). Tests: tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + .../Session/LiveSessionControllerCharacterCreationTests.cs (5 cases — wire-send exactly 55 skill slots via a REAL WorldSession + GameMessageCapture, decoded byte-for-byte; the full Ok round trip via WorldSession.ProcessDatagram reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + ApplyCharacterCreated; the NameInUse round trip asserting ApplyCreationFailed + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: RuntimeCharacterCreationState's ChargenOptions currently defaults to ChargenOptions.Empty — threading the installed DAT's loaded options through GameRuntime/App startup is unresolved; the Slot field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); classID's real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks.
CC4
CC5
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-82 (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-82 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), ChargenPreviewCameraTests (17 cases, every per-heritage literal + the two recovered constants), ChargenPreviewEntityBuilderTests (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 datLock parameter).

Review fix round (F1-F12, same session): F1 (BLOCKING) — hairStyle.AlternateSetup != 0 / setupId == 0 tested the wrong sentinel; retail's Setup "unset" is INVALID_DID (0xFFFFFFFF — CharGenState::GetSetupID @0x005C5B22), not 0, so an AlternateSetup field storing that value would have been ADOPTED as a literal Setup id, nulling Get<Setup> and killing the whole preview. Fixed at both sites (ChargenAppearanceFactory.cs, new InvalidDid constant); two new hand-built tests plus a new installed-DAT sweep (EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-82'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-82 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — [0x10000009, 0x100000F9, 0x10000001, 0x10000007], same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (ClothingTable::BuildObjDesc ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — continue changed to break, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling <see cref="ChargenAppearanceFactory.Compose"/> doc-comment references (the method is TryCompose) fixed. F10 (LOW) — the packed (byte)(range.Offset/8)/(byte)(range.NumColors/8) narrowing on dat-sourced data was unchecked (a real NumColors of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit PackOffset/PackNumColors helpers that document the 2048→0 equivalence deliberately and throw ArgumentOutOfRangeException on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second m_alternateSetupID override source (the appearance-page option checkbox — Penumbraen crown @0x004DFB3F, Undead no-flame @0x004E0C54, precedence at @0x004EEA51) is unmodelled; a shared RetailHeldPose helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). 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) | single commit, HEAD of campaign-cc6a | Review outstanding (dual-lens Opus pass not yet run this round) | 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 ctor evidence that m_bZoomedIn is one of three consecutive bool bytes the decompiler shows only two of (m_bShouldZoomAnimate/m_bRotating explicitly zeroed, m_bZoomedIn never explicitly touched — the same decompiler-elision class claude-memory/feedback_bn_decomp_field_names.md warns about) 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 byte-behavior-identical — 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. 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::ListenToElementMessage/::InitializePage (confirmed via the enclosing-function scan: gmBarberUI::SetSelection/::Rotate calls and a CM_Character::Event_FinishBarber wire call sit in the SAME function bodies), the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's gmCGAppearancePage, which has NO m_pOption1Checkbox-equivalent field anywhere in its own field list (acclient.h:56373-56428, checked exhaustively) and never writes m_alternateSetupID in any of its own methods. 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, this campaign's own scope. 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 for a future non-chargen consumer of this same factory, not a fabricated 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 (9), ChargenPreviewZoomControllerTests (7), 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 (+14 from the CC6a baseline of 4772/1), Content.Tests 147/0 skips (unchanged — no Content-layer work this round), App.Tests 5149/6 skips (+28 from 5121/6) — zero failures, full solution Release build green. One PRE-EXISTING flake noted, not caused by this round: AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge failed once in the full-suite run, passed 1/1 in isolation — a randomized-loss-injection timing flake in the unrelated Core.Net transport suite (zero files under src/AcDream.Core.Net/ touched this round). 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 the three new controllers' Tick/Toggle/ZoomIn/ZoomOut methods, spin controls, color wheels. | | CC7 | — | | | |