CC6a (index->ObjDesc factory + static-pose offscreen renderer) and CC6b-PRE (idle loop, rotation, zoom, alternate-setup plumbing) both closed through dual-lens review -> fix round -> narrow re-review. The branch carries its own cross-branch renumbering (TS-84, ISSUES #403) so this merge is number-clean against the CC4 rows. Notable review outcomes carried in: the barber refutation (chargen has NO alternate-setup checkbox — all five write sites are gmBarberUI), the idle-by-default finding with its corrected InitializePage evidence, and the 180-degree initial heading owed to the mount half. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> # Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
73 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 | REVIEW-CLOSED 2026-08-15 | 0e71d3b8, ec854db0, 8add0667, + the R5 closeout commit |
CLOSED after two fix rounds + final re-review (R1 arbiter CLOSED; R5 — the chargen root extent pinned 800x600 by live-DAT observation in the closeout commit, closing the mismatch-throw crash premise). Original verdict: architectural FAIL (F1, F6) + retail-fidelity PASS-with-reservations (F2, F3, F4) + LOW findings F5/F7-F12 (F13 is a merge-mechanics note for the orchestrator, not an acdream defect). Fix round applied same-session (see the "Review fix round" paragraph at the end of this row); re-review status owed to the orchestrator. | Screen shell + form pages (App layer). Mount: CharacterCreationUiController/CharacterCreationUiMountCoordinator (src/AcDream.App/UI/Layout/) clone CharacterManagementUiController's recipe — enum 0x10000039 via RetailDataIdResolver.Resolve(dats, ..., 5u), root 0x100003CC (decomp-verified: gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0, NOT the plan doc's earlier 0x100003cc-adjacent guesses — confirmed live against the installed DAT, [CC4-DAT] enum=0x10000039 -> DID=0x21000038), fixed-canvas AD-98 treatment shared with char-management. CORRECTED at the review fix round (2026-08-15, F1) — the original claim above was FALSE: CharacterManagementUiController does NOT do a per-tick set; it writes UiRoot.FixedCanvasSize ONCE on its own activation edge and NULLS it in both Deactivate() and Dispose(). This controller now matches that exact shape: Open() sets the canvas once, Close()/Deactivate()/Dispose() null it symmetrically. The un-nulled canvas was a real bug: RuntimeCharacterCreationState had no CompleteEnter() analogue to RuntimeCharacterSelectionState's (added this round, wired at both LiveSessionController in-world edges), so the chargen view reported IsActive=true for an entire in-world session, and since RetailUiRuntime.Tick ticks char-management BEFORE chargen, chargen's un-nulled canvas would silently re-pin an 800x600 scale over the in-world UI forever once the screen had ever been opened (dormant at defaults, armed under ACDREAM_OPEN_CHARGEN=1). Master shell: progress bar 0x100003ce, master page 0x100003d0 (state 0x10000025+page-1), 6 page roots, 6 free-navigation tabs (0x100003ef..f4), nav buttons 0x100003c6..cb — full decomp port of gmCharGenMainUI::ListenToElementMessage @ 0x004e9450 (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and SetProgressState @ 0x004e7a10 (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via RetailDialogFactory.MakeConfirmation + ID_CharGen_ExitWarning (table 0x23000002, matching DoExit @ 0x004e8650); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting gmEpilogueUI. Heritage page (CharacterCreationHeritagePage.cs, decomp InitializePage @ 0x00483a10 + the EXACT button-id→heritage-id map read off ListenToElementMessage @ 0x00483860, which is NOT numeric-order — e.g. 0x100005e8→Tumerok(7)): all 13 buttons, composed description text (ID_CharGen_Heritage_StartingSkills_Header/Body, ID_CharGen_Heritage_BonusSkills_Trained_Header + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's case 5: case 0xa:; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). Profession page (CharacterCreationProfessionPage.cs, InitializePage @ 0x00482d50 + UpdateProfession @ 0x004821b0's template map, cited already on ChargenTemplate): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's DynamicCast calls don't predict: the slider's value display (0x100002ef) imports as UiField not UiText (retail's NumberInputFilter, @0x00482e36) — wired for direct numeric entry via OnSubmit, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as UIElement_Button whose Type-12 value child is swallowed by UiButton.ConsumesDatChildren before ever becoming an addressable widget — substituted with the button's own .Label (AD-103). Health/Stamina/Mana formulas ported from UpdateAttributeValues @ 0x00482450: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at _ftol2 @0x0048262b, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=RemainingAttributeCredits directly (UpdateCreditsMeter-style, no formula). Skills page (CharacterCreationSkillsPage.cs, InitializePage @ 0x00481dd0): ONE flat listbox (AP-213, retail's four-bucket sorted InsertEntrySorted/UpdateSkillEntry model not ported) driven by CC3's TrainSkill/SpecializeSkill/UntrainSkill + the SAME two-tier TryGetSkillCost presence gate RuntimeCharacterCreationState uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes 0x100003fb/fc unbound (no info-pane content source this round). Town page (CharacterCreationTownPage.cs, InitializePage @ 0x0047c6d0 + SetTown @ 0x0047c360's literal index map): the four buttons map to LITERAL startArea indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. Random (0x100003cb, DoRandom @ 0x004e7d70): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no RandomizeHeritageGroup/RandomizeTemplate primitives exist); disabled outright on Skills (no RandomizeSkills primitive), Appearance (placeholder), Summary (CC5's warning dialog). Options threading: RuntimeCharacterCreationState.InstallOptions(ChargenOptions) (new, mirrors RuntimeCharacterState.InstallSpellMetadata→Spellbook.InstallMetadata's "install immutable DAT metadata after construction, throw if already active" pattern) called from ContentEffectsAudioCompositionPhase.Compose (new ChargenOptionsInstalled composition point, right after SpellMetadataInstalled) via IContentEffectsAudioCompositionFactory.LoadChargenOptions/InstallChargenOptions — ChargenTableReader.Load(dats) threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's Begin(). CORRECTED at the review fix round (2026-08-15, F6): the original claim that headless was unaffected left a dead end — HeadlessSessionHost wired the CharacterCreated/CreationFailed status hooks (closing CC3's F14) but never installed ChargenOptions, so a content-bearing headless host could observe a create but never actually issue one (every chargen command silently refused against ChargenOptions.Empty). Fixed by installing options directly beside the existing InstallSpellMetadata call, off the same HeadlessProcessContentLease.Dats, whenever contentLease is non-null; a content-less headless host (a validated-legal configuration — see the R9 note near _contentLease's other reads) still cannot issue chargen commands, matching its existing inability to resolve spell/collision data either. Status hooks: LiveSessionLifecycleBindings gained optional CharacterCreated/CreationFailed delegates (default null — every pre-CC4 construction site keeps compiling); LiveSessionLifecycleHost now overrides both ILiveSessionLifecycleHost methods to forward them; LiveSessionHostBindings gained matching optional fields threaded through LiveSessionHost's constructor; both LiveSessionRuntimeFactory.Create (App/graphical) and HeadlessSessionHost wire them to SessionStatusWriter.CharacterCreated/CreationFailed, closing CC3's F14 (zero call sites). Deferred command seam: IGameRuntimeView.CharacterCreation (new default-throw member, mirrors CharacterSelection), GameRuntime.CharacterCreation (passthrough to Session.CharacterCreation), CurrentGameRuntimeAdapter's new CharacterCreationProjection (IsActive-gated view+command wrapper, mirrors CharacterSelectionProjection), DeferredGameRuntimeStateCommands's new CharacterCreation view getter + 9 generation-capturing wrapper methods, and CharacterCreationRuntimeBindings wired in InteractionRetainedUiComposition.cs (CharacterCreation: sibling of CharacterSelection:, ResolveText backed by a DatStringResolver cached once per composition (characterCreationStrings, review fix round F12 — a fresh resolver per call was allocating + re-locking on every Heritage/Town description lookup, several times per page switch) and locked under d.DatLock only around each .Resolve call, OpenOnStart from the new RuntimeOptions.OpenCharacterCreationOnStart / ACDREAM_OPEN_CHARGEN=1 env flag — the interim open seam since Create stays ghosted). Widget types added to DatWidgetFactory: NONE — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. Register rows filed (same commit): AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). Tests: tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs (7 cases, ACDREAM_PROBE_LIVE_MOUNT=1-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + CharacterCreationUiControllerTests.cs (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs (+4 InstallOptions cases) + tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. OPEN for CC5/CC6a/CC6b: the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes 0x100003fb/fc have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. Review fix round (this commit, 2026-08-15): F1 (HIGH, blocking, architectural) — see the corrected FixedCanvasSize paragraph above; added RuntimeCharacterCreationState.CompleteEnter() (mirrors RuntimeCharacterSelectionState's own, wired at both LiveSessionController in-world edges: StartCore and the shared EnterHighlightedCore) and made CharacterCreationUiController.Open/Close/Deactivate/Dispose set/null UiRoot.FixedCanvasSize symmetrically with CharacterManagementUiController's real (not per-tick) shape; added FixedCanvasSize coverage to CharacterCreationUiControllerTests. F2 (MEDIUM-HIGH, blocking, fidelity) — the attribute-slider scalar mapping was NOT retail's: fixed the display scalar to value/100f (UpdateAttributeValues @ 0x0048251d) and the drag inverse to Math.Max(10, (int)(scalar*100f)) — truncate, clamp low only, no rescale (ListenToElementMessage @ 0x004829c0's scrollbar-drag case, independently re-derived against the decomp and confirmed byte-for-byte); added tests at scalar 0.5 and 0.0 (the previous single scalar=1f test coincidentally agreed with both the old wrong formula and the new correct one). F3 (MEDIUM, blocking, fidelity) — ported ListenToElementMessage @ 0x004e9450's heritage-button tab-restore arm (independently re-derived from the decomp: SHOW ids 0x100003bf/c1/c2/c3/10000590/91/100005a9/bf/c4/e8, HIDE ids 0x100005c7/c8, with Lugian 0x100005f1 genuinely absent from both switch cases — a real retail quirk, reproduced faithfully) as CharacterCreationUiController.ApplyHeritageTabRestore, invoked synchronously from a new CharacterCreationHeritagePage ctor callback on every button click; added restore-after-Olthoi-hide and Lugian-no-restore tests. F4 (MEDIUM, fidelity, blocks the user gate) — gmCGTownPage::SetTown @ 0x0047c360 also sets the TOWN PAGE's own retail state (a separate literal map from the master page's per-page-index cycling: Holtburg->0x10000034, Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035, re-asserted directly at the Sanamar-click site @0x0047c518) — independently re-derived from the decomp's tail-merged-branch pattern and ported to CharacterCreationTownPage.Refresh via the existing IUiDatStateful.TrySetRetailState seam; added a test. F5 (MEDIUM) — AD-103's "composited pixel result unchanged" claim was asserted, not measured; softened to state the equivalence is unverified rather than building a rect/justify comparison probe this round. F6 (MEDIUM, blocking, architectural) — decision: install ChargenOptions in the headless content path (option (a) of the two offered), not the deferred/out-of-scope alternative — HeadlessSessionHost now calls RuntimeCharacterCreationState.InstallOptions(ChargenTableReader.Load(content.Dats)) beside the existing InstallSpellMetadata call whenever contentLease is non-null, closing the gap where CC3's F14 status hooks were wired but no content-bearing headless host could ever produce a create to observe. F7 (LOW-MEDIUM) — AP-213 already named the label format and the click/double-click substitution explicitly on inspection; no row edit needed. F8 (LOW) — AP-212 now names all SIX of DoRandom's decompiled primitives (added the three the original row omitted: RandomizeAppearance @ 0x005c4f10, RandomizeClothing @ 0x005c6770, RandomizeCharacter @ 0x005c6d80, independently verified against the decomp alongside the three already-cited ones) and states the known landing site (Runtime, beside CC3's CharGenState ports). F9 (LOW) — AD-101's retirement condition corrected: must happen before CC5's Finish un-ghosts, not merely "at CC6b" (CC5 precedes CC6b in the slice order; shipping Finish first would let a create complete on an implicit gender default). F10 (LOW) — merged ItemAppraisalTextFormatter.SkillName's two consecutive <summary> blocks into one. F11 (LOW) — TS-82's "see AP-211's sibling gate" cross-reference was wrong (AP-211 is the unrelated roster-slot-cap refusal); corrected to point at TS-82's own CC5 dependency. F12 (LOW) — cached the chargen DatStringResolver once per composition (characterCreationStrings in InteractionRetainedUiComposition.CreateRetainedUi) instead of constructing + DAT-locking fresh on every ResolveText call; the LinesProvider per-Refresh closure allocation already matched the house pattern used throughout CharacterStatController.cs and elsewhere, so it was left as-is. F13 is a merge-mechanics note (TS-82 collides with campaign-cc6a's TS-82/83) for the orchestrator at merge time — no acdream-side action taken. CC4 re-review round (ec854db0's own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above): the F1 fix's raw _host.FixedCanvasSize = null in Close() was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. Fix — the root-cause shape, no workaround: UiRoot gained a single arbiter, DeclareFixedCanvas(object owner, Vector2 size)/RevokeFixedCanvas(object owner) (see AD-98's own register row for the mechanism detail); both CharacterCreationUiController and CharacterManagementUiController now declare on their activation edge and revoke on close/deactivate/dispose instead of writing FixedCanvasSize directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for UiRootFixedCanvasTests' isolated scale-math coverage. Test (reviewer-specified): tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs — two controllers sharing ONE UiRoot, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). R3 (LOW): tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs's new ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted proves F6's install actually opens the gate — a HeadlessSessionHost built with a content lease carrying a REAL hand-built DatCharGen heritage (not ChargenOptions.Empty) has that heritage present in CharacterCreationState.Options, and TrySelectHeritage for it succeeds once Begin is called (both called directly via this project's existing InternalsVisibleTo on AcDream.Runtime, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). R2 (LOW): filed docs/ISSUES.md #402 for the pre-existing Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched 82f8d4f8 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. R4 (LOW): fixed the "unchached" → "uncached" typo in InteractionRetainedUiComposition.cs's F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 CharacterScreensFixedCanvasArbiterTests cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. |
| CC5 | — | |||
| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | 55bfd9ca (foundation), 1774d8b2 (same-session review fix round, F1-F12) |
Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | Index→ObjDesc factory (ChargenAppearanceFactory.TryCompose, src/AcDream.Core/CharGen/, pure — no Chorizite types on its public surface, verified by the existing ChargenNoChoriziteLeakTests reflection guard, which walks the whole AcDream.Core.CharGen namespace and now covers these new types too): ports gmCG3DView::Update @ 0x004EE9D0's ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → Headgear → Trousers → Shirt → Footwear (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: ChargenPalSet/ChargenPalSetMath (shade→index), ChargenClothingTable/ChargenClothingBaseEffect/ChargenClothingPaletteTemplate/ChargenClothingSubPaletteChoice (pure ClothingTable projection), IChargenPalSetSource/IChargenClothingTableSource (DAT-touching work pushed behind these, implemented by the new Content-layer ChargenAppearanceCatalog, src/AcDream.Content/CharGen/, a cached dat reader mirroring ChargenTableReader's discipline), ChargenAppearanceSelection (mirrors RuntimeCharacterCreationAppearance's 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below): PalSet::GetPaletteID's FPU-elided body ((int)((count - 0.000001) * shade), clamped) is corroborated by ACE's PaletteSet.GetPaletteID (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the >= 0.0 gate at 0x005AC5A0). ACViewer's ClothingTableList.xaml.cs:97 does NOT corroborate this — it computes a different expression (Shades.Maximum - 0.000001, i.e. count-1, not count) for a different problem (mapping a shade back to a UI slider position), and references/ACViewer's vendored PaletteSet.cs is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use PalSet+shade indirection (skin: sex.SkinPalSet; hair: sex.HairColors[i] is ITSELF a PalSet id — confirmed against PlayerFactory.cs:96); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against PlayerFactory.cs:100's EyesPalette = sex.EyeColorList[eyeColor], no GetPaletteID call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against PaletteOverride's pre-existing *8 packing doc comment. Clothing dye resolution, installed-DAT-verified: CharGenState::GetHeadgearPaletteTemplateID/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single Sex_CG::ClothingColors dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male ClothingColors = {9,6,4,8,7,5,2,3,13} and the "Cloth Cap" headgear's ClothingSubPalEffects keys include every one of those values directly. Chargen preview renderer (ChargenPreviewRenderer, ChargenPreviewCamera/ChargenPreviewViewportCamera, ChargenPreviewEntityBuilder, all new files under src/AcDream.App/Rendering/): follows PrivateEntityViewportRenderer's exact architecture (offscreen target → texture table → UiViewport sprite later), a THIRD facade beside PaperdollViewportRenderer/CreatureAppraisalViewportRenderer — no existing file touched. ChargenPreviewEntityBuilder.TryBuild resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as DatLiveEntityProjectionMaterializer (surface-override resolution ported verbatim) and RetailPaperdollPoseApplicator (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (m_didAnimationRest: enum 0x10000005 for every standard heritage — the SAME id the paperdoll's own pose reads — 0x10000011 for Olthoi, 0x10000013 for OlthoiAcid, all resolved through master-map slot 7). Camera (gmCGAppearancePage::Update @ 0x0047E8F0, cross-checked against the identical literals in ZoomIn/ZoomOut @ 0x0047CF00/0x0047D050): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention DollCamera already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (CPhysicsObj::set_heading), not the camera — CC6b's heading parameter belongs on the entity builder. Constants recovered, not just cited (deliverable #4): RotationSecondsPerRevolution = 3.0 (clean in the decomp, no reconstruction needed) and ZoomTweenDurationSeconds = 0.6 — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at DoZoomAnimation's reset-default site (→ 0.6) AND independently at ZoomIn/ZoomOut's -0.1 invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). Register rows filed (same commit): TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-84 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss ClothingBaseEffects coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). Tests (final, post-fix-round counts): ChargenPalSetMathTests (10 cases, the shade-index formula), ChargenAppearanceFactoryTests (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), ChargenAppearanceCatalogInstalledDatTests (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-84 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), ChargenPreviewCameraTests (17 cases, every per-heritage literal + the two recovered constants), ChargenPreviewEntityBuilderTests (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 datLock parameter). |
Review fix round (F1-F12, same session): F1 (BLOCKING) — hairStyle.AlternateSetup != 0 / setupId == 0 tested the wrong sentinel; retail's Setup "unset" is INVALID_DID (0xFFFFFFFF — CharGenState::GetSetupID @0x005C5B22), not 0, so an AlternateSetup field storing that value would have been ADOPTED as a literal Setup id, nulling Get<Setup> and killing the whole preview. Fixed at both sites (ChargenAppearanceFactory.cs, new InvalidDid constant); two new hand-built tests plus a new installed-DAT sweep (EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, ChargenClothingTable.cs's doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in ChargenPalSetMath.cs's doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — ChargenPreviewEntityBuilder.TryBuild did unlocked dat reads; DatCollection is not thread-safe and every sibling dat-touching resolver in this layer takes a shared object datLock. Added a required datLock parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one lock, mirroring RetailPaperdollPoseApplicator.Apply's "resolve under lock, process after" shape. F5 (LOW) — Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — ChargenPreviewCamera.cs's rotation doc cited a nonexistent RotationDegreesPerSecond identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (DoRotation @0x0047CAC7: deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses Assert.Skip, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — [0x10000009, 0x100000F9, 0x10000001, 0x10000007], same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (ClothingTable::BuildObjDesc ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — continue changed to break, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling <see cref="ChargenAppearanceFactory.Compose"/> doc-comment references (the method is TryCompose) fixed. F10 (LOW) — the packed (byte)(range.Offset/8)/(byte)(range.NumColors/8) narrowing on dat-sourced data was unchecked (a real NumColors of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit PackOffset/PackNumColors helpers that document the 2048→0 equivalence deliberately and throw ArgumentOutOfRangeException on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second m_alternateSetupID override source (the appearance-page option checkbox — Penumbraen crown @0x004DFB3F, Undead no-flame @0x004E0C54, precedence at @0x004EEA51) is unmodelled; a shared RetailHeldPose helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15): the two cited write sites are gmBarberUI's, not gmCGAppearancePage's — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. Test counts after the fix round (measured, not projected): Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. |
| CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | 8dfee111 (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real InitializePage evidence) | Idle animation loop, TS-83 RETIRED: decomp re-read of gmCGAppearancePage::Update's own trailing gate (~0x0047EF01-0x0047EF12: if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — gmCGAppearancePage::InitializePage @0x0047FDD0 writes an explicit m_bZoomedIn = 0 at 0x004802C3, right after setting the camera to the zoomed-IN per-heritage eye at 0x00480286-0x0048029E (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose — the rest pose only appears once the user presses Zoom In, which retail's own ZoomIn/ZoomOut (0x0047CF00/0x0047D050) call gmCG3DView::StopAnimation/StartAnimation for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive RetailAnimationCyclePlayback (src/AcDream.Core/Physics/, pure, unit-tested) ports CPhysicsObj::set_sequence_animation @ 0x0050F6F0's effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-AnimationSequencer NPC idle path (LiveEntityAnimationPresenter.Present's legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type ChargenPreviewAnimator (src/AcDream.App/Rendering/) owns the per-tick idle-frame advance / rest-pose freeze swap; ChargenPreviewEntityBuilder gained TryBuildAnimated (returns a ChargenPreviewAnimatedBuild: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL TryBuild (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — ResolveIdleAnimEnum resolves m_didAnimation's enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing ResolveRestPoseEnum (0x10000005/0x10000011/0x10000013) — Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). Rotation controller: new ChargenPreviewRotationController (src/AcDream.App/Rendering/) ports gmCGAppearancePage::Rotate/DoRotation (0x0047CB50/0x0047CA80) verbatim — toggle-to-stop-same-direction, deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the -1.0 sentinel Rotate() writes to invalidate m_dLastRotateTime (bit-confirmed: high dword 0xbff00000 + zero low dword). ECG_ROTATE_CLOCKWISE=1/ECG_ROTATE_COUNTERCLOCKWISE=2 confirmed from acclient.h:6848-6852 — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via MoveToMath.SetHeading (the exact existing CPhysicsObj::set_heading port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. Zoom tween: new ChargenPreviewZoomController ports ZoomIn/ZoomOut/DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) — a LINEAR (not eased — the decomp shows a straight (targ-start)*t+start per axis with no easing curve anywhere in the function) 0.6s tween between ChargenPreviewCamera's already-recorded default/zoomed-out eye profiles, using the same -0.1 invalidation-sentinel idiom as rotation; ZoomIn/ZoomOut call into ChargenPreviewAnimator.SetZoomedIn IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. Fix round F2: the controller and the animator originally kept two INDEPENDENT IsZoomedIn bools synced only through a nullable animator argument on ZoomIn/ZoomOut — a null pass, or a direct ChargenPreviewAnimator.SetZoomedIn call bypassing the controller, could desync the camera target from the animation pose. Retail's m_bZoomedIn is a SINGLE field gating both, so ChargenPreviewZoomController now takes its ChargenPreviewAnimator as a required constructor dependency and IsZoomedIn reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. m_alternateSetupID (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port: re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE m_alternateSetupID write sites — including the two the CC6a review fix round cited, Penumbraen crown @0x004DFB3F and Undead no-flame @0x004E0C54 — belong to gmBarberUI, not gmCGAppearancePage. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): @0x004DFB5B sits inside gmBarberUI::ListenToElementMessage (sibling evidence: gmBarberUI::SetSelection/gmBarberUI::Rotate calls in the same body, which ends in a CM_Character::Event_FinishBarber wire call — a barber-shop-only message); @0x004E0C54 (Penumbraen crown), @0x004E0D42, and @0x004E0DB1 all sit inside the SAME gmBarberUI::InitializePage (sibling evidence: m_pOption1Checkbox reads and UIElement_Text::SetStringInfoWithFont calls on barber-specific string ids in that body); the ONLY thing gmCGAppearancePage itself ever does with the field is READ it generically through the shared gmCG3DView ctor/::Update (every gmCG3DView owner does this) — gmCGAppearancePage's own field list (acclient.h:56373-56428, checked exhaustively) has NO m_pOption1Checkbox-equivalent member and none of its own methods write m_alternateSetupID. gmBarberUI is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's gmCGAppearancePage. For character creation, m_alternateSetupID is therefore ALWAYS INVALID_DID in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all, and is out of this campaign's scope entirely. Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there. ChargenAppearanceFactory.TryCompose still gained a real, decomp-cited alternateSetupIdOverride parameter (default InvalidDid, i.e. no-op for every existing caller) implementing gmCG3DView::Update's own generic precedence exactly (~0x004EEA46-0x004EEA53: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the INVALID_DID sentinel discipline. RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction: new src/AcDream.App/Rendering/RetailHeldPose.cs shares ResolvePoseDid (master-map-slot-7 DID lookup) and ComposePartTransform (Scale*Rotate*Translate) between RetailPaperdollPoseApplicator.Apply (paperdoll, refactored to call the shared helper, behavior byte-identical) and ChargenPreviewEntityBuilder (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered WorldEntity.MeshRefs; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. Bookkeeping: TS-83 retired in docs/architecture/retail-divergence-register.md (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (55bfd9ca, 1774d8b2) instead of "HEAD of campaign-cc6a". Tests: RetailAnimationCyclePlaybackTests (10, Core), ChargenAppearanceFactoryTests (+4, the override precedence/sentinel), ChargenPreviewRotationControllerTests (10, +1 this fix round — F7's clockwise-past-360 clamp case), ChargenPreviewZoomControllerTests (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), ChargenPreviewAnimatorTests (7, hand-built fixtures — no dat needed since a ChargenPreviewAnimatedBuild is constructible entirely in memory), ChargenPreviewEntityBuilderTests (+5, installed-DAT-gated — TryBuildAnimated resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge (randomized-loss-injection timing, zero files under src/AcDream.Core.Net/ touched) and AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce (a concurrency race under full-solution parallel load, zero files under src/AcDream.Content/ touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. OWED (CC6b page-mount half, separate follow-up): the Appearance/Summary viewport mount (0x100003bb/0x10000406), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to ChargenPreviewZoomController.ZoomIn/ZoomOut (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and ChargenPreviewRotationController.Toggle/Tick, spin controls, color wheels, and the INITIAL HEADING: gmCGAppearancePage::InitializePage @0x0047FDD0 sets m_fCurHeading = 180f at 0x00480235 and pushes it via SetPlayerHeading at 0x0048023F (overriding the ctor’s 0°; cross-confirmed at gmBarberUI::PostInit @0x004DE330 and the summary page’s 0x0047BD54) — the mount half must seed ChargenPreviewRotationController.HeadingDegrees = 180f or the character faces AWAY from the camera at the user gate. Explicitly NOT owed: an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; gmCGAppearancePage never had one, so CC6b-mount must not invent one. |
| CC7 | — | | | |