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>
17 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@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@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. 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 | — |