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

50 KiB
Raw Permalink Blame History

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", …).
  • IconId0x06000xxx 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:

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:

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

Total budget: HeritageGroupCG.AttributeCredits (typically 330).

// 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:

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:

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):

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):

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):

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:

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

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:

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:

// 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:

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 matchCharacterGenerationVerificationResponse.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 useNameInUse (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):

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)

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:

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:

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:

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:

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:

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

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 → CharGenCatalogCharGenBuilder (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)