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.
50 KiB
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_0047aa10is the CharGen panel ctor,FUN_0047b590is the summary redraw,FUN_0047c5a0is the starting-town description render,FUN_0047c140is 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.csand relatedTypes/HeritageGroupCG,TemplateCG,SexCG,HairStyleCG,FaceStripCG,EyeStripCG,GearCG,StartingArea,SkillCG(this is the authoritative on-disk schema of0xE000002 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—0x06000xxxRenderSurface 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 perPlayerFactory.cscomment.PrimaryStartAreas/SecondaryStartAreas— lists ofStartingAreaindices (1-based? 0-based? — the client stores them asList<int>; a value like 1 for Aluvian is the Holtburg index inStartingAreas).Skills— list ofSkillCG { Id, NormalCost, PrimaryCost }overrides that replace the defaultSkillBase.TrainedCost/SkillBase.SpecializedCostfor this heritage. The Aluvian test pinsArcaneLoretoNormalCost=0, PrimaryCost=2— this is how heritage-specific "free" or "cheap" skills are encoded.Templates— list ofTemplateCG(see §2). The Aluvian test pins 7 templates; the last has STR=100, COORD=100, 4 primary skills.Genders— hash table1 → Male,2 → FemaleofSexCG.
Attribute credits are per-heritage. Not one constant. Every heritage declares its own pool. Retail values (verified via DatReaderWriter tests and ACE's factory code):
- Most heritages: 330 attribute credits (spread across 6 attributes each valued 10–100; see §3).
- Olthoi variants: different budget; read from dat, do not hardcode.
Starting skill credits: 52 for most, 68 for Olthoi. Read from dat.
2. Template system
Each HeritageGroupCG.Templates entry is a pre-baked class archetype:
Swordsman, Sorcerer, Archer, Custom, etc. The dat stores:
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:
- The user picks a heritage → the Templates list for that heritage is shown.
- 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
PrimarySkillsgetsSkillAdvancementClass.Specialized, every SkillId inNormalSkillsgetsTrained, everything else startsUntrained, - the character's starting title (
Titledword).
- The user can then modify either — change template = reset both.
- There's always a "Custom" template (the last entry in some heritages,
the first in others) with
Strength = Endurance = … = 10and 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.TotalSkillCreditson 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
HairStyleCGholds{ IconId, Bald bool, AlternateSetup uint, ObjDesc }. TheObjDesccarries texture overrides.Baldis a flag that selects a differentEyeStripCGvariant (BaldObjDesc) — eyes look different against a bald scalp.AlternateSetupis used by Gear Knight / Olthoi: their "hair style" is really a whole-body model swap because they have no hair. WhenAlternateSetup > 0the server setsPropertyDataId.Setupto that alternate — switching the model for the creature (seePlayerFactory.cs:74-75).EyeStripCGcarriesIconId, BaldIconId, ObjDesc, BaldObjDesc. The chargen UI picksBaldObjDescwhenHairStyleCG.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_NameTooLongstring ←FUN_0047c140checks length and pops a modal if the typed name exceeds the limit. The limit is thePStringBase<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)
- Empty name → rejected (holtburger enforces
require_nonempty_name = trueby default). - Taboo table match →
CharacterGenerationVerificationResponse.NameBanned. ACE readsDatManager.PortalDat.TabooTableand checksContainsBadWord(name.ToLowerInvariant())(CharacterHandler.cs:51). The taboo table is a dat file (profanity + reserved-name list). - Creature name conflict → also
NameBanned(CharacterHandler.cs:57— optional rule, default on). - Name already in use →
NameInUse(CharacterHandler.cs:63, 149). - 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_0043c680device 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):
- Scene setup. When the panel activates with (heritage, gender),
read
HeritageGroupCG.EnvironmentSetupIdfrom dat → this is the pedestal / backdrop Setup. Instantiate it at the origin. - Character model. Read
SexCG.SetupId(withHairStyleCG.AlternateSetupoverride if non-zero). Build the ObjDesc fromSexCG.BaseObjDescoverlaid withHairStyleCG.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. - 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.
- 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. - 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:swordsmancommand-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:
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):
HeritageSelector— grid of heritage icons with tooltips.GenderSelector— two buttons.TemplateSelector— scrolling list of template icons.AttributeEditor— 6 rows,<value>with running total.SkillEditor— 4 groups (Specialized, Trained, Usable Untrained, Unusable Untrained) with per-skill up/down, grouped by category.AppearancePicker— tabbed face/body/hair/clothing with per-selector arrows.NameField— 20-char input with client-side length check.StartTownSelector— 1..4 buttons with flavor-text panel.SummaryPanel— mirrorsFUN_0047b590output (profession, gender, heritage, town, 10-row attributes/vitals, 4-group skill list).PreviewRenderer— inset 3D viewport (see §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):
- Dat reads. Unit tests for
CharGenCatalog.FromDaton the EoRclient_portal.dat. Assert Aluvian skill credits == 52, 7 templates, ArcaneLore override. Pin all 13 heritage names. Pin all 5 starting area names. - Validator. Port holtburger's validator + tests. Include the "skill delta cost" math and the "template minimum" check.
- Wire serializer. Round-trip
CharCreateRequestbytes 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. - Form state. TUI-style CLI (like holtburger-cli) first — no graphics, just stdin/stdout. Prove the form-state + validator flow end-to-end.
- 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.
- Server round-trip. Connect to ACE, go through login, press Create, handle OK response, refresh charlist. Visual verification = "new character appears in slot selector".
- 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_disabledproperty; 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.
EnvironmentSetupIdper heritage — need to decode what these actually are (a setup is a model tree; the chargen backdrop is "an Empyrean plinth for Empyreans, a stone circle for Aluvians," etc.). Confirm by instantiating each one and visually checking in R4 renderer.
17. Summary for the parent agent
acdream's R7 character creation is a thin C# state machine over the
dat file 0xE000002 CharGen. The retail client does nothing
interesting except read the dat, present it on-screen, validate the
user's picks client-side, and send a single 0xF656
Character_SendCharGenResult packet with the distilled choices.
All constants — attribute budget 330, skill budget 52/68,
attribute range 10–100, skill costs, heritage-specific overrides,
appearance option counts, starting town positions — live in the dat.
Port plan: dat → CharGenCatalog → CharGenBuilder (validator +
builder) → CharGenFormState (UI model) → panels + preview renderer.
The validator is the one place correctness matters (skill credit
accumulation including heritage overrides and template minimums);
everything else is data-driven UI.
holtburger-core's character_gen.rs is the closest working
reference; port it idiom-for-idiom to C# and adapt it to our UI
framework. ACE's PlayerFactory.cs and CharacterCreateInfo.cs are
the server-side counterpart — trust them for expected field widths
and validation semantics.
Last updated: 2026-04-18 (R7 deep-dive for parallel research pass)