F1 (BLOCKING, doc-only) — the idle-by-default rationale rested on an unsound "uninitialized C++ member defaults to 0" argument (heap operator-new memory is indeterminate, not zero). Verified and replaced with the real evidence: gmCGAppearancePage::InitializePage @0x0047FDD0 writes an EXPLICIT this->m_bZoomedIn = 0; at 0x004802C3, immediately after that same function points the camera at the zoomed-IN per-heritage eye (0x00480286-0x0048029E). Fixed in all three places: the register's TS-83 retirement clause, ChargenPreviewAnimator's class doc, ChargenPreviewZoomController.IsZoomedIn's doc. Recorded the retail quirk this implies: the character starts framed close-up while not-zoomed-in, so the first Zoom In click (once mounted) tweens close-eye->close-eye (visually null) while still freezing the animation — the port reproduces this faithfully. F2 — ChargenPreviewZoomController and ChargenPreviewAnimator kept independent _zoomedIn bools synced only via a nullable animator parameter, risking desync. Retail's m_bZoomedIn is a single field gating both camera and animation, so the fix makes the animator the sole state owner: ChargenPreviewZoomController now takes its ChargenPreviewAnimator as a required constructor dependency, IsZoomedIn reads straight through to it, and ZoomIn/ZoomOut no longer take a parameter at all — there is no second bool left to disagree. F3 — documented the DoRotation counter-clockwise branch's x87-stack decompiler artifact (BN renders x87_r7_1 = x87_r6_3 at 0x0047CAEB, which would store delta-degrees instead of the timestamp for CCW only); the port already stores "now" in both branches, cited against feedback_bn_decomp_field_names.md. F4 — ChargenPreviewAnimator.ApplyIdleFrame now double-buffers two List<MeshRef> instead of allocating fresh every 30fps tick. F5 — filed docs/ISSUES.md #402 tracking the RetailAnimationCyclePlayback / LiveEntityAnimationPresenter duplication as an owned post-CC follow-up, referenced from the new type's own doc. F6 — reworded the ChargenPreviewEntityBuilder.TryBuild "byte-identical" claim to result-identical (TryBuildAnimated now also resolves the idle DID and loads the idle Animation before the wrapper discards them). F7 — added the missing clockwise >360 clamp test (readable decomp polarity, unlike F3's CCW artifact). ALSO — rewrote the CC6b ledger row's m_alternateSetupID MUST-COVER note per the reviewer's F11 concession: all five write sites belong to gmBarberUI (the post-creation barber shop), not gmCGAppearancePage, which has no option-checkbox-equivalent field at all. Added the enclosing-function citations and an explicit directive that CC6b-mount must NOT build a crown/no-flame checkbox on the Appearance page. Tests: ChargenPreviewRotationControllerTests +1 (10 total), ChargenPreviewZoomControllerTests +2 and every case rewritten for the required-animator constructor (9 total). Core.Tests 4786/1 skip (unchanged), Content.Tests 147/0, App.Tests 5152/6 skips (+3) — zero failures in isolation, full solution Release build green. Two pre-existing flakes observed across repeated full-solution runs, neither caused by this round and neither reproducing standalone: Core.Net.Tests' NakEmissionTests loss soak, and Content.Tests' DecodedTextureCacheTests concurrency race. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
54 KiB
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@138974hard-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.DatWidgetFactoryalready maps dat type 0xD →UiViewport. The char-management controller REFUSES viewports by local policy (:212) — chargen gets its OWN controller; cloneCharacterManagementUiMountCoordinator+ the bindings-record pattern. - Fixed canvas: chargen is the same 800×600 flow screen — mount at authored
extent,
UiRoot.FixedCanvasSizeon activate (AD-98), dialogs center onEffectiveCanvasSize. Live-DAT probe tests sweep ALL media ids (CharacterManagementLiveDatTestspattern) and pin authored justify/anchors. - Preview pipeline:
PrivateEntityViewportRenderer(offscreen target → texture table →UiViewportsprite) is proven by paperdoll + appraisal; cameras there are FIXED — chargen needs a heading-capable camera. NOTE:GlGpuDevice.RegisterExternalColorTextureis a DELETED API that survives only in stale doc comments — do not cite it. Appearance building:DollEntityBuilder.Buildis 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
RuntimeCharacterSelectionStateexactly (lifecycle/ snapshot/delta records, borrow-only view, generation-gated commands, one mutable owner, no App types). Command family lands besideIGameRuntimeCommands.CharacterSelection. Enter-after-create hooks the existingLiveSessionController.BeginEnter/CompleteEnter. - Wire plumbing:
WorldSession's dispatch chain routes EVERY 0xF643 throughCharacterRestore.Parsetoday with no request correlation — the KNOWN LANDMINE. Creation requires an awaiting-request latch (create vs restore) BEFORE its response arm lands. Outbound mirrorsSendRestoreCharacter@2223. Status writer: addcharacterCreated/creationFailedevents (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)
- 0xF643 create/restore correlation (CC2's first job; the restore doc comment already warns).
- 55-slot skill array: ACE terminates the session on mismatch — CC2/CC3 must make it structurally impossible to send anything else.
- Slot cap is client-enforced only (ACE never checks on create) — honor
slotCountlike retail's UI did. - Color-wheel/gradient widgets (
tagColorWheel, GradCircle0x1000030e, shade scroll) may need new widget types inDatWidgetFactory— CC6 scouts the authored layout first. - 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_CGenums are empty in the header, ZoomIn tween duration constant is decompiler-garbled (measure against retail if it matters). - 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.
references/*absent in worktrees (except WorldBuilder, uninitialized submodule) — agents read ACE/holtburger from the MAIN checkout path.- CC7 landmine (found in the CC1 review fix round, 2026-08-15): ACE's
PlayerFactory.CreatePlayerheritage-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 viaSkillBase.UpgradeCostFromTrainedToSpecialized(=SpecializedCost - TrainedCost) and chargesTrainSkill(trainedCost) + SpecializeSkill(incrementalCost)= the field's TOTAL, matching retail. But when the heritage's own list has an entry, ACE setsspecializedCost = skillGroup.PrimaryCostdirectly —PrimaryCostis already the TOTAL cost to reach Specialized (acdream's ownChargenSkillCost.PrimaryCostconvention, confirmed against retail) — and then still chargesTrainSkill(NormalCost) + SpecializeSkill(PrimaryCost), over-deducting by an extraNormalCostcredits 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 — seeChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet) may be REJECTED by local ACE withFailedToSpecializeSkilleven 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 | — | |||
| 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). 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 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 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. 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 | — | | | |