acdream/docs/plans/2026-08-15-character-creation-campaign.md
Erik cb4703e8d5 fix(chargen): CC1 review fix round — Custom is template 0, SkillTable cost fallback, frozen model
Implements all six Opus review findings against 04450041 (Campaign CC
CC1 chargen data layer):

- F1 (HIGH, blocking): ChargenTemplate's doc claimed "Custom" has no
  ChargenTemplate entry and cited two nonexistent addresses. Verified
  against the named retail decomp: gmCGProfessionPage::UpdateProfession
  @ 0x004821b0 resolves BOTH the highlighted button and the description
  string from CharGenState.template_ 0..6, and case 0 is button
  0x100003d9 / ID_CharGen_CustomText. Custom IS template index 0 (the
  "Adventurer" row CC1 already found sitting at the attribute floor).
  CharGenState::SetTemplate @ 0x005C5A60 confirms every button (including
  Custom) calls CharGenState::ApplyTemplate @ 0x005C5080 when committing,
  so selecting Custom resets the sliders/skills to that row rather than
  leaving them untouched.

- F2 (MEDIUM): retail's skill-cost lookup is two-tiered
  (ACCharGenData::GetSkillTrainedCost/GetSkillSpecializedCost @
  0x005C26D0/0x005C27D0 fall through to the global SkillTable,
  portal.dat 0x0E000004, on a heritage-list miss — confirmed against
  ACE's identical PlayerFactory.cs precedence). ChargenTableReader now
  also projects the global SkillTable into
  ChargenOptions.GlobalSkillCostsBySkillId, and
  ChargenSkillCreditMath.ComputeSpent/RemainingCredits check the
  heritage list first and the global list on a miss. Added an
  installed-DAT completeness assertion recording reality: the global
  table prices 38/54 advancement skill ids, every one of the 13
  installed heritages ships exactly one heritage-specific override
  (always also priced globally), and 16 ids are genuinely uncostable in
  both tiers. Also filed a CC7 risk-item note: ACE's own heritage-
  override branch over-deducts on Specialize (PlayerFactory.cs:184-211)
  — a retail-legal build may be rejected by local ACE at the CC7
  connected gate; that is an ACE bug, not an acdream defect.

- F3 (MEDIUM): every collection ChargenTableReader hands into the
  record model is now frozen at projection (ToFrozenDictionary/ToArray,
  matching MagicCatalog's house pattern), including both
  ChargenOptions.Empty dictionaries.

- F4 (LOW): added a reflection guard test
  (ChargenNoChoriziteLeakTests) that walks every public
  AcDream.Core.CharGen member (property/indexer/constructor/method
  types, recursively through generic arguments) and fails if any
  resolves to the DatReaderWriter or a Chorizite* assembly.

- F5 (LOW): ChargenGenderOptions.HasAnyAppearanceOptions's doc now
  states precisely what the installed-DAT gate proves (an OR across
  eight lists, for at least one gender per heritage) rather than the
  stronger claim it previously made, and explicitly calls out the three
  omitted color lists. Added a second installed-DAT gate that records
  per-list reality across every gender of every heritage — found
  complete, no empty lists anywhere in the installed DAT today.

- F6 (LOW): ChargenOptions.TryGetHeritage/TryGetStarterArea now use
  [MaybeNullWhen(false)] instead of null! suppression, matching the
  house pattern already used elsewhere in the test suite. Fixed every
  call site this surfaced (more than the five originally estimated,
  since Content.Tests has TreatWarningsAsErrors).

Core.Tests: 4737 passed / 1 skip (pre-existing, unrelated).
Content.Tests: 145 passed / 0 skip.

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

17 KiB
Raw Blame History

Campaign CC — retail character creation

Status: ACTIVE (started 2026-08-15) Goal (user-set): the full retail creation flow against local ACE — Create button through a new character entering the world, 3D preview live, rejections showing retail's dialogs — then stop for the user gate. Branch: claude/acdream-launcher-credentials-4d2f7c Process: Campaign LA's, binding (Sonnet implements, Opus dual-lens reviews per slice, retail decomp is the oracle, register rows with deviations, build+test green per slice, commits tagged Campaign CC).

This plan embeds the 2026-08-15 recon facts (three parallel sweeps: retail gmCG UI, chargen data+wire, acdream seams) so slices and future sessions need no transcript access. references/ACE and references/holtburger are NOT in this worktree (gitignored) — read them from the main checkout at C:\Users\erikn\source\repos\acdream\references\.

Retail ground truth (recon summary — cite these in code)

Flow. Create button (0x100003A0) → QueueUIMode(0x1000000b)gmCharGenMainUI (acclient.h:56232): ONE root layout, enum 0x10000039 via GetDIDByEnum table 5 (our generic RetailDataIdResolver handles this), pages as children. ECGProgress: Heritage=1 → Profession=2 → Skills=3 → Appearance=4 → Town=5 → Summary=6. Nav dispatch gmCharGenMainUI::ListenToElementMessage@237025: Back 0x100003c6 (at Heritage → DoExit), Next 0x100003c7, Finish 0x100003c8 (Summary only), Help 0x100003c9, Exit 0x100003ca (→ ID_CharGen_ExitWarning confirm), Random 0x100003cb (on Summary → randomize warning first). Tab buttons 0x100003ef..f4 jump pages freely (not validation-gated). Page roots: Heritage 0x100003d1, Profession 0x100003d2, Skills 0x100003d3, Appearance 0x100003d4, Town 0x100003d5, Summary 0x100003d6; progress bar 0x100003ce, master page 0x100003d0. Per-page child ids are in the recon-cited ctors: Heritage InitializePage@143731 (13 race buttons + text 0x100003c4), Profession @143010 (6 attribute sliders 0x100003e6..eb, avail/health/stam/mana 0x100003e2..e5, template buttons resolved in UpdateProfession@142180: Custom 0x100003d9, Bowhunter/Swashbuckler/ Lifecaster/Warmage/Wayfarer/Soldier 0x100003da..df), Skills @141911 (listbox 0x100003f7, credits 0x100002f3, info 0x100003fb/fc), Appearance @140032 (gender 0x100003a7/a8, spins hair/eyes/nose/mouth/skin 0x100003af..b3, headgear/shirt/trousers/footwear 0x100003b5..b8, zoom 0x10000325/26, rotate 0x10000323/24, color wheel family 0x1000030e..0x10000321, viewport 0x100003bb), Town @137120 (Sanamar 0x1000040b, Holtburg 0x1000040d, Yaraq 0x1000040e, Shoushi 0x1000040f), Summary @136566 (list 0x10000400, name text 0x10000402 with NameInputFilter, viewport 0x10000406).

CharGenState (acclient.h:40074): the model our Runtime owner mirrors — heritage/gender, appearance strips+styles+colors+shades (f64 shades), template + 6 attributes + credit budgets + per-attribute locks, 55-slot skill advancement array + skill credits, name[33], startArea, setupID, verificationState. Writers per page in the recon (SetHeritageGroup recomputes budgets + ApplyTemplate + RandomizeStartArea; SetGender reapplies clothing and UpdateTrueFacePal).

Finish (DoFinish@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 implemented; review fix round complete 04450041, 459a87f2 dispatched 2026-08-15 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 (459a87f2): 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.
CC2 implemented (campaign-cc2); review in flight 5eaad2c8 dispatched 2026-08-15 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