acdream/docs/plans/2026-08-15-character-creation-campaign.md
Erik 55bfd9ca82 feat(chargen): Campaign CC slice CC6a — index→ObjDesc factory + preview renderer foundation
Delivers the CC6a foundation half of the chargen 3D preview: the missing
index->ObjDesc appearance factory the campaign plan's acdream-seams
section named, plus a static-pose offscreen renderer following
PrivateEntityViewportRenderer's proven paperdoll/appraisal architecture.
Page mount, spin/color-wheel controls, and rotate/zoom behavior stay out
of scope per the CC4-parallel worktree contract (CC6b, after CC4 merges).

Core (src/AcDream.Core/CharGen/, pure, no Chorizite on public surfaces):
ChargenAppearanceFactory.TryCompose ports gmCG3DView::Update @0x004EE9D0's
ObjDesc rebuild in its exact decompiled order - base body, hair style,
clothing in retail's own Headgear/Trousers/Shirt/Footwear order (not the
UI tab order or the wire's field order, both of which differ), eyes
(bald-aware), nose, mouth, then the unconditional skin subpalette, hair
color, eye color. ChargenPalSetMath ports PalSet::GetPaletteID's
shade-to-index formula, cross-checked three ways (decomp control flow,
ACE's PaletteSet.GetPaletteID "Taken from acclient.c" citation, ACViewer's
identical slider math). ChargenPalSet/ChargenClothingTable are pure
projections behind IChargenPalSetSource/IChargenClothingTableSource so the
factory itself never touches a dat.

Content (src/AcDream.Content/CharGen/): ChargenAppearanceCatalog is the
cached dat-backed implementation of those two source interfaces, mirroring
ChargenTableReader's no-leak discipline.

App (src/AcDream.App/Rendering/): ChargenPreviewRenderer is a third facade
over PrivateEntityViewportRenderer beside PaperdollViewportRenderer and
CreatureAppraisalViewportRenderer - no existing rendering file touched.
ChargenPreviewCamera carries the four retail-verbatim per-heritage eye
profiles from gmCGAppearancePage::Update @0x0047E8F0 (cross-checked
against ZoomIn/ZoomOut's identical literals) plus the recovered rotation
(3.0 s/revolution) and zoom-tween (0.6 s, reconstructed from the
decompiler's garbled float literals - the plan's own "measure if it
matters" note is resolved, not garbled beyond recovery). Rotation applies
to the character model, not the camera, per gmCGAppearancePage::DoRotation.
ChargenPreviewEntityBuilder resolves Setup/GfxObj/Surface/Animation itself
(there is no live entity yet), reusing DatLiveEntityProjectionMaterializer's
surface-override algorithm and RetailPaperdollPoseApplicator's held-pose
technique, generalized to chargen's per-heritage rest-pose DID.

Two register rows filed: TS-83 (the plan-named CC6a static-pose-vs-retail-
idle-loop staging, CC6b to retire) and TS-82 (measured, not assumed - the
un-ported clothing Setup-substitution fallback chain costs nothing for the
9 standard heritages with clothing UI, but Undead's default gear choices
genuinely lack ClothingBaseEffects coverage for Undead's own body Setup).

Tests: ChargenPalSetMathTests, ChargenAppearanceFactoryTests (hand-built
fixtures), ChargenAppearanceCatalogInstalledDatTests (installed-DAT sweep,
all 26 heritage/gender combinations, zero missing PalSet/ClothingTable
ids), ChargenPreviewCameraTests, ChargenPreviewEntityBuilderTests
(installed-DAT-gated, proves a real 34-part Aluvian mesh resolves).
Core.Tests 4767/1 skip, Content.Tests 146/0, App.Tests 5121/6 skips - all
pre-existing skips, zero failures, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 17:22:06 +02:00

258 lines
36 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(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@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. **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 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 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160``SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. |
| CC4 | — | | | |
| CC5 | — | | | |
| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | single commit, HEAD of `campaign-cc6a` | PENDING (Opus dual-lens not yet run this session) | **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 — three-way agreement, no guessing:** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c"), ACViewer's identical `ClothingTableList.xaml.cs:97` slider math, AND the decomp's own control-flow shape. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-82 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default headgear/trousers/footwear choices genuinely miss `ClothingBaseEffects` coverage for Undead's own live body Setup on both genders — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests:** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (19 hand-built-fixture cases covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (installed-DAT sweep, all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids — 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). Final counts this session: Core.Tests 4767/1 skip, Content.Tests 146/0 skips, App.Tests 5121/6 skips — all pre-existing skips, zero failures, full solution Release build green. |
| CC6b | — | | | |
| CC7 | — | | | |