acdream/docs/research/deepdives/r07-character-creation.md
Erik 3f913f1999 docs+feat: 13 retail-AC deep-dives (R1-R13) + C# port scaffolds + roadmap E-H
78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.

Research (docs/research/deepdives/):
- 00-master-synthesis.md          (navigation hub + dependency graph)
- r01-spell-system.md        5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md       5.9K words (damage formula, crit, body table)
- r03-motion-animation.md    8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md       5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md         5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md     7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md  6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md       7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md          5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md    4.5K words (deterministic client-side)
- r13-dynamic-lighting.md    4.9K words (8-light cap, hard Range cutoff)

Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.

Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).

C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs    — ItemType/EquipMask enums, ItemInstance,
                             Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs      — SpellDatEntry, SpellComponentEntry,
                             SpellCastStateMachine, ActiveBuff,
                             SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs     — CombatMode/AttackType/DamageType/BodyPart,
                             DamageEvent record, CombatMath (hit-chance
                             sigmoids, power/accuracy mods, damage formula),
                             ArmorBuild
- Audio/AudioModel.cs       — SoundId enum, SoundEntry, WaveData,
                             IAudioEngine / ISoundCache contracts,
                             AudioFalloff (inverse-square)
- Vfx/VfxModel.cs           — 13 ParticleType integrators, EmitterDesc,
                             PhysicsScript + hooks, Particle struct,
                             ParticleEmitter, IParticleSystem contract

All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.

Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
            combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)

Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
2026-04-18 10:32:44 +02:00

1210 lines
50 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.

# 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 10100; 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 10100, 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)*