# R7 — Character Creation (deep dive)

Research slice for acdream's retail-faithful character-creation pipeline:
heritage taxonomy, templates, appearance, skill-credit economy, starting
town, wire format, preview renderer, port plan.

**Inputs surveyed:**
- `docs/research/decompiled/chunk_00470000.c` (CharGen UI panel, strings,
  state machine; `FUN_0047aa10` is the CharGen panel ctor,
  `FUN_0047b590` is the summary redraw, `FUN_0047c5a0` is the starting-town
  description render, `FUN_0047c140` is the name-too-long dialog).
- `docs/research/decompiled/chunk_00480000.c`, `chunk_00540000.c`
  (opcode dispatch — 0xF656 Character_SendCharGenResult send, 0xF657
  Login_SendEnterWorld, 0xF643 CharGen response).
- `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/CharGen.generated.cs`
  and related `Types/HeritageGroupCG`, `TemplateCG`, `SexCG`, `HairStyleCG`,
  `FaceStripCG`, `EyeStripCG`, `GearCG`, `StartingArea`, `SkillCG` (this
  is the authoritative on-disk schema of `0xE000002 CharGen`).
- `references/ACE/Source/ACE.Entity/CharacterCreateInfo.cs`,
  `Appearance.cs`, `Enum/HeritageGroup.cs`,
  `Enum/SkillAdvancementClass.cs`,
  `Server/Network/Handlers/CharacterHandler.cs`,
  `Server/Factories/PlayerFactory.cs` (server expectations + skill-credit
  math).
- `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/C2S/Character_SendCharGenResult.generated.cs`,
  `Types/CharGenResult.generated.cs`,
  `Messages/S2C/Character_CharGenVerificationResponse.generated.cs`,
  `Enums/CharGenResponseType.generated.cs` (generated from protocol XML;
  canonical wire format).
- `references/holtburger/crates/holtburger-core/src/character_gen.rs`,
  `references/holtburger/crates/holtburger-content/src/character_gen.rs`,
  `references/holtburger/apps/holtburger-cli/src/pages/selection/creation.rs`
  (only reference client with a complete working chargen form — TUI).
- `references/DatReaderWriter/DatReaderWriter.Tests/DBObjs/CharGenTests.cs`
  (pinned EoR values — Aluvian has 52 skill credits, 7 templates, 2 genders).
- `references/AC2D/cNetwork.cpp` (char-create error code table, 0xF643
  response format).

**The anchor rule:** the dat-file `0xE000002 CharGen` record is the ground
truth for every numeric constant (attribute credits, skill credits,
template attribute values, per-heritage skill cost overrides, hair/eye/face
counts, starter-area positions). The client never hardcodes any of it —
everything is read from the dat and displayed. We do the same.

---

## 1. Heritage taxonomy

Retail AC ships with **7 canonical heritages** in the launch/Throne of
Destiny era (Aluvian, Gharu'ndim, Sho, Viamontian, Umbraen, Empyrean,
Undead). Master of Arms (2013) added additional heritages (Lugian,
Tumerok, Gear Knight), and Olthoi Play is retail's experiment — so the
dat actually has 13 entries. acdream targets End-of-Retail (EoR) and
must expose all 13.

**Canonical enum (`ACE.Entity.Enum.HeritageGroup`, matches dat key):**

| ID | Enum name         | Dat name      | Display (ToSentence)   | Era          |
|----|-------------------|---------------|------------------------|--------------|
| 0  | Invalid           | (unused)      | —                      | —            |
| 1  | Aluvian           | Aluvian       | Aluvian                | Launch       |
| 2  | Gharundim         | Gharu'ndim    | Gharu'ndim             | Launch       |
| 3  | Sho               | Sho           | Sho                    | Launch       |
| 4  | Viamontian        | Viamontian    | Viamontian             | Throne of D. |
| 5  | Shadowbound       | Umbraen       | Umbraen                | Darktide     |
| 6  | Gearknight        | Gearknight    | Gearknight             | Master of A. |
| 7  | Tumerok           | Tumerok       | Tumerok                | Master of A. |
| 8  | Lugian            | Lugian        | Lugian                 | Master of A. |
| 9  | Empyrean          | Empyrean      | Empyrean               | Master of A. |
| 10 | Penumbraen        | Penumbraen    | Penumbraen             | Darktide     |
| 11 | Undead            | Undead        | Undead                 | Halloween    |
| 12 | Olthoi            | Olthoi        | Olthoi                 | Special      |
| 13 | OlthoiAcid        | OlthoiAcid    | Olthoi (acid)          | Special      |

**Decompiled confirmation.** `FUN_0047b278` in chunk_00470000.c switches
on heritage id 1..13 (0xb, 0xc, 0xd included) to pick the random name list
(`ID_CharGen_AluMaleNames`, `ID_CharGen_GharuFemaleNames`, …,
`ID_CharGen_OlthoiMaleNames`). Case 5 and case 10 both fall through to the
"Shad" strings — the retail client treats Umbraen and Penumbraen as the
same visual heritage with one shared name list but distinct dat entries
(different body scale / base palette).

**Per-heritage data comes from the dat.** Each `HeritageGroupCG` carries:

- `Name` — English label ("Aluvian", "Gharu'ndim", …).
- `IconId` — `0x06000xxx` RenderSurface for the UI portrait.
- `SetupId` — body model used to build the preview character.
- `EnvironmentSetupId` — backdrop environment (pedestal / room) rendered
  behind the preview character during chargen.
- `AttributeCredits` — budget for the 6 attributes (see §3).
- `SkillCredits` — starting skill credit pool (see §4); **52 for all
  standard heritages, 68 for Olthoi** per `PlayerFactory.cs` comment.
- `PrimaryStartAreas` / `SecondaryStartAreas` — lists of `StartingArea`
  indices (1-based? 0-based? — the client stores them as `List<int>`;
  a value like 1 for Aluvian is the Holtburg index in `StartingAreas`).
- `Skills` — list of `SkillCG { Id, NormalCost, PrimaryCost }`
  **overrides** that replace the default `SkillBase.TrainedCost` /
  `SkillBase.SpecializedCost` for this heritage. The Aluvian test pins
  `ArcaneLore` to `NormalCost=0, PrimaryCost=2` — this is how
  heritage-specific "free" or "cheap" skills are encoded.
- `Templates` — list of `TemplateCG` (see §2). The Aluvian test pins
  7 templates; the last has STR=100, COORD=100, 4 primary skills.
- `Genders` — hash table `1 → Male`, `2 → Female` of `SexCG`.

**Attribute credits are per-heritage.** Not one constant. Every heritage
declares its own pool. Retail values (verified via DatReaderWriter tests
and ACE's factory code):

- Most heritages: **330** attribute credits (spread across 6 attributes
  each valued 10–100; see §3).
- Olthoi variants: different budget; read from dat, do not hardcode.

**Starting skill credits:** 52 for most, 68 for Olthoi. Read from dat.

---

## 2. Template system

Each `HeritageGroupCG.Templates` entry is a **pre-baked class archetype**:
Swordsman, Sorcerer, Archer, Custom, etc. The dat stores:

```csharp
public partial class TemplateCG : IDatObjType {
    public PStringBase<byte> Name;          // "Swordsman", "Custom", ...
    public QualifiedDataId<RenderSurface> IconId;
    public uint Title;                      // Title.TitleID granted on create
    public int Strength, Endurance, Coordination, Quickness, Focus, Self;
    public List<SkillId> NormalSkills;      // auto-trained
    public List<SkillId> PrimarySkills;     // auto-specialized
}
```

**How templates work in retail:**

1. The user picks a heritage → the Templates list for that heritage is
   shown.
2. Picking a template pre-fills:
   - the 6 attribute stat values (e.g., Aluvian "Swordsman" gives
     STR=70, COORD=60, …),
   - the skill advancement table: every SkillId in `PrimarySkills` gets
     `SkillAdvancementClass.Specialized`, every SkillId in
     `NormalSkills` gets `Trained`, everything else starts `Untrained`,
   - the character's starting title (`Title` dword).
3. The user can then modify either — change template = reset both.
4. There's always a "Custom" template (the last entry in some heritages,
   the first in others) with `Strength = Endurance = … = 10` and empty
   primary/normal lists. Picking Custom lets the user distribute all
   attribute/skill credits freely.

**Holtburger discovery** (`custom_template_for_heritage` in
`holtburger-core/src/character_gen.rs`): the client locates the "Custom"
template by name-matching "Custom" case-insensitively, with fallback to
`template_option == 0`, then finally the first entry. acdream must do
the same — retail data is not strictly ordered.

**Attribute pre-fill semantics.** `TemplateCG.Strength` is the **starting
value** for that attribute — not an addition. The rest of the
`AttributeCredits - (STR + END + COORD + QCK + FOC + SELF)` is available
for the user to distribute further. For the Aluvian "tank" template with
STR=70, that leaves `330 - sum` credits for additional spending.

**The template_option sent on the wire** (`CharGenResult.TemplateNum`)
is the 0-based index into `HeritageGroupCG.Templates`. The server uses
this to look up the granted title via `heritage.Templates[template].Title`
(see `PlayerFactory.cs:138 player.AddTitle(heritageGroup.Templates[...].Title, true)`).

---

## 3. Attribute point budget

**Range per attribute:** each of Strength, Endurance, Coordination,
Quickness, Focus, Self is clamped to **10..100 inclusive**. Enforced both
client-side and by `PlayerFactory.ValidateAttributeCredits` server-side:

```csharp
// PlayerFactory.cs:622
if (attributeValue < 10 || attributeValue > 100)
    return CreateResult.InvalidSkillRequested;
```

**Total budget:** `HeritageGroupCG.AttributeCredits` (typically 330).

```csharp
// PlayerFactory.cs:628
if (total > maxAttributes)
    return CreateResult.TooManySkillCreditsUsed;
```

**Note that ACE allows total < budget** (underspend). Retail's client
does not — the UI forces the user to spend the whole budget before the
"Create" button enables. Our UI must match retail: block submit until
the counter reads 0 remaining. holtburger implements this via a strict
`AttributeBudgetIncomplete` validation error
(`holtburger-core/character_gen.rs:343-348`).

**Minimum footprint:** 6 × 10 = 60 points, leaving 270 to distribute
for a standard 330 budget.

**Holtburger constants to port verbatim:**

```csharp
public const uint CharGenMinAttribute = 10;
public const uint CharGenMaxAttribute = 100;
```

---

## 4. Skill credit economy

This is where chargen has real depth. **Ultrathink required:** get the
spec/trained combo math wrong and the whole system silently misreports.

### 4.1 Credit budget

- `HeritageGroupCG.SkillCredits` = starting pool (52 for most, 68 for
  Olthoi).
- The pool is expressed as `PropertyInt.AvailableSkillCredits` =
  `PropertyInt.TotalSkillCredits` on the created Player.

### 4.2 Per-skill costs

Each skill in the dat has a `SkillBase`:

```csharp
public partial class SkillBase : IDatObjType {
    public int TrainedCost;           // cost to go Untrained → Trained
    public int SpecializedCost;       // cost to go Trained → Specialized
    public SkillCategory Category;
    public bool ChargenUse;           // if false, can't train at chargen
    public uint MinLevel;             // gate for late-game skills
    public SkillFormula Formula;      // attribute weighting
    public double UpperBound, LowerBound, LearnMod;
}
```

**Retail pitfall (the one we must get right):** `TrainedCost` is the
credit cost to train from Untrained. `SpecializedCost` is the **ADDITIONAL**
cost to go from Trained to Specialized. To **specialize from scratch** you
pay `TrainedCost + SpecializedCost`. The field is a delta, not a total.

ACE's factory proves this (`PlayerFactory.cs:199-202`):

```csharp
if (sac == SkillAdvancementClass.Specialized)
{
    if (!player.TrainSkill((Skill)i, trainedCost))          // pay train first
        return CreateResult.FailedToTrainSkill;
    if (!player.SpecializeSkill((Skill)i, specializedCost)) // then pay spec delta
        return CreateResult.FailedToSpecializeSkill;
}
```

holtburger's accumulator proves it too
(`holtburger-core/character_gen.rs:395-400`):

```rust
SkillAdvancementClass::Trained     => spent += trained_cost,
SkillAdvancementClass::Specialized => spent += trained_cost + specialized_cost,
```

### 4.3 Heritage overrides

`HeritageGroupCG.Skills` (list of `SkillCG`) **overrides** the base
costs for specific skills. Aluvian's EoR override: ArcaneLore
`{NormalCost=0, PrimaryCost=2}` — Aluvians can train ArcaneLore for
free and specialize it for just 2 credits, reflecting their magic
affinity. The override replaces (not adds to) the base. This is why
Aluvian mages get built.

Port sequence (matches ACE `PlayerFactory.cs:184-195`):

```csharp
int trainedCost = skill.TrainedCost;
int specializedCost = skill.UpgradeCostFromTrainedToSpecialized;
foreach (var skillGroup in heritageGroup.Skills) {
    if (skillGroup.SkillNum == i) {
        trainedCost    = skillGroup.NormalCost;
        specializedCost = skillGroup.PrimaryCost;
        break;
    }
}
```

### 4.4 Unavailable skills

Some skills have `ChargenUse = false` (late-game or retired skills:
Salvaging, void magic at first, etc.). The UI must hide these from
the chargen picker. The server also gates on `ChargenUse`:

```csharp
if (!DatManager.PortalDat.SkillTable.SkillBaseHash.ContainsKey(i))
    return CreateResult.InvalidSkillRequested;
```

holtburger adds a safety: **cost >= 999 is a sentinel for "cannot raise
at chargen"** (`CHARACTER_GEN_UNAVAILABLE_SKILL_COST = 999`). Retail uses
high cost values on skills that weren't meant to be taken. We adopt the
same sentinel check to refuse to enable the "up" button when the next
tier cost >= 999.

### 4.5 SkillAdvancementClass values

```csharp
public enum SkillAdvancementClass : uint {
    Inactive,      // no credits spent, not in player's skill list
    Untrained,     // 0 credits, visible but unskilled
    Trained,       // TrainedCost credits spent
    Specialized    // TrainedCost + SpecializedCost credits spent
}
```

`Inactive` vs `Untrained` distinction: `Untrained` means "in the skill
list but you haven't trained it" — the skill still has a trainable
future. `Inactive` is the server-side "don't even show this row" state
for skills that don't exist for this character's era. The wire message
sends one enum value per slot in the 55-skill array (55 is the EoR
skill count — checked explicitly by
`PlayerFactory.cs:166 if (characterCreateInfo.SkillAdvancementClasses.Count != 55) return ClientServerSkillsMismatch`).

### 4.6 Cannot despec

Retail rule: once specialized at chargen, always specialized. The client
UI offers "lower skill" but the server-side state machine will reject
despec after the character is saved. At chargen it's purely UI —
holtburger's `lower_selected_skill` allows stepping back down from
Specialized to Trained to Untrained (modulo template minimums) while
still on the chargen panel, because no server state exists yet.

**Template minimums block despec below the template floor.** If a
template sets `NormalSkills = [Sword]`, the chargen form forbids
untraining Sword. Even the Custom template has effective minimums if
`TrainedCost == 0` for a skill in the heritage override (Aluvian
ArcaneLore is effectively free-trained → minimum is Trained, not
Untrained). See `minimum_skill_advancement_for_template` in
holtburger-core.

---

## 5. Skill categories / UI grouping

The chargen summary panel (`FUN_0047b590` in chunk_00470000.c) groups
skills into four rows:

| Dat flag | Summary label            | UI color        |
|----------|--------------------------|-----------------|
| 3        | "Specialized Skills"     | yellow          |
| 2        | "Trained Skills"         | green / white   |
| 1        | "Useable Untrained Skills"   | grey (Usable) |
| 1        | "Unuseable Untrained Skills" | dark grey    |

"Useable Untrained" means `SkillBase.Category` allows casting/using
with a penalty; "Unuseable" means the skill has `Untrained = cannot use
at all`. ACE stores this via `SkillCategory` — reflects whether a
skill can be attempted with zero points. The UI has to know this to
draw the two separate untrained buckets.

---

## 6. Appearance system

Per heritage + gender, the dat carries a full `SexCG`:

```csharp
public partial class SexCG : IDatObjType {
    public PStringBase<byte> Name;           // "Male" or "Female"
    public uint Scale;                       // 100 = default, <100 = smaller
    public QualifiedDataId<Setup> SetupId;   // body model
    public QualifiedDataId<SoundTable> SoundTable;
    public QualifiedDataId<RenderSurface> IconId;
    public QualifiedDataId<Palette> BasePalette;
    public QualifiedDataId<PalSet> SkinPalSet;     // skin hue gradient
    public QualifiedDataId<PhysicsScriptTable> PhysicsTable;
    public QualifiedDataId<MotionTable> MotionTable;
    public QualifiedDataId<CombatTable> CombatTable;
    public ObjDesc BaseObjDesc;
    public List<uint> HairColors;            // uint = PalSet id
    public List<HairStyleCG> HairStyles;
    public List<uint> EyeColors;             // uint = Palette id
    public List<EyeStripCG> EyeStrips;       // face eye variant
    public List<FaceStripCG> NoseStrips;
    public List<FaceStripCG> MouthStrips;
    public List<GearCG> Headgears;
    public List<GearCG> Shirts;
    public List<GearCG> Pants;
    public List<GearCG> Footwear;
    public List<uint> ClothingColors;        // uint = PalSet id
}
```

### 6.1 Hair / eyes / face texture selection

- `HairStyleCG` holds `{ IconId, Bald bool, AlternateSetup uint, ObjDesc }`.
  The `ObjDesc` carries texture overrides. `Bald` is a flag that selects
  a different `EyeStripCG` variant (`BaldObjDesc`) — eyes look different
  against a bald scalp.
- `AlternateSetup` is used by Gear Knight / Olthoi: their "hair style"
  is really a whole-body model swap because they have no hair. When
  `AlternateSetup > 0` the server sets `PropertyDataId.Setup` to that
  alternate — switching the model for the creature (see
  `PlayerFactory.cs:74-75`).
- `EyeStripCG` carries `IconId, BaldIconId, ObjDesc, BaldObjDesc`.
  The chargen UI picks `BaldObjDesc` when `HairStyleCG.Bald == true`.
- `FaceStripCG` (used for noses, mouths) is simpler: `IconId + ObjDesc`.

### 6.2 Colors (palette sets)

`HairColors` and `ClothingColors` are lists of `PalSet` ids. A PalSet
is a **gradient** of palettes — selecting a hair color gives you a
base (e.g., "red"), and the **hue** slider (`HairHue` 0.0..1.0 double)
picks a specific palette from that gradient. Server-side:

```csharp
// PlayerFactory.cs:96-97
var hairPalSet = DatManager.PortalDat.ReadFromDat<PaletteSet>(
    sex.HairColorList[(int)characterCreateInfo.Appearance.HairColor]);
player.SetProperty(PropertyDataId.HairPalette,
    hairPalSet.GetPaletteID(characterCreateInfo.Appearance.HairHue));
```

- `Appearance.HairColor` (uint): which PalSet (red vs brown vs blonde).
- `Appearance.HairHue` (double, 0.0..1.0): where in the gradient.
- Same pattern for `SkinHue`, `ShirtHue`, `PantsHue`, `HeadgearHue`,
  `FootwearHue`.

`EyeColors` is a `List<uint>` of `Palette` (not PalSet) ids — eyes have
no hue slider, just a discrete choice.

### 6.3 Gender-gated options

Beards, body shape, and armor-cut are all resolved via distinct
`SexCG` records per (heritage, gender). There is no explicit "has beard"
flag — a male Aluvian simply has different `HairStyleCG` entries than
female. Hair styles for male Aluvian include beards as facial hair
decals on the head ObjDesc.

### 6.4 Headgear is optional

`Appearance.HeadgearStyle == 0xFFFFFFFF` (uint::MaxValue) is the
"no hat" sentinel. Every other appearance index must be valid
(< options.Length). holtburger's `validate_optional_index` handles this
— if the sentinel is sent, skip validation; else require in-range.

### 6.5 Randomization

The client has a "Random Appearance" button. Decompilation shows
`FUN_004e9720` with `ID_CharGen_RandomizeWarning` prompt — it shows a
confirmation dialog before clobbering the player's manual appearance
tweaks. When confirmed, every index gets a uniform random in
`[0, options.Length)`, hues get uniform `[0, 1)`, headgear has ~50%
chance of `0xFFFFFFFF`. Port holtburger's `randomize_appearance`
directly (`holtburger-core/character_gen.rs:195-242`).

### 6.6 Gear Knight / Olthoi special handling

Gear Knight has no hair palette, no clothing (body is a full suit).
`PlayerFactory.cs:105-106`:

```csharp
if (player.Heritage != (int)HeritageGroup.Gearknight)
    // ... apply hat/shirt/pants/shoes
```

Olthoi has `IsOlthoiPlayer == true` → weenie is `olthoiplayer` (not
`human`), entire appearance block skipped. Port plan: guard clothing
application on `heritage != Gearknight && !heritage.IsOlthoi()`.

---

## 7. Starting town selection

`CharGen.StartingAreas` is a 5-entry list in EoR data:

| Index | Name       | Heritages (primary)                           |
|-------|------------|-----------------------------------------------|
| 0     | Holtburg   | Aluvian                                       |
| 1     | Shoushi    | Sho, Gharu'ndim                               |
| 2     | Yaraq      | Gharu'ndim, Sho (secondary)                   |
| 3     | Sanamar    | Viamontian, Umbraen, Empyrean, Undead, etc.   |
| 4     | OlthoiLair | Olthoi / OlthoiAcid only                      |

Each `StartingArea` has `{ Name, List<Position> Locations }`. Multiple
locations per town are used for randomized spawn jitter. ACE picks
`starterArea.Locations[0]` always (`PlayerFactory.cs:360`) — retail
may or may not randomize. acdream can start with `[0]` and revisit.

**Decompiled confirmation.** `FUN_0047c5a0` in chunk_00470000.c takes
the selected town id 1..4 and renders the flavor text string
(`ID_CharGen_HoltText`, `ID_CharGen_ShoushiText`,
`ID_CharGen_YaraqText`, `ID_CharGen_SanamarText`). Note the client uses
1-based town IDs internally, while the dat list is 0-indexed. The UI
layer maps `uiTownId → datIndex = uiTownId - 1`; the wire message uses
the 0-based `start_area`. This 1-vs-0 gotcha lives in the UI state, not
the dat.

**Per-heritage validation.** `HeritageGroupCG.PrimaryStartAreas` lists
the allowed indices. The UI cycles through allowed options only.
`SecondaryStartAreas` is currently unused by retail but the dat
reserves it. holtburger combines both lists into a single allowed set.

**Post-login Free Ride.** After character creation, the server casts
a "Free Ride to Holtburg / Shoushi / Yaraq / Sanamar" spell to teleport
the player to the Dereth Coast Tutorial. The initial `player.Location`
is the tutorial, and `player.Instantiation` is the town itself
(`PlayerFactory.cs:360-389`). acdream's first-login flow does not
care — we just receive the player's actual spawn location in the
PlayerCreate message. Noted here because the "starting town" the
player picks is NOT the first position they see; it's where they
recall to after the tutorial.

---

## 8. Name validation

### 8.1 Client-side (retail)

From chunk_00470000.c:
- `ID_CharGen_NameTooLong` string ← `FUN_0047c140` checks length and
  pops a modal if the typed name exceeds the limit. The limit is the
  `PStringBase<byte>` max for character names: **20 characters** per
  retail convention (hardcoded in the UI text field's char limit).
- The client filters the IME/keyboard input to only accept latin
  alphanumeric plus apostrophe and hyphen. Whitespace is trimmed
  on submit. Uppercase/lowercase preserved.

### 8.2 Server-side (ACE)

1. **Empty name** → rejected (holtburger enforces
   `require_nonempty_name = true` by default).
2. **Taboo table match** → `CharacterGenerationVerificationResponse.NameBanned`.
   ACE reads `DatManager.PortalDat.TabooTable` and checks
   `ContainsBadWord(name.ToLowerInvariant())` (`CharacterHandler.cs:51`).
   The taboo table is a dat file (profanity + reserved-name list).
3. **Creature name conflict** → also `NameBanned`
   (`CharacterHandler.cs:57` — optional rule, default on).
4. **Name already in use** → `NameInUse`
   (`CharacterHandler.cs:63, 149`).
5. **Admin/Sentinel name override** → server prefixes with `+` for
   accounts with elevated access levels
   (`CharacterHandler.cs:374-377`).

### 8.3 Case normalization

Case is preserved as typed. "Borelean" and "BORELEAN" are different
strings to the DB; typically only one is accepted first, the other
triggers `NameInUse`. Server compares case-insensitively for
availability but persists the typed casing.

---

## 9. CharCreate wire message layout (0xF656)

This is the **only** bit the client controls. The client sends a single
message, C2S opcode `0xF656` (`Character_SendCharGenResult`). Generated
schema from `Chorizite.ACProtocol/Types/CharGenResult.generated.cs`:

### 9.1 Header

```
UINT32  OpCode          = 0xF656
```

Followed by outer message envelope (from `Messages/C2S/Character_SendCharGenResult.generated.cs`):

```
string16L  Account                  // wide-prefixed length, UTF-16 LE
CharGenResult Result                // the inline body described below
```

### 9.2 CharGenResult body

Reading in order (little-endian, no padding unless noted):

```
string16L  Account                  // duplicated account name — ACE discards
UINT32     One                      // always 1 — "unknown constant"
BYTE       HeritageGroup            // 1..13 (HeritageGroup enum)
BYTE       Gender                   // 1=Male, 2=Female
UINT32     EyesStrip                // index into SexCG.EyeStrips
UINT32     NoseStrip                // index into SexCG.NoseStrips
UINT32     MouthStrip               // index into SexCG.MouthStrips
UINT32     HairColor                // index into SexCG.HairColors (PalSet)
UINT32     EyeColor                 // index into SexCG.EyeColors (Palette)
UINT32     HairStyle                // index into SexCG.HairStyles
UINT32     HeadgearStyle            // 0xFFFFFFFF = no hat
UINT32     HeadgearColor            // index into SexCG.ClothingColors
UINT32     ShirtStyle
UINT32     ShirtColor
UINT32     TrousersStyle
UINT32     TrousersColor
UINT32     FootwearStyle
UINT32     FootwearColor
UINT64     SkinShade                // 8 bytes = IEEE 754 double, 0.0..1.0
UINT64     HairShade
UINT64     HeadgearShade
UINT64     ShirtShade
UINT64     TrousersShade
UINT64     TootwearShade            // (sic — typo preserved in generator)
UINT32     TemplateNum              // 0-based index into HeritageGroup.Templates
UINT32     Strength                 // 10..100
UINT32     Endurance
UINT32     Coordination
UINT32     Quickness
UINT32     Focus
UINT32     Self
UINT32     Slot                     // character slot on the server (0..max-1)
UINT32     ClassId                  // always 1 — legacy
PackableList<UINT32> Skills         // 55-entry list, each = SkillAdvancementClass
                                    // PackableList = compressed-uint count + items
string16L  Name
UINT32     StartArea                // 0-based index into CharGen.StartingAreas
UINT32     IsAdmin                  // 0 or 1
UINT32     IsEnvoy                  // 0 or 1 (aka IsSentinel on ACE side)
UINT32     Validation               // xor/sum checksum, see §9.3
```

**ACE reads this slightly differently** (`CharacterCreateInfo.cs`)
— the "Unknown constant (1)" is read as a UINT32, and the heritage /
gender are read as UINT32 each instead of bytes. **These two formats
must match one of them byte-for-byte.** The Chorizite generator is
authoritative because it's generated from the protocol XML used by
both retail and ACE. ACE's `BinaryReader.ReadUInt32()` for fields the
XML declares as BYTE actually works because they're the first 4 bytes
of a little-endian dword with the other 3 bytes being part of the next
field — but the ACE code then advances 4 bytes, misaligning if the
sizes were really bytes. **Trust Chorizite.**

In practice: there's a **known discrepancy** between Chorizite and ACE
on heritage/gender width. Empirical pcaps from retail server will
resolve it. Until then, acdream should send matching ACE's format
(UINT32 each) because ACE is the target server. Add a switchable
serializer.

### 9.3 Validation checksum

The `Validation` field is described by the protocol XML as:

> "Seems to be the total of heritageGroup, gender, eyesStrip,
> noseStrip, mouthStrip, hairColor, eyeColor, hairStyle,
> headgearStyle, shirtStyle, trousersStyle, footwearStyle,
> templateNum, strength, endurance, coordination, quickness, focus,
> self. Perhaps used for some kind of validation?"

ACE **ignores** this field — it's client-side anti-tamper. Retail
client summed specific fields (no shade doubles, no colors except hair/eye,
no slot, no name, no skills) — probably to catch byte-twiddling
hacks. acdream computes the same sum for bug-for-bug parity with the
live retail server (ACE doesn't care; retail-emu or GDL may).

Exact formula (from XML description order):

```
validation = heritageGroup + gender
           + eyesStrip + noseStrip + mouthStrip
           + hairColor + eyeColor + hairStyle
           + headgearStyle + shirtStyle + trousersStyle + footwearStyle
           + templateNum
           + strength + endurance + coordination + quickness + focus + self
```

All as u32 wrapping add; overflow discarded.

---

## 10. Server response (0xF643)

S2C `Character_CharGenVerificationResponse`. Schema from Chorizite:

```
UINT32     OpCode                  = 0xF643
UINT32     ResponseType            (CharGenResponseType)
  if ResponseType == OK (1):
    UINT32     CharacterId
    string16L  Name
    UINT32     SecondsUntilDeletion
    align(4)
```

`CharGenResponseType` values (note gap):

```csharp
public enum CharGenResponseType : uint {
    OK                  = 0x0001,
    NameInUse           = 0x0003,
    NameBanned          = 0x0004,
    Corrupt             = 0x0005,   // character data rejected
    Corrupt_0x0006      = 0x0006,   // "DatabaseDown" aka "try again later"
    AdminPrivilegeDenied = 0x0007,
}
```

ACE's `CharacterGenerationVerificationResponse` enum has extra values
(`Undef=0`, `Pending=2`, plus `Count`) that don't appear on the wire —
they're for server-internal bookkeeping. Only the 0x0001..0x0007 values
go out.

AC2D confirms the error messages (`cNetwork.cpp:1433`):
- 0x03 "The name you have chosen … is already in use"
- 0x04 "Sorry, but that name is not permitted."
- 0x05 "… found an unexplained error with this new character. The data
  may be corrupt or out of date."
- 0x06 "… cannot create your new character at this time. Please try
  again later."
- 0x07 "Sorry, but you do not have the privileges to make an
  administrator character."

On success the server **also** pushes a refreshed character list
(S2C `Character_CharacterList`, opcode varies) so the new character
appears in the slot selection. The client should not refetch on its
own — it waits for the server's push.

---

## 11. Preview renderer

During chargen the client renders the character on a rotating pedestal
in a small 3D viewport inset. Key observations from the decompiled
CharGen panel ctor (`FUN_0047aa10` at 0x0047AA10):

- The ctor wires up a scene using `FUN_0045a910(puStack_4, 0, 0, 1)` —
  this is the panel's scene graph container with flags indicating a
  character preview.
- It stores an `FUN_0043c680` device pointer and asks for viewport
  region registration (`0x186a1`, `0x186a2` → region IDs for the
  character window and a child area).
- A 4000ms timer is registered: `(**(code**)(*DAT_00837ff4 + 0x34))
  (0xe, param_1 + 2, 4000)`. This is the **preview rotation timer** —
  ticks every 4 seconds to update the character's Y-axis rotation by
  a small delta (retail rotates slowly, ~15°/sec observed).

Implementation plan (**ported, not invented**):

1. **Scene setup.** When the panel activates with (heritage, gender),
   read `HeritageGroupCG.EnvironmentSetupId` from dat → this is the
   pedestal / backdrop Setup. Instantiate it at the origin.
2. **Character model.** Read `SexCG.SetupId` (with `HairStyleCG.AlternateSetup`
   override if non-zero). Build the ObjDesc from `SexCG.BaseObjDesc`
   overlaid with `HairStyleCG.ObjDesc`, `EyeStripCG.ObjDesc` (bald or
   standard), `FaceStripCG.ObjDesc` (nose), `FaceStripCG.ObjDesc`
   (mouth). Apply palette/hue overrides same as a world player.
   Place at origin offset slightly above pedestal.
3. **Camera.** Fixed orbit at ~2m radius, ~1.5m eye height, looking
   at character head. Retail camera does NOT pan or zoom during
   chargen — only the character rotates.
4. **Rotation driver.** Per-frame Y rotation using `Time.Delta *
   rotationSpeed`. Retail speed is ~0.26 rad/sec (15°/sec). The 4000ms
   timer is not for rotation continuity (that's per-frame) — it's
   probably a "full revolution" tick for state inspection.
5. **Lighting.** One directional key-light from camera-right-up, a
   fill from camera-left. Ambient ~0.3. This matches retail's
   flat-but-readable lighting in the chargen inset.

---

## 12. Chat commands vs UI

**Retail is UI-only for chargen.** There are no `/heritage` or
`/template` chat commands. The only text the user types is the
character's name. All other selection is click-driven.

**What acdream should add (not retail):**

- The plugin API lives above the CharGen state machine. A plugin could
  set the state programmatically (useful for "clone this character's
  appearance" tools), but the stock UI is click-driven for parity.
- For QA we should support a `--chargen-template=aluvian:swordsman`
  command-line flag that pre-fills the form for rapid iteration.

---

## 13. Full flow timeline

```
1. User clicks "Create new character" in charlist panel.
2. CharGenPanel opens (FUN_0047aa10 ctor):
   - read CharGen dat (0xE000002) → StartingAreas + HeritageGroups.
   - default heritage = Aluvian (id 1).
   - default gender = Male (id 1).
   - default template = first template (index 0).
   - default start town = heritage.PrimaryStartAreas[0].
   - pre-fill attributes from template.
   - pre-fill skills from template (primary = Spec, normal = Trained).
3. User iterates on UI:
   - heritage cycle → reload templates, reset start town, reset
     attributes + skills.
   - gender cycle → reload appearance option lists.
   - template cycle → reset attributes + skills.
   - attribute +/- → update remaining_attribute_points.
   - skill raise/lower → update remaining_skill_credits.
   - appearance tweaks → rebuild preview ObjDesc.
   - random appearance button → confirm dialog (ID_CharGen_RandomizeWarning),
     then reroll all indices + hues.
   - name field → each keystroke, check length ≤ 20 (client-side).
4. User clicks "Create":
   - client-side validation: all budgets spent, name non-empty,
     name length ≤ 20.
   - build CharGenResult struct, compute Validation checksum.
   - send 0xF656 Character_SendCharGenResult with account + result.
5. Server responds 0xF643:
   - OK → push refreshed CharacterList, close CharGenPanel, return to
     slot selection.
   - NameInUse / NameBanned → show modal with ID_CharGen_Name{InUse,Banned}
     string, stay on CharGenPanel with name field re-focused.
   - Corrupt / Corrupt_0x0006 → show modal "character data is corrupt,
     please regenerate", reset form.
   - AdminPrivilegeDenied → modal "you do not have privileges",
     un-check admin box.
6. On OK, user selects new char in list → 0xF657 Login_SendEnterWorld
   → enter world.
```

---

## 14. acdream port plan (C# .NET 10)

### 14.1 Dat layer (already in DatReaderWriter, consume via NuGet-style link)

No port needed — the generated `CharGen`, `HeritageGroupCG`,
`TemplateCG`, `SexCG`, `StartingArea`, `SkillCG`, `HairStyleCG`,
`FaceStripCG`, `EyeStripCG`, `GearCG` classes are already part of the
`references/DatReaderWriter` project, which acdream can reference
directly.

### 14.2 Content layer (`Acdream.Game.CharGen`)

Mirror holtburger-content's flatten step. Reads the raw dat types and
produces snapshot objects friendlier for UI binding:

```csharp
namespace Acdream.Game.CharGen;

public sealed class CharGenCatalog
{
    public IReadOnlyList<StartingArea> StartingAreas { get; }
    public IReadOnlyDictionary<uint, Heritage> Heritages { get; }
    public IReadOnlyDictionary<uint, SkillDefinition> Skills { get; }
    public int ExpectedSkillSlots { get; }          // 55 for EoR

    public static CharGenCatalog FromDat(CharGen charGen, SkillTable skillTable);
    public Heritage? GetHeritage(uint id);
    public StartingArea? GetStartingArea(uint id);
    public SkillCosts? GetSkillCosts(uint heritageId, uint skillId);
    public IReadOnlyList<uint> AllowedStartAreaIds(uint heritageId);
}

public sealed class Heritage
{
    public uint Id { get; }
    public string Name { get; }                     // "Aluvian"
    public uint IconId { get; }
    public uint SetupId { get; }
    public uint EnvironmentSetupId { get; }         // backdrop scene
    public uint AttributeCredits { get; }           // 330, Olthoi=more
    public uint SkillCredits { get; }               // 52, Olthoi=68
    public IReadOnlyList<int> PrimaryStartAreas { get; }
    public IReadOnlyList<int> SecondaryStartAreas { get; }
    public IReadOnlyDictionary<uint, SkillCostsOverride> SkillOverrides { get; }
    public IReadOnlyList<Template> Templates { get; }
    public IReadOnlyDictionary<uint, GenderAppearance> Genders { get; }
}

public sealed class Template
{
    public int Option { get; }                      // index
    public string Name { get; }                     // "Swordsman", "Custom"
    public uint IconId { get; }
    public uint TitleId { get; }
    public int Strength, Endurance, Coordination, Quickness, Focus, Self;
    public IReadOnlyList<uint> NormalSkills { get; }      // → Trained at start
    public IReadOnlyList<uint> PrimarySkills { get; }     // → Specialized
}

public sealed class GenderAppearance
{
    public uint Id { get; }                         // 1=Male, 2=Female
    public string Name { get; }
    public uint Scale { get; }
    public uint SetupId { get; }
    public uint SoundTableId { get; }
    public uint MotionTableId { get; }
    public uint CombatTableId { get; }
    public uint PhysicsTableId { get; }
    public uint BasePaletteId { get; }
    public uint SkinPalSetId { get; }
    public ObjDesc BaseObjDesc { get; }
    public IReadOnlyList<uint> HairColorPalSets { get; }
    public IReadOnlyList<HairStyle> HairStyles { get; }
    public IReadOnlyList<uint> EyeColorPalettes { get; }
    public IReadOnlyList<EyeStrip> EyeStrips { get; }
    public IReadOnlyList<FaceStrip> NoseStrips { get; }
    public IReadOnlyList<FaceStrip> MouthStrips { get; }
    public IReadOnlyList<Gear> Headgears { get; }
    public IReadOnlyList<Gear> Shirts { get; }
    public IReadOnlyList<Gear> Pants { get; }
    public IReadOnlyList<Gear> Footwear { get; }
    public IReadOnlyList<uint> ClothingColorPalSets { get; }
}
```

### 14.3 Builder / validator (`Acdream.Game.CharGen.Builder`)

Port of holtburger-core's `CharacterGenBuilder`. Collects errors during
validation (does not throw on first), matches the 4 retail client-side
checks AND ACE's 3 server-side extras:

```csharp
public enum CharGenValidationError
{
    EmptyName,
    NameTooLong,            // > 20 chars
    DisabledHeritage,
    UnknownHeritage,
    UnknownGender,
    InvalidTemplateOption,
    InvalidStartArea,
    StartAreaNotAllowedForHeritage,
    AttributeOutOfRange,
    AttributeBudgetExceeded,
    AttributeBudgetIncomplete,
    SkillSlotCountMismatch,
    UnknownSkill,
    SkillUnavailableAtCharacterCreation,
    SkillBudgetExceeded,
    AppearanceChoiceOutOfRange,
    AdminFlagNotAllowed,
    SentinelFlagNotAllowed,
}

public sealed class CharGenBuilder
{
    public CharGenBuilder(CharGenCatalog catalog, CharGenPolicy policy = null);

    public IReadOnlyList<CharGenValidationError> Validate(CharGenBuild build);
    public CharCreateRequest BuildRequest(CharGenBuild build);
    public AppearanceSelection RandomizeAppearance(uint heritageId, uint genderId);
}

public sealed record CharGenPolicy(
    uint UnknownConstant = 1,
    uint ClassId = 1,
    bool AllowAdminFlag = false,
    bool AllowSentinelFlag = false,
    ISet<uint> DisabledHeritages = null,
    bool EnforceHeritageStartAreaMembership = false,
    bool RequireNonEmptyName = true,
    int MaxNameLength = 20);
```

Constants:

```csharp
public static class CharGenConstants
{
    public const uint MinAttribute = 10;
    public const uint MaxAttribute = 100;
    public const int UnavailableSkillCostSentinel = 999;
    public const int ExpectedSkillSlotsEor = 55;
}
```

### 14.4 Wire model (`Acdream.Network.Messages`)

Port the Chorizite generator output directly — it is already idiomatic
C#. `CharCreateRequest` = `CharGenResult`. Write as:

```csharp
public sealed class CharCreateRequest
{
    public string Account { get; set; }
    public uint UnknownConstant { get; set; } = 1;
    public HeritageGroup Heritage { get; set; }
    public uint Gender { get; set; }                    // 1 or 2
    public AppearanceSelection Appearance { get; set; }
    public int TemplateOption { get; set; }
    public uint Strength, Endurance, Coordination, Quickness, Focus, Self;
    public uint Slot { get; set; }
    public uint ClassId { get; set; } = 1;
    public IReadOnlyList<SkillAdvancementClass> SkillAdvancementClasses { get; set; }
    public string Name { get; set; }
    public uint StartArea { get; set; }
    public bool IsAdmin { get; set; }
    public bool IsSentinel { get; set; }

    public uint ComputeValidation() => unchecked(
        (uint)Heritage + Gender
        + Appearance.Eyes + Appearance.Nose + Appearance.Mouth
        + Appearance.HairColor + Appearance.EyeColor + Appearance.HairStyle
        + Appearance.HeadgearStyle + Appearance.ShirtStyle
        + Appearance.PantsStyle + Appearance.FootwearStyle
        + (uint)TemplateOption
        + Strength + Endurance + Coordination + Quickness + Focus + Self);

    public void Serialize(BinaryWriter w)
    {
        // matches CharGenResult.Write in Chorizite
        w.WriteString16L(Account);
        w.Write(UnknownConstant);
        w.Write((byte)Heritage);            // 1 byte per Chorizite
        w.Write((byte)Gender);
        Appearance.Serialize(w);
        w.Write(TemplateOption);
        w.Write(Strength); w.Write(Endurance); w.Write(Coordination);
        w.Write(Quickness); w.Write(Focus); w.Write(Self);
        w.Write(Slot); w.Write(ClassId);
        w.WritePackableList(SkillAdvancementClasses, (bw, sac) => bw.Write((uint)sac));
        w.WriteString16L(Name);
        w.Write(StartArea);
        w.Write(IsAdmin ? 1u : 0u);
        w.Write(IsSentinel ? 1u : 0u);
        w.Write(ComputeValidation());
    }
}

public sealed class AppearanceSelection
{
    public uint Eyes, Nose, Mouth;
    public uint HairColor, EyeColor, HairStyle;
    public uint HeadgearStyle = 0xFFFFFFFF;             // no-hat sentinel
    public uint HeadgearColor;
    public uint ShirtStyle, ShirtColor;
    public uint PantsStyle, PantsColor;
    public uint FootwearStyle, FootwearColor;
    public double SkinHue, HairHue, HeadgearHue;
    public double ShirtHue, PantsHue, FootwearHue;

    public void Serialize(BinaryWriter w)
    {
        w.Write(Eyes); w.Write(Nose); w.Write(Mouth);
        w.Write(HairColor); w.Write(EyeColor); w.Write(HairStyle);
        w.Write(HeadgearStyle); w.Write(HeadgearColor);
        w.Write(ShirtStyle);    w.Write(ShirtColor);
        w.Write(PantsStyle);    w.Write(PantsColor);
        w.Write(FootwearStyle); w.Write(FootwearColor);
        w.Write(SkinHue);       w.Write(HairHue);
        w.Write(HeadgearHue);   w.Write(ShirtHue);
        w.Write(PantsHue);      w.Write(FootwearHue);
    }
}
```

**ACE compatibility note:** if the target server is ACE, switch
heritage/gender to UINT32 each. Feature-flag via `CharGenPolicy`. Keep
both serializers and pick one at build time.

### 14.5 Panel (`Acdream.UI.Panels.CharGen`)

Uses our UI framework (see `docs/plans/` retail UI deep-dive). The
panel owns a `CharGenFormState` (mirror of holtburger's
`CharacterCreationFormState`) with fields:

```csharp
public sealed class CharGenFormState
{
    public uint HeritageId;
    public uint GenderId;
    public uint StartAreaId;
    public int TemplateOption;
    public uint[] AttributeValues = new uint[6];
    public SkillAdvancementClass[] SkillAdvancementClasses;
    public AppearanceSelection Appearance;
    public string Name = "";

    public uint AttributeBudget { get; }
    public long RemainingAttributePoints { get; }
    public long SkillBudget { get; }
    public long SpentSkillPoints { get; }
    public long RemainingSkillPoints { get; }

    public void ResetForHeritage();
    public void ResetForTemplate(int templateOption);
    public bool CycleHeritage(int delta);
    public bool AdjustAttribute(int index, int delta);
    public bool RaiseSkill(uint skillId);
    public bool LowerSkill(uint skillId);
    public bool SetAppearance(AppearanceField field, uint value);
    public bool Randomize();
}

public enum AppearanceField {
    Eyes, Nose, Mouth,
    HairColor, HairStyle, EyeColor,
    HeadgearStyle, HeadgearColor, HeadgearHue,
    ShirtStyle, ShirtColor, ShirtHue,
    PantsStyle, PantsColor, PantsHue,
    FootwearStyle, FootwearColor, FootwearHue,
    SkinHue, HairHue,
}
```

Sub-panels (all children of `CharGenPanel`):

1. `HeritageSelector` — grid of heritage icons with tooltips.
2. `GenderSelector` — two buttons.
3. `TemplateSelector` — scrolling list of template icons.
4. `AttributeEditor` — 6 rows, `<` `value` `>` with running total.
5. `SkillEditor` — 4 groups (Specialized, Trained, Usable Untrained,
   Unusable Untrained) with per-skill up/down, grouped by category.
6. `AppearancePicker` — tabbed face/body/hair/clothing with
   per-selector arrows.
7. `NameField` — 20-char input with client-side length check.
8. `StartTownSelector` — 1..4 buttons with flavor-text panel.
9. `SummaryPanel` — mirrors `FUN_0047b590` output (profession, gender,
   heritage, town, 10-row attributes/vitals, 4-group skill list).
10. `PreviewRenderer` — inset 3D viewport (see §11).
11. `ActionButtons` — Random, Back, Create.

Each sub-panel emits domain events (`HeritageChanged`,
`AttributeAdjusted`) that the root panel routes into
`CharGenFormState`. The `CreateButton.Enabled` is computed from
`FormState.Validate(catalog).IsEmpty`.

### 14.6 Integration with plugin API

```csharp
public interface ICharGen
{
    CharGenCatalog Catalog { get; }
    CharGenFormState Form { get; }      // live form state
    event Action<CharGenFormState> FormChanged;
    Task<CharGenResponseType> SubmitAsync(CancellationToken ct);
}
```

A plugin can inspect the current form, autofill it
(`Form.HeritageId = 1; Form.Name = "Auto";` etc.), and call
`SubmitAsync()` programmatically. This is a non-retail capability by
design — the plugin surface is the net-new thing acdream provides.

---

## 15. Port order within R7

Suggested order (each step ships behind a feature flag, visually
verified before moving on):

1. **Dat reads.** Unit tests for `CharGenCatalog.FromDat` on the EoR
   `client_portal.dat`. Assert Aluvian skill credits == 52, 7
   templates, ArcaneLore override. Pin all 13 heritage names. Pin
   all 5 starting area names.
2. **Validator.** Port holtburger's validator + tests. Include the
   "skill delta cost" math and the "template minimum" check.
3. **Wire serializer.** Round-trip `CharCreateRequest` bytes against
   Chorizite's generator on synthetic inputs. Verify the Validation
   checksum against an ACE packet capture if available; otherwise
   pin a golden value per the formula in §9.3.
4. **Form state.** TUI-style CLI (like holtburger-cli) first — no
   graphics, just stdin/stdout. Prove the form-state + validator
   flow end-to-end.
5. **UI panels.** Build each sub-panel. The preview renderer is the
   last piece; stub it with a placeholder sprite until the character
   ObjDesc pipeline from R4/R5 is ready.
6. **Server round-trip.** Connect to ACE, go through login, press
   Create, handle OK response, refresh charlist. Visual verification
   = "new character appears in slot selector".
7. **Error handling.** Force each error path (name too long, name in
   use by creating the same name twice, disabled heritage via
   policy). Verify modal text matches retail strings.

---

## 16. Open questions

- **Heritage/gender byte vs dword on the wire.** Chorizite says byte
  each, ACE says dword each. A live pcap against retail-era server
  would resolve this immediately. For now: go dword to match ACE.
- **Name max length.** 20 chars is retail convention but not
  documented in the dat. Verify from pcap; treat as tunable in
  `CharGenPolicy.MaxNameLength`.
- **Free Ride spell on first login.** Out of scope for R7 (R8
  world-entry territory), but noted: server sends a teleport spell
  after CharacterCreate, so the first spawn-in may show a different
  location than the chosen starting town. Do not chase this as a
  bug during R7 testing.
- **Olthoi play gating.** ACE has `olthoi_play_disabled` property;
  retail had Olthoi play as a special event. acdream default should
  be "Olthoi heritage hidden unless server announces support" —
  plumb via a server capability flag later.
- **Preview background environment.** `EnvironmentSetupId` per
  heritage — need to decode what these actually are (a setup is a
  model tree; the chargen backdrop is "an Empyrean plinth for
  Empyreans, a stone circle for Aluvians," etc.). Confirm by
  instantiating each one and visually checking in R4 renderer.

---

## 17. Summary for the parent agent

acdream's R7 character creation is a thin C# state machine over the
dat file `0xE000002 CharGen`. The retail client does **nothing
interesting** except read the dat, present it on-screen, validate the
user's picks client-side, and send a single 0xF656
`Character_SendCharGenResult` packet with the distilled choices.
All constants — attribute budget 330, skill budget 52/68,
attribute range 10–100, skill costs, heritage-specific overrides,
appearance option counts, starting town positions — live in the dat.

Port plan: dat → `CharGenCatalog` → `CharGenBuilder` (validator +
builder) → `CharGenFormState` (UI model) → panels + preview renderer.
The validator is the one place correctness matters (skill credit
accumulation including heritage overrides and template minimums);
everything else is data-driven UI.

holtburger-core's `character_gen.rs` is the closest working
reference; port it idiom-for-idiom to C# and adapt it to our UI
framework. ACE's `PlayerFactory.cs` and `CharacterCreateInfo.cs` are
the server-side counterpart — trust them for expected field widths
and validation semantics.

---

*Last updated: 2026-04-18 (R7 deep-dive for parallel research pass)*
