acdream/docs/plans/2026-08-15-character-creation-campaign.md
Erik f3ef7baae2 docs+fix(chargen): CC1/CC2 review closeout — R2/R3 residuals closed, ledger final
Both narrow re-reviews returned CLOSED. This closeout takes the two cheap
re-review residuals before CC3 takes references to the shared model:

R2: every array handed into the typed chargen model is now wrapped in
Array.AsReadOnly at the projection seam — a T[] behind IReadOnlyList<T>
was still downcast-mutable, and ChargenOptions is a process-shared
singleton graph.

R3: the no-Chorizite-leak guard now also walks public fields; every
current type uses properties, but a public field would have slipped
through the property-only walk.

Ledger: CC1 fix-round sha corrected to cb4703e8 (the cell previously
cited the pre-amend 459a87f2), CC1/CC2 rows flipped to REVIEW-CLOSED
with the re-review outcomes, R1 (retail refunds +1 credit on a
both-tier cost miss; port charges 0 — unreachable via retail's own
listbox, noted for CC3) and the Olthoi-locked-to-template-0 decomp fact
recorded for CC3/CC4.

Core.Tests 4736/1 skip, Content.Tests 145/0, Release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 13:41:37 +02:00

238 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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@236864`): trim+set name → empty name →
`ID_CharGen_NoNameWarning`, abort; `remainingAtrbCredits > 0` → credit
warning, abort (retail FORCES full spend — ACE does not; we port the client
gate); verification state must be UNDEF (no double submit) → set PENDING →
`Proto_UI::SendCharGenResult@0x00546A70`.
**Wire 0xF656** (`ACCharGenResult::CG_Pack@0x005C7200`, byte-identical to
ACE's `CharacterCreateInfo.Unpack`): account String16L FIRST (outside the
body), then u32 constant 1, u32 heritage, u32 gender, u32×3 eyes/nose/mouth
strips, u32×2 hairColor/eyeColor, u32 hairStyle, u32×2 headgearStyle/Color,
u32×2 shirt, u32×2 trousers, u32×2 footwear, f64×6 skin/hair/headgear/shirt/
trousers/footwear shades, u32 templateNum, u32×6 attributes
(str/end/coord/quick/focus/self), u32 slot, u32 classID, u32 numSkills +
numSkills×u32 advancement classes (MUST be exactly 55 — ACE TERMINATES the
session on mismatch), String16L name, u32 startArea, u32 isAdmin, u32
isEnvoy(=ACE IsSentinel), u32 trailing checksum = sum of
heritage+gender+strips(3)+hairColor+eyeColor+hairStyle+headgearStyle+
shirtStyle+trousersStyle+footwearStyle+template+6 attributes (ACE never
reads it; we send it for byte fidelity). holtburger cross-check:
`character/types.rs:236` (stops before the checksum).
**Response 0xF643** (shared opcode with restore — LA7a's conditional parse is
reusable): codes Undef=0 Ok=1 Pending=2 NameInUse=3 NameBanned=4 Corrupt=5
DatabaseDown=6 AdminPrivilegeDenied=7. On Ok the payload is a
CharacterIdentity (guid, String16L name, u32 secondsGreyedOut) and NOBODY
sends a fresh CharacterList — retail appends the identity to its local
roster (`Handle_CharGenVerificationResponse@0x0055E8B0` case 1 →
`CharacterSet::AddIdentity`) and `gmCharGenMainUI::Update@236161` then
watches the set and calls `CPlayerSystem::LogOnCharacter` DIRECTLY when the
new name appears (logs straight in; only falls back to char management if it
never appears). Error dialogs: NameInUse→`ID_Character_Err_NameReserved`,
NameBanned→`ID_Character_Err_NameBanned`, Corrupt/DatabaseDown→
`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→
`ID_Character_Err_NameAdminDenied`, Pending/Undef→silent state reset (ACE
sends Pending for a disabled-Olthoi rejection — retail swallows it; port
as-is, register-note the quirk).
**Chargen DAT table** `0x0E000002`: readable TODAY via the
Chorizite.DatReaderWriter package (`dats.Get<CharGen>`) — zero in-tree
readers exist. ACE loaders (`ACE.DatLoader.FileTypes.CharGen` +
`HeritageGroupCG/SexCG/TemplateCG`) and retail serializers
(`ACCharGenData::Serialize@0x005C36D0`, `HeritageGroup_CG@0x005C2100`,
`Sex_CG@0x005C1600`, `Template_CG@0x005C0450`) define the shape: per
heritage → name/icon/setup/EnvironmentSetup/attribute+skill credits/start
areas/skills(costs)/templates(attrs+skills)/genders; per sex → scale, setup,
base palette, skin palset, base ObjDesc, and the option LISTS (hair styles/
colors, eye colors, eye/nose/mouth strips, headgear/shirt/pants/footwear,
clothing colors).
**3D preview** (`gmCG3DView`, Appearance `0x100003bb` + Summary `0x10000406`
ONLY — the other four pages have no viewport): preview body
`CPhysicsObj::makeObject(setupId)` (fallback HUMAN_SETUP_ID), rebuild on
change via ObjDesc (`ClothingTable::BuildObjDesc` per clothing slot + strips
+ PalSet skin/hair/eye subpalettes) applied with
`DoObjDescChangesFromDefault@242308`, one DISTANT_LIGHT (intensity 2.0),
idle animation loop at 30fps (`set_sequence_animation`), rest-pose freeze on
zoom-in, BUTTON-toggled continuous rotation (`DoRotation@137337`, 3.0
s/revolution, per-frame global-message-3 tick), zoom tween between
per-heritage camera positions (`Update@138974` hard-codes Olthoi vs
human-form camera offsets).
## acdream seams (build on these, do not reinvent)
- Layout mount: `RetailDataIdResolver.Resolve(dats, 0x10000039, 5)` +
`LayoutImporter` — fully generic. `DatWidgetFactory` already maps dat type
0xD → `UiViewport`. The char-management controller REFUSES viewports by
local policy (:212) — chargen gets its OWN controller; clone
`CharacterManagementUiMountCoordinator` + the bindings-record pattern.
- Fixed canvas: chargen is the same 800×600 flow screen — mount at authored
extent, `UiRoot.FixedCanvasSize` on activate (AD-98), dialogs center on
`EffectiveCanvasSize`. Live-DAT probe tests sweep ALL media ids
(`CharacterManagementLiveDatTests` pattern) and pin authored
justify/anchors.
- Preview pipeline: `PrivateEntityViewportRenderer` (offscreen target →
texture table → `UiViewport` sprite) is proven by paperdoll + appraisal;
cameras there are FIXED — chargen needs a heading-capable camera. NOTE:
`GlGpuDevice.RegisterExternalColorTexture` is a DELETED API that survives
only in stale doc comments — do not cite it. Appearance building:
`DollEntityBuilder.Build` is index-agnostic and pure (setup + resolved
palette/part ids), but the only existing factory reads a LIVE entity —
chargen needs a new index→dat→ObjDesc factory (SexCG.BaseObjDesc + strip
overlays + PalSet.GetPaletteID hues). Pose: paperdoll holds a static final
frame; retail chargen plays a live idle loop — see slice CC6 for the
staged approach.
- Runtime owner: mirror `RuntimeCharacterSelectionState` exactly (lifecycle/
snapshot/delta records, borrow-only view, generation-gated commands, one
mutable owner, no App types). Command family lands beside
`IGameRuntimeCommands.CharacterSelection`. Enter-after-create hooks the
existing `LiveSessionController.BeginEnter/CompleteEnter`.
- Wire plumbing: `WorldSession`'s dispatch chain routes EVERY 0xF643 through
`CharacterRestore.Parse` today with no request correlation — the KNOWN
LANDMINE. Creation requires an awaiting-request latch (create vs restore)
BEFORE its response arm lands. Outbound mirrors
`SendRestoreCharacter@2223`. Status writer: add `characterCreated` /
`creationFailed` events (update the pinned §LA1 contract text + the
Launcher.Core tailer + tests in lockstep).
## Slices
| Slice | Deliverable | Depends |
|---|---|---|
| CC1 | Chargen data layer: `CharGen` table reader → typed options model (heritages/sexes/appearance lists/templates/skills+costs/budgets/towns), Content/Core, live-DAT probes | — |
| CC2 | Wire: `CharacterCreate` 0xF656 builder (byte-exact incl. checksum), shared verification-response type (refactor from `CharacterRestore`), WorldSession request-correlation for 0xF643, send seam, status events + contract/tailer update | — |
| CC3 | `RuntimeCharacterCreationState`: full CharGenState mirror, per-page commands, retail client gates (full-spend, name, 55-slot invariant, client-side slot cap), verification latch, Ok → roster append + retail log-straight-in | CC1, CC2 |
| CC4 | Screen shell + form pages (App): mount (enum 0x10000039), master nav/tabs/progress, dialogs, Heritage + Profession + Skills + Town pages | CC1, CC3 |
| CC5 | Summary page: name input (NameInputFilter, `ID_CharGen_NameTooLong`), summary listbox, static summary viewport, Finish gates + full response/dialog handling | CC3, CC4 |
| CC6 | Appearance page + preview: index→ObjDesc factory, chargen preview renderer (offscreen, heading camera, rotate/zoom buttons), spin controls + color wheels; **staged:** CC6a static-pose preview (paperdoll-style held frame, register row for the missing idle loop), CC6b idle animation + zoom rest-freeze (retire the row) | CC1, CC4 |
| CC7 | End-to-end: Create button un-ghosts, full flow vs ACE shapes in tests, launcher payload cycle, connected checklist doc | all |
Parallelism: CC1 ∥ CC2 (disjoint: Content/Core vs Core.Net; separate
worktrees). CC4 ∥ CC6a after CC3. CC5 last before CC7.
## Risks / open items (from recon Unknowns)
1. 0xF643 create/restore correlation (CC2's first job; the restore doc
comment already warns).
2. 55-slot skill array: ACE terminates the session on mismatch — CC2/CC3
must make it structurally impossible to send anything else.
3. Slot cap is client-enforced only (ACE never checks on create) — honor
`slotCount` like retail's UI did.
4. Color-wheel/gradient widgets (`tagColorWheel`, GradCircle `0x1000030e`,
shade scroll) may need new widget types in `DatWidgetFactory` — CC6
scouts the authored layout first.
5. Retail unknowns to resolve during slices, never guess: the chargen
please-wait dialog context (decompiler-mislabeled field), the
AppearancePage gender-flip-on-init oddity (@140355 — verify live before
porting), `Method_CG` enums are empty in the header, ZoomIn tween
duration constant is decompiler-garbled (measure against retail if it
matters).
6. Viewport inside the fixed canvas: the offscreen target's pixel size vs
the canvas-scaled on-screen rect (render at scaled size for crispness or
authored size for fidelity) — decide in CC6a with the user gate as
arbiter.
7. `references/*` absent in worktrees (except WorldBuilder, uninitialized
submodule) — agents read ACE/holtburger from the MAIN checkout path.
8. **CC7 landmine (found in the CC1 review fix round, 2026-08-15):** ACE's
`PlayerFactory.CreatePlayer` heritage-override branch
(references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211)
over-deducts skill credits when specializing a skill the active
heritage's own list prices. For a skill priced ONLY by the global
SkillTable, ACE correctly computes the incremental specialize cost via
`SkillBase.UpgradeCostFromTrainedToSpecialized` (= `SpecializedCost -
TrainedCost`) and charges `TrainSkill(trainedCost) +
SpecializeSkill(incrementalCost)` = the field's TOTAL, matching retail.
But when the heritage's own list has an entry, ACE sets
`specializedCost = skillGroup.PrimaryCost` directly — `PrimaryCost` is
already the TOTAL cost to reach Specialized (acdream's own
`ChargenSkillCost.PrimaryCost` convention, confirmed against retail) —
and then still charges `TrainSkill(NormalCost) +
SpecializeSkill(PrimaryCost)`, over-deducting by an extra `NormalCost`
credits versus what retail's client computed and what the player agreed
to spend. Practical impact for CC7's connected gate: a retail-legal
character build that specializes a skill the ACTIVE HERITAGE prices
(every one of the 13 installed heritages has exactly one such skill —
see `ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet`)
may be REJECTED by local ACE with `FailedToSpecializeSkill` even though
acdream sent the byte-correct 0xF656 body. If CC7's gate hits this,
it is an ACE-side bug reproduced from its own source, NOT an acdream
wire or math defect — do not "fix" acdream's cost math to match ACE's
over-deduction. Register: file an AD row if CC7 needs a documented
workaround (e.g. picking a Specialized skill combination that avoids
the heritage-priced skill for the connected gate) rather than silently
adjusting acdream's send.
## Review protocol
Per slice: implement → Opus dual-lens (architectural + retail fidelity — this
campaign is retail-heavy everywhere) → fixes → narrow re-review → DONE in
ledger. CC2's review adds wire-byte scrutiny (the LA7a precedent: the
reviewer decodes the binary); CC6's adds the visual-fidelity lens ahead of
the user gate.
## Ledger
| Slice | Status | Commits | Review | Notes |
|---|---|---|---|---|
| CC1 | REVIEW-CLOSED 2026-08-15 | `04450041`, `cb4703e8` | CLOSED (fix round + narrow re-review; every citation independently re-derived) | Core model (no Chorizite leak) + Content projector; 31 math units + 6 installed-DAT gates (13 heritages). FINDING for CC3: each human heritage's "Adventurer" template IS retail's Custom entry point — attributes at the 10-floor (60/330), a real TemplateCG row, not a UI special case. **Review fix round (`cb4703e8`):** F1 doc corrected — Custom IS template index 0 (the Adventurer row), per `gmCGProfessionPage::UpdateProfession @ 0x004821b0` (case 0 → button 0x100003d9 / `ID_CharGen_CustomText`) and `CharGenState::SetTemplate @ 0x005C5A60` (commits via `CharGenState::ApplyTemplate @ 0x005C5080`, i.e. selecting Custom resets sliders to the floor spread, it does not bypass templates); F2 two-tier skill-cost fallback implemented (`ChargenOptions.GlobalSkillCostsBySkillId` from portal.dat 0x0E000004, `ChargenSkillCreditMath` checks heritage list then global list) + installed-DAT completeness assertion recording reality: the global SkillTable prices 38/54 advancement skill ids, every one of the 13 heritages ships EXACTLY one heritage-specific override (always also present in the global table), and 16 skill ids are genuinely uncostable in both tiers (retail's -1 case) — see `ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet`; F3 every `ChargenTableReader` collection is now frozen at projection (`ToFrozenDictionary`/`ToArray`, matching `MagicCatalog`'s pattern) including both `ChargenOptions.Empty` dictionaries; F4 a reflection guard test (`ChargenNoChoriziteLeakTests`) pins the no-Chorizite-leak contract by walking every public `AcDream.Core.CharGen` member; F5 `HasAnyAppearanceOptions`'s doc reworded to state precisely what it proves (an OR across eight lists, omitting the three color lists) + a new installed-DAT gate records per-list reality — found COMPLETE, every gender of every heritage has non-empty lists across all eight plus the three color lists, even the sparse Gear Knight/Olthoi variants; F6 `TryGetHeritage`/`TryGetStarterArea` annotated `[MaybeNullWhen(false)]` (matching the house `EmptyDatReaderWriter` pattern), all affected call sites (more than the originally estimated five) fixed across both test projects. Filed CC7 risk item 8: ACE's `PlayerFactory` heritage-override branch over-deducts skill credits when specializing a heritage-priced skill (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) — a retail-legal build may be rejected by local ACE at the CC7 connected gate; this is an ACE bug, not an acdream defect. **Narrow re-review CLOSED:** the reviewer retro-graded F2 to HIGH (under the base commit 37 of 38 costable skills were charged zero) and confirmed the SkillBase.SpecializedCost->PrimaryCost mapping dodged the UpgradeCostFromTrainedToSpecialized trap. Residuals: R1 retail refunds +1 credit on a both-tier miss (port charges 0; unreachable via retails own skills listbox — NOTE FOR CC3 if any path ever exposes the 16 uncostable ids); R2 list downcast-mutability and R3 field-walking in the leak guard CLOSED at the merge-closeout commit (Array.AsReadOnly at every projection seam; GetFields walk added). Decomp fact for CC4: ApplyTemplate force-sets template_=0 for heritage 0xc/0xd — both Olthoi variants are hard-locked to Custom/template 0. |
| CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL |
| CC3 | — | | | |
| CC4 | — | | | |
| CC5 | — | | | |
| CC6a | — | | | |
| CC6b | — | | | |
| CC7 | — | | | |