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.
1210 lines
50 KiB
Markdown
1210 lines
50 KiB
Markdown
# R7 — Character Creation (deep dive)
|
||
|
||
Research slice for acdream's retail-faithful character-creation pipeline:
|
||
heritage taxonomy, templates, appearance, skill-credit economy, starting
|
||
town, wire format, preview renderer, port plan.
|
||
|
||
**Inputs surveyed:**
|
||
- `docs/research/decompiled/chunk_00470000.c` (CharGen UI panel, strings,
|
||
state machine; `FUN_0047aa10` is the CharGen panel ctor,
|
||
`FUN_0047b590` is the summary redraw, `FUN_0047c5a0` is the starting-town
|
||
description render, `FUN_0047c140` is the name-too-long dialog).
|
||
- `docs/research/decompiled/chunk_00480000.c`, `chunk_00540000.c`
|
||
(opcode dispatch — 0xF656 Character_SendCharGenResult send, 0xF657
|
||
Login_SendEnterWorld, 0xF643 CharGen response).
|
||
- `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/CharGen.generated.cs`
|
||
and related `Types/HeritageGroupCG`, `TemplateCG`, `SexCG`, `HairStyleCG`,
|
||
`FaceStripCG`, `EyeStripCG`, `GearCG`, `StartingArea`, `SkillCG` (this
|
||
is the authoritative on-disk schema of `0xE000002 CharGen`).
|
||
- `references/ACE/Source/ACE.Entity/CharacterCreateInfo.cs`,
|
||
`Appearance.cs`, `Enum/HeritageGroup.cs`,
|
||
`Enum/SkillAdvancementClass.cs`,
|
||
`Server/Network/Handlers/CharacterHandler.cs`,
|
||
`Server/Factories/PlayerFactory.cs` (server expectations + skill-credit
|
||
math).
|
||
- `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/C2S/Character_SendCharGenResult.generated.cs`,
|
||
`Types/CharGenResult.generated.cs`,
|
||
`Messages/S2C/Character_CharGenVerificationResponse.generated.cs`,
|
||
`Enums/CharGenResponseType.generated.cs` (generated from protocol XML;
|
||
canonical wire format).
|
||
- `references/holtburger/crates/holtburger-core/src/character_gen.rs`,
|
||
`references/holtburger/crates/holtburger-content/src/character_gen.rs`,
|
||
`references/holtburger/apps/holtburger-cli/src/pages/selection/creation.rs`
|
||
(only reference client with a complete working chargen form — TUI).
|
||
- `references/DatReaderWriter/DatReaderWriter.Tests/DBObjs/CharGenTests.cs`
|
||
(pinned EoR values — Aluvian has 52 skill credits, 7 templates, 2 genders).
|
||
- `references/AC2D/cNetwork.cpp` (char-create error code table, 0xF643
|
||
response format).
|
||
|
||
**The anchor rule:** the dat-file `0xE000002 CharGen` record is the ground
|
||
truth for every numeric constant (attribute credits, skill credits,
|
||
template attribute values, per-heritage skill cost overrides, hair/eye/face
|
||
counts, starter-area positions). The client never hardcodes any of it —
|
||
everything is read from the dat and displayed. We do the same.
|
||
|
||
---
|
||
|
||
## 1. Heritage taxonomy
|
||
|
||
Retail AC ships with **7 canonical heritages** in the launch/Throne of
|
||
Destiny era (Aluvian, Gharu'ndim, Sho, Viamontian, Umbraen, Empyrean,
|
||
Undead). Master of Arms (2013) added additional heritages (Lugian,
|
||
Tumerok, Gear Knight), and Olthoi Play is retail's experiment — so the
|
||
dat actually has 13 entries. acdream targets End-of-Retail (EoR) and
|
||
must expose all 13.
|
||
|
||
**Canonical enum (`ACE.Entity.Enum.HeritageGroup`, matches dat key):**
|
||
|
||
| ID | Enum name | Dat name | Display (ToSentence) | Era |
|
||
|----|-------------------|---------------|------------------------|--------------|
|
||
| 0 | Invalid | (unused) | — | — |
|
||
| 1 | Aluvian | Aluvian | Aluvian | Launch |
|
||
| 2 | Gharundim | Gharu'ndim | Gharu'ndim | Launch |
|
||
| 3 | Sho | Sho | Sho | Launch |
|
||
| 4 | Viamontian | Viamontian | Viamontian | Throne of D. |
|
||
| 5 | Shadowbound | Umbraen | Umbraen | Darktide |
|
||
| 6 | Gearknight | Gearknight | Gearknight | Master of A. |
|
||
| 7 | Tumerok | Tumerok | Tumerok | Master of A. |
|
||
| 8 | Lugian | Lugian | Lugian | Master of A. |
|
||
| 9 | Empyrean | Empyrean | Empyrean | Master of A. |
|
||
| 10 | Penumbraen | Penumbraen | Penumbraen | Darktide |
|
||
| 11 | Undead | Undead | Undead | Halloween |
|
||
| 12 | Olthoi | Olthoi | Olthoi | Special |
|
||
| 13 | OlthoiAcid | OlthoiAcid | Olthoi (acid) | Special |
|
||
|
||
**Decompiled confirmation.** `FUN_0047b278` in chunk_00470000.c switches
|
||
on heritage id 1..13 (0xb, 0xc, 0xd included) to pick the random name list
|
||
(`ID_CharGen_AluMaleNames`, `ID_CharGen_GharuFemaleNames`, …,
|
||
`ID_CharGen_OlthoiMaleNames`). Case 5 and case 10 both fall through to the
|
||
"Shad" strings — the retail client treats Umbraen and Penumbraen as the
|
||
same visual heritage with one shared name list but distinct dat entries
|
||
(different body scale / base palette).
|
||
|
||
**Per-heritage data comes from the dat.** Each `HeritageGroupCG` carries:
|
||
|
||
- `Name` — English label ("Aluvian", "Gharu'ndim", …).
|
||
- `IconId` — `0x06000xxx` RenderSurface for the UI portrait.
|
||
- `SetupId` — body model used to build the preview character.
|
||
- `EnvironmentSetupId` — backdrop environment (pedestal / room) rendered
|
||
behind the preview character during chargen.
|
||
- `AttributeCredits` — budget for the 6 attributes (see §3).
|
||
- `SkillCredits` — starting skill credit pool (see §4); **52 for all
|
||
standard heritages, 68 for Olthoi** per `PlayerFactory.cs` comment.
|
||
- `PrimaryStartAreas` / `SecondaryStartAreas` — lists of `StartingArea`
|
||
indices (1-based? 0-based? — the client stores them as `List<int>`;
|
||
a value like 1 for Aluvian is the Holtburg index in `StartingAreas`).
|
||
- `Skills` — list of `SkillCG { Id, NormalCost, PrimaryCost }`
|
||
**overrides** that replace the default `SkillBase.TrainedCost` /
|
||
`SkillBase.SpecializedCost` for this heritage. The Aluvian test pins
|
||
`ArcaneLore` to `NormalCost=0, PrimaryCost=2` — this is how
|
||
heritage-specific "free" or "cheap" skills are encoded.
|
||
- `Templates` — list of `TemplateCG` (see §2). The Aluvian test pins
|
||
7 templates; the last has STR=100, COORD=100, 4 primary skills.
|
||
- `Genders` — hash table `1 → Male`, `2 → Female` of `SexCG`.
|
||
|
||
**Attribute credits are per-heritage.** Not one constant. Every heritage
|
||
declares its own pool. Retail values (verified via DatReaderWriter tests
|
||
and ACE's factory code):
|
||
|
||
- Most heritages: **330** attribute credits (spread across 6 attributes
|
||
each valued 10–100; see §3).
|
||
- Olthoi variants: different budget; read from dat, do not hardcode.
|
||
|
||
**Starting skill credits:** 52 for most, 68 for Olthoi. Read from dat.
|
||
|
||
---
|
||
|
||
## 2. Template system
|
||
|
||
Each `HeritageGroupCG.Templates` entry is a **pre-baked class archetype**:
|
||
Swordsman, Sorcerer, Archer, Custom, etc. The dat stores:
|
||
|
||
```csharp
|
||
public partial class TemplateCG : IDatObjType {
|
||
public PStringBase<byte> Name; // "Swordsman", "Custom", ...
|
||
public QualifiedDataId<RenderSurface> IconId;
|
||
public uint Title; // Title.TitleID granted on create
|
||
public int Strength, Endurance, Coordination, Quickness, Focus, Self;
|
||
public List<SkillId> NormalSkills; // auto-trained
|
||
public List<SkillId> PrimarySkills; // auto-specialized
|
||
}
|
||
```
|
||
|
||
**How templates work in retail:**
|
||
|
||
1. The user picks a heritage → the Templates list for that heritage is
|
||
shown.
|
||
2. Picking a template pre-fills:
|
||
- the 6 attribute stat values (e.g., Aluvian "Swordsman" gives
|
||
STR=70, COORD=60, …),
|
||
- the skill advancement table: every SkillId in `PrimarySkills` gets
|
||
`SkillAdvancementClass.Specialized`, every SkillId in
|
||
`NormalSkills` gets `Trained`, everything else starts `Untrained`,
|
||
- the character's starting title (`Title` dword).
|
||
3. The user can then modify either — change template = reset both.
|
||
4. There's always a "Custom" template (the last entry in some heritages,
|
||
the first in others) with `Strength = Endurance = … = 10` and empty
|
||
primary/normal lists. Picking Custom lets the user distribute all
|
||
attribute/skill credits freely.
|
||
|
||
**Holtburger discovery** (`custom_template_for_heritage` in
|
||
`holtburger-core/src/character_gen.rs`): the client locates the "Custom"
|
||
template by name-matching "Custom" case-insensitively, with fallback to
|
||
`template_option == 0`, then finally the first entry. acdream must do
|
||
the same — retail data is not strictly ordered.
|
||
|
||
**Attribute pre-fill semantics.** `TemplateCG.Strength` is the **starting
|
||
value** for that attribute — not an addition. The rest of the
|
||
`AttributeCredits - (STR + END + COORD + QCK + FOC + SELF)` is available
|
||
for the user to distribute further. For the Aluvian "tank" template with
|
||
STR=70, that leaves `330 - sum` credits for additional spending.
|
||
|
||
**The template_option sent on the wire** (`CharGenResult.TemplateNum`)
|
||
is the 0-based index into `HeritageGroupCG.Templates`. The server uses
|
||
this to look up the granted title via `heritage.Templates[template].Title`
|
||
(see `PlayerFactory.cs:138 player.AddTitle(heritageGroup.Templates[...].Title, true)`).
|
||
|
||
---
|
||
|
||
## 3. Attribute point budget
|
||
|
||
**Range per attribute:** each of Strength, Endurance, Coordination,
|
||
Quickness, Focus, Self is clamped to **10..100 inclusive**. Enforced both
|
||
client-side and by `PlayerFactory.ValidateAttributeCredits` server-side:
|
||
|
||
```csharp
|
||
// PlayerFactory.cs:622
|
||
if (attributeValue < 10 || attributeValue > 100)
|
||
return CreateResult.InvalidSkillRequested;
|
||
```
|
||
|
||
**Total budget:** `HeritageGroupCG.AttributeCredits` (typically 330).
|
||
|
||
```csharp
|
||
// PlayerFactory.cs:628
|
||
if (total > maxAttributes)
|
||
return CreateResult.TooManySkillCreditsUsed;
|
||
```
|
||
|
||
**Note that ACE allows total < budget** (underspend). Retail's client
|
||
does not — the UI forces the user to spend the whole budget before the
|
||
"Create" button enables. Our UI must match retail: block submit until
|
||
the counter reads 0 remaining. holtburger implements this via a strict
|
||
`AttributeBudgetIncomplete` validation error
|
||
(`holtburger-core/character_gen.rs:343-348`).
|
||
|
||
**Minimum footprint:** 6 × 10 = 60 points, leaving 270 to distribute
|
||
for a standard 330 budget.
|
||
|
||
**Holtburger constants to port verbatim:**
|
||
|
||
```csharp
|
||
public const uint CharGenMinAttribute = 10;
|
||
public const uint CharGenMaxAttribute = 100;
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Skill credit economy
|
||
|
||
This is where chargen has real depth. **Ultrathink required:** get the
|
||
spec/trained combo math wrong and the whole system silently misreports.
|
||
|
||
### 4.1 Credit budget
|
||
|
||
- `HeritageGroupCG.SkillCredits` = starting pool (52 for most, 68 for
|
||
Olthoi).
|
||
- The pool is expressed as `PropertyInt.AvailableSkillCredits` =
|
||
`PropertyInt.TotalSkillCredits` on the created Player.
|
||
|
||
### 4.2 Per-skill costs
|
||
|
||
Each skill in the dat has a `SkillBase`:
|
||
|
||
```csharp
|
||
public partial class SkillBase : IDatObjType {
|
||
public int TrainedCost; // cost to go Untrained → Trained
|
||
public int SpecializedCost; // cost to go Trained → Specialized
|
||
public SkillCategory Category;
|
||
public bool ChargenUse; // if false, can't train at chargen
|
||
public uint MinLevel; // gate for late-game skills
|
||
public SkillFormula Formula; // attribute weighting
|
||
public double UpperBound, LowerBound, LearnMod;
|
||
}
|
||
```
|
||
|
||
**Retail pitfall (the one we must get right):** `TrainedCost` is the
|
||
credit cost to train from Untrained. `SpecializedCost` is the **ADDITIONAL**
|
||
cost to go from Trained to Specialized. To **specialize from scratch** you
|
||
pay `TrainedCost + SpecializedCost`. The field is a delta, not a total.
|
||
|
||
ACE's factory proves this (`PlayerFactory.cs:199-202`):
|
||
|
||
```csharp
|
||
if (sac == SkillAdvancementClass.Specialized)
|
||
{
|
||
if (!player.TrainSkill((Skill)i, trainedCost)) // pay train first
|
||
return CreateResult.FailedToTrainSkill;
|
||
if (!player.SpecializeSkill((Skill)i, specializedCost)) // then pay spec delta
|
||
return CreateResult.FailedToSpecializeSkill;
|
||
}
|
||
```
|
||
|
||
holtburger's accumulator proves it too
|
||
(`holtburger-core/character_gen.rs:395-400`):
|
||
|
||
```rust
|
||
SkillAdvancementClass::Trained => spent += trained_cost,
|
||
SkillAdvancementClass::Specialized => spent += trained_cost + specialized_cost,
|
||
```
|
||
|
||
### 4.3 Heritage overrides
|
||
|
||
`HeritageGroupCG.Skills` (list of `SkillCG`) **overrides** the base
|
||
costs for specific skills. Aluvian's EoR override: ArcaneLore
|
||
`{NormalCost=0, PrimaryCost=2}` — Aluvians can train ArcaneLore for
|
||
free and specialize it for just 2 credits, reflecting their magic
|
||
affinity. The override replaces (not adds to) the base. This is why
|
||
Aluvian mages get built.
|
||
|
||
Port sequence (matches ACE `PlayerFactory.cs:184-195`):
|
||
|
||
```csharp
|
||
int trainedCost = skill.TrainedCost;
|
||
int specializedCost = skill.UpgradeCostFromTrainedToSpecialized;
|
||
foreach (var skillGroup in heritageGroup.Skills) {
|
||
if (skillGroup.SkillNum == i) {
|
||
trainedCost = skillGroup.NormalCost;
|
||
specializedCost = skillGroup.PrimaryCost;
|
||
break;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.4 Unavailable skills
|
||
|
||
Some skills have `ChargenUse = false` (late-game or retired skills:
|
||
Salvaging, void magic at first, etc.). The UI must hide these from
|
||
the chargen picker. The server also gates on `ChargenUse`:
|
||
|
||
```csharp
|
||
if (!DatManager.PortalDat.SkillTable.SkillBaseHash.ContainsKey(i))
|
||
return CreateResult.InvalidSkillRequested;
|
||
```
|
||
|
||
holtburger adds a safety: **cost >= 999 is a sentinel for "cannot raise
|
||
at chargen"** (`CHARACTER_GEN_UNAVAILABLE_SKILL_COST = 999`). Retail uses
|
||
high cost values on skills that weren't meant to be taken. We adopt the
|
||
same sentinel check to refuse to enable the "up" button when the next
|
||
tier cost >= 999.
|
||
|
||
### 4.5 SkillAdvancementClass values
|
||
|
||
```csharp
|
||
public enum SkillAdvancementClass : uint {
|
||
Inactive, // no credits spent, not in player's skill list
|
||
Untrained, // 0 credits, visible but unskilled
|
||
Trained, // TrainedCost credits spent
|
||
Specialized // TrainedCost + SpecializedCost credits spent
|
||
}
|
||
```
|
||
|
||
`Inactive` vs `Untrained` distinction: `Untrained` means "in the skill
|
||
list but you haven't trained it" — the skill still has a trainable
|
||
future. `Inactive` is the server-side "don't even show this row" state
|
||
for skills that don't exist for this character's era. The wire message
|
||
sends one enum value per slot in the 55-skill array (55 is the EoR
|
||
skill count — checked explicitly by
|
||
`PlayerFactory.cs:166 if (characterCreateInfo.SkillAdvancementClasses.Count != 55) return ClientServerSkillsMismatch`).
|
||
|
||
### 4.6 Cannot despec
|
||
|
||
Retail rule: once specialized at chargen, always specialized. The client
|
||
UI offers "lower skill" but the server-side state machine will reject
|
||
despec after the character is saved. At chargen it's purely UI —
|
||
holtburger's `lower_selected_skill` allows stepping back down from
|
||
Specialized to Trained to Untrained (modulo template minimums) while
|
||
still on the chargen panel, because no server state exists yet.
|
||
|
||
**Template minimums block despec below the template floor.** If a
|
||
template sets `NormalSkills = [Sword]`, the chargen form forbids
|
||
untraining Sword. Even the Custom template has effective minimums if
|
||
`TrainedCost == 0` for a skill in the heritage override (Aluvian
|
||
ArcaneLore is effectively free-trained → minimum is Trained, not
|
||
Untrained). See `minimum_skill_advancement_for_template` in
|
||
holtburger-core.
|
||
|
||
---
|
||
|
||
## 5. Skill categories / UI grouping
|
||
|
||
The chargen summary panel (`FUN_0047b590` in chunk_00470000.c) groups
|
||
skills into four rows:
|
||
|
||
| Dat flag | Summary label | UI color |
|
||
|----------|--------------------------|-----------------|
|
||
| 3 | "Specialized Skills" | yellow |
|
||
| 2 | "Trained Skills" | green / white |
|
||
| 1 | "Useable Untrained Skills" | grey (Usable) |
|
||
| 1 | "Unuseable Untrained Skills" | dark grey |
|
||
|
||
"Useable Untrained" means `SkillBase.Category` allows casting/using
|
||
with a penalty; "Unuseable" means the skill has `Untrained = cannot use
|
||
at all`. ACE stores this via `SkillCategory` — reflects whether a
|
||
skill can be attempted with zero points. The UI has to know this to
|
||
draw the two separate untrained buckets.
|
||
|
||
---
|
||
|
||
## 6. Appearance system
|
||
|
||
Per heritage + gender, the dat carries a full `SexCG`:
|
||
|
||
```csharp
|
||
public partial class SexCG : IDatObjType {
|
||
public PStringBase<byte> Name; // "Male" or "Female"
|
||
public uint Scale; // 100 = default, <100 = smaller
|
||
public QualifiedDataId<Setup> SetupId; // body model
|
||
public QualifiedDataId<SoundTable> SoundTable;
|
||
public QualifiedDataId<RenderSurface> IconId;
|
||
public QualifiedDataId<Palette> BasePalette;
|
||
public QualifiedDataId<PalSet> SkinPalSet; // skin hue gradient
|
||
public QualifiedDataId<PhysicsScriptTable> PhysicsTable;
|
||
public QualifiedDataId<MotionTable> MotionTable;
|
||
public QualifiedDataId<CombatTable> CombatTable;
|
||
public ObjDesc BaseObjDesc;
|
||
public List<uint> HairColors; // uint = PalSet id
|
||
public List<HairStyleCG> HairStyles;
|
||
public List<uint> EyeColors; // uint = Palette id
|
||
public List<EyeStripCG> EyeStrips; // face eye variant
|
||
public List<FaceStripCG> NoseStrips;
|
||
public List<FaceStripCG> MouthStrips;
|
||
public List<GearCG> Headgears;
|
||
public List<GearCG> Shirts;
|
||
public List<GearCG> Pants;
|
||
public List<GearCG> Footwear;
|
||
public List<uint> ClothingColors; // uint = PalSet id
|
||
}
|
||
```
|
||
|
||
### 6.1 Hair / eyes / face texture selection
|
||
|
||
- `HairStyleCG` holds `{ IconId, Bald bool, AlternateSetup uint, ObjDesc }`.
|
||
The `ObjDesc` carries texture overrides. `Bald` is a flag that selects
|
||
a different `EyeStripCG` variant (`BaldObjDesc`) — eyes look different
|
||
against a bald scalp.
|
||
- `AlternateSetup` is used by Gear Knight / Olthoi: their "hair style"
|
||
is really a whole-body model swap because they have no hair. When
|
||
`AlternateSetup > 0` the server sets `PropertyDataId.Setup` to that
|
||
alternate — switching the model for the creature (see
|
||
`PlayerFactory.cs:74-75`).
|
||
- `EyeStripCG` carries `IconId, BaldIconId, ObjDesc, BaldObjDesc`.
|
||
The chargen UI picks `BaldObjDesc` when `HairStyleCG.Bald == true`.
|
||
- `FaceStripCG` (used for noses, mouths) is simpler: `IconId + ObjDesc`.
|
||
|
||
### 6.2 Colors (palette sets)
|
||
|
||
`HairColors` and `ClothingColors` are lists of `PalSet` ids. A PalSet
|
||
is a **gradient** of palettes — selecting a hair color gives you a
|
||
base (e.g., "red"), and the **hue** slider (`HairHue` 0.0..1.0 double)
|
||
picks a specific palette from that gradient. Server-side:
|
||
|
||
```csharp
|
||
// PlayerFactory.cs:96-97
|
||
var hairPalSet = DatManager.PortalDat.ReadFromDat<PaletteSet>(
|
||
sex.HairColorList[(int)characterCreateInfo.Appearance.HairColor]);
|
||
player.SetProperty(PropertyDataId.HairPalette,
|
||
hairPalSet.GetPaletteID(characterCreateInfo.Appearance.HairHue));
|
||
```
|
||
|
||
- `Appearance.HairColor` (uint): which PalSet (red vs brown vs blonde).
|
||
- `Appearance.HairHue` (double, 0.0..1.0): where in the gradient.
|
||
- Same pattern for `SkinHue`, `ShirtHue`, `PantsHue`, `HeadgearHue`,
|
||
`FootwearHue`.
|
||
|
||
`EyeColors` is a `List<uint>` of `Palette` (not PalSet) ids — eyes have
|
||
no hue slider, just a discrete choice.
|
||
|
||
### 6.3 Gender-gated options
|
||
|
||
Beards, body shape, and armor-cut are all resolved via distinct
|
||
`SexCG` records per (heritage, gender). There is no explicit "has beard"
|
||
flag — a male Aluvian simply has different `HairStyleCG` entries than
|
||
female. Hair styles for male Aluvian include beards as facial hair
|
||
decals on the head ObjDesc.
|
||
|
||
### 6.4 Headgear is optional
|
||
|
||
`Appearance.HeadgearStyle == 0xFFFFFFFF` (uint::MaxValue) is the
|
||
"no hat" sentinel. Every other appearance index must be valid
|
||
(< options.Length). holtburger's `validate_optional_index` handles this
|
||
— if the sentinel is sent, skip validation; else require in-range.
|
||
|
||
### 6.5 Randomization
|
||
|
||
The client has a "Random Appearance" button. Decompilation shows
|
||
`FUN_004e9720` with `ID_CharGen_RandomizeWarning` prompt — it shows a
|
||
confirmation dialog before clobbering the player's manual appearance
|
||
tweaks. When confirmed, every index gets a uniform random in
|
||
`[0, options.Length)`, hues get uniform `[0, 1)`, headgear has ~50%
|
||
chance of `0xFFFFFFFF`. Port holtburger's `randomize_appearance`
|
||
directly (`holtburger-core/character_gen.rs:195-242`).
|
||
|
||
### 6.6 Gear Knight / Olthoi special handling
|
||
|
||
Gear Knight has no hair palette, no clothing (body is a full suit).
|
||
`PlayerFactory.cs:105-106`:
|
||
|
||
```csharp
|
||
if (player.Heritage != (int)HeritageGroup.Gearknight)
|
||
// ... apply hat/shirt/pants/shoes
|
||
```
|
||
|
||
Olthoi has `IsOlthoiPlayer == true` → weenie is `olthoiplayer` (not
|
||
`human`), entire appearance block skipped. Port plan: guard clothing
|
||
application on `heritage != Gearknight && !heritage.IsOlthoi()`.
|
||
|
||
---
|
||
|
||
## 7. Starting town selection
|
||
|
||
`CharGen.StartingAreas` is a 5-entry list in EoR data:
|
||
|
||
| Index | Name | Heritages (primary) |
|
||
|-------|------------|-----------------------------------------------|
|
||
| 0 | Holtburg | Aluvian |
|
||
| 1 | Shoushi | Sho, Gharu'ndim |
|
||
| 2 | Yaraq | Gharu'ndim, Sho (secondary) |
|
||
| 3 | Sanamar | Viamontian, Umbraen, Empyrean, Undead, etc. |
|
||
| 4 | OlthoiLair | Olthoi / OlthoiAcid only |
|
||
|
||
Each `StartingArea` has `{ Name, List<Position> Locations }`. Multiple
|
||
locations per town are used for randomized spawn jitter. ACE picks
|
||
`starterArea.Locations[0]` always (`PlayerFactory.cs:360`) — retail
|
||
may or may not randomize. acdream can start with `[0]` and revisit.
|
||
|
||
**Decompiled confirmation.** `FUN_0047c5a0` in chunk_00470000.c takes
|
||
the selected town id 1..4 and renders the flavor text string
|
||
(`ID_CharGen_HoltText`, `ID_CharGen_ShoushiText`,
|
||
`ID_CharGen_YaraqText`, `ID_CharGen_SanamarText`). Note the client uses
|
||
1-based town IDs internally, while the dat list is 0-indexed. The UI
|
||
layer maps `uiTownId → datIndex = uiTownId - 1`; the wire message uses
|
||
the 0-based `start_area`. This 1-vs-0 gotcha lives in the UI state, not
|
||
the dat.
|
||
|
||
**Per-heritage validation.** `HeritageGroupCG.PrimaryStartAreas` lists
|
||
the allowed indices. The UI cycles through allowed options only.
|
||
`SecondaryStartAreas` is currently unused by retail but the dat
|
||
reserves it. holtburger combines both lists into a single allowed set.
|
||
|
||
**Post-login Free Ride.** After character creation, the server casts
|
||
a "Free Ride to Holtburg / Shoushi / Yaraq / Sanamar" spell to teleport
|
||
the player to the Dereth Coast Tutorial. The initial `player.Location`
|
||
is the tutorial, and `player.Instantiation` is the town itself
|
||
(`PlayerFactory.cs:360-389`). acdream's first-login flow does not
|
||
care — we just receive the player's actual spawn location in the
|
||
PlayerCreate message. Noted here because the "starting town" the
|
||
player picks is NOT the first position they see; it's where they
|
||
recall to after the tutorial.
|
||
|
||
---
|
||
|
||
## 8. Name validation
|
||
|
||
### 8.1 Client-side (retail)
|
||
|
||
From chunk_00470000.c:
|
||
- `ID_CharGen_NameTooLong` string ← `FUN_0047c140` checks length and
|
||
pops a modal if the typed name exceeds the limit. The limit is the
|
||
`PStringBase<byte>` max for character names: **20 characters** per
|
||
retail convention (hardcoded in the UI text field's char limit).
|
||
- The client filters the IME/keyboard input to only accept latin
|
||
alphanumeric plus apostrophe and hyphen. Whitespace is trimmed
|
||
on submit. Uppercase/lowercase preserved.
|
||
|
||
### 8.2 Server-side (ACE)
|
||
|
||
1. **Empty name** → rejected (holtburger enforces
|
||
`require_nonempty_name = true` by default).
|
||
2. **Taboo table match** → `CharacterGenerationVerificationResponse.NameBanned`.
|
||
ACE reads `DatManager.PortalDat.TabooTable` and checks
|
||
`ContainsBadWord(name.ToLowerInvariant())` (`CharacterHandler.cs:51`).
|
||
The taboo table is a dat file (profanity + reserved-name list).
|
||
3. **Creature name conflict** → also `NameBanned`
|
||
(`CharacterHandler.cs:57` — optional rule, default on).
|
||
4. **Name already in use** → `NameInUse`
|
||
(`CharacterHandler.cs:63, 149`).
|
||
5. **Admin/Sentinel name override** → server prefixes with `+` for
|
||
accounts with elevated access levels
|
||
(`CharacterHandler.cs:374-377`).
|
||
|
||
### 8.3 Case normalization
|
||
|
||
Case is preserved as typed. "Borelean" and "BORELEAN" are different
|
||
strings to the DB; typically only one is accepted first, the other
|
||
triggers `NameInUse`. Server compares case-insensitively for
|
||
availability but persists the typed casing.
|
||
|
||
---
|
||
|
||
## 9. CharCreate wire message layout (0xF656)
|
||
|
||
This is the **only** bit the client controls. The client sends a single
|
||
message, C2S opcode `0xF656` (`Character_SendCharGenResult`). Generated
|
||
schema from `Chorizite.ACProtocol/Types/CharGenResult.generated.cs`:
|
||
|
||
### 9.1 Header
|
||
|
||
```
|
||
UINT32 OpCode = 0xF656
|
||
```
|
||
|
||
Followed by outer message envelope (from `Messages/C2S/Character_SendCharGenResult.generated.cs`):
|
||
|
||
```
|
||
string16L Account // wide-prefixed length, UTF-16 LE
|
||
CharGenResult Result // the inline body described below
|
||
```
|
||
|
||
### 9.2 CharGenResult body
|
||
|
||
Reading in order (little-endian, no padding unless noted):
|
||
|
||
```
|
||
string16L Account // duplicated account name — ACE discards
|
||
UINT32 One // always 1 — "unknown constant"
|
||
BYTE HeritageGroup // 1..13 (HeritageGroup enum)
|
||
BYTE Gender // 1=Male, 2=Female
|
||
UINT32 EyesStrip // index into SexCG.EyeStrips
|
||
UINT32 NoseStrip // index into SexCG.NoseStrips
|
||
UINT32 MouthStrip // index into SexCG.MouthStrips
|
||
UINT32 HairColor // index into SexCG.HairColors (PalSet)
|
||
UINT32 EyeColor // index into SexCG.EyeColors (Palette)
|
||
UINT32 HairStyle // index into SexCG.HairStyles
|
||
UINT32 HeadgearStyle // 0xFFFFFFFF = no hat
|
||
UINT32 HeadgearColor // index into SexCG.ClothingColors
|
||
UINT32 ShirtStyle
|
||
UINT32 ShirtColor
|
||
UINT32 TrousersStyle
|
||
UINT32 TrousersColor
|
||
UINT32 FootwearStyle
|
||
UINT32 FootwearColor
|
||
UINT64 SkinShade // 8 bytes = IEEE 754 double, 0.0..1.0
|
||
UINT64 HairShade
|
||
UINT64 HeadgearShade
|
||
UINT64 ShirtShade
|
||
UINT64 TrousersShade
|
||
UINT64 TootwearShade // (sic — typo preserved in generator)
|
||
UINT32 TemplateNum // 0-based index into HeritageGroup.Templates
|
||
UINT32 Strength // 10..100
|
||
UINT32 Endurance
|
||
UINT32 Coordination
|
||
UINT32 Quickness
|
||
UINT32 Focus
|
||
UINT32 Self
|
||
UINT32 Slot // character slot on the server (0..max-1)
|
||
UINT32 ClassId // always 1 — legacy
|
||
PackableList<UINT32> Skills // 55-entry list, each = SkillAdvancementClass
|
||
// PackableList = compressed-uint count + items
|
||
string16L Name
|
||
UINT32 StartArea // 0-based index into CharGen.StartingAreas
|
||
UINT32 IsAdmin // 0 or 1
|
||
UINT32 IsEnvoy // 0 or 1 (aka IsSentinel on ACE side)
|
||
UINT32 Validation // xor/sum checksum, see §9.3
|
||
```
|
||
|
||
**ACE reads this slightly differently** (`CharacterCreateInfo.cs`)
|
||
— the "Unknown constant (1)" is read as a UINT32, and the heritage /
|
||
gender are read as UINT32 each instead of bytes. **These two formats
|
||
must match one of them byte-for-byte.** The Chorizite generator is
|
||
authoritative because it's generated from the protocol XML used by
|
||
both retail and ACE. ACE's `BinaryReader.ReadUInt32()` for fields the
|
||
XML declares as BYTE actually works because they're the first 4 bytes
|
||
of a little-endian dword with the other 3 bytes being part of the next
|
||
field — but the ACE code then advances 4 bytes, misaligning if the
|
||
sizes were really bytes. **Trust Chorizite.**
|
||
|
||
In practice: there's a **known discrepancy** between Chorizite and ACE
|
||
on heritage/gender width. Empirical pcaps from retail server will
|
||
resolve it. Until then, acdream should send matching ACE's format
|
||
(UINT32 each) because ACE is the target server. Add a switchable
|
||
serializer.
|
||
|
||
### 9.3 Validation checksum
|
||
|
||
The `Validation` field is described by the protocol XML as:
|
||
|
||
> "Seems to be the total of heritageGroup, gender, eyesStrip,
|
||
> noseStrip, mouthStrip, hairColor, eyeColor, hairStyle,
|
||
> headgearStyle, shirtStyle, trousersStyle, footwearStyle,
|
||
> templateNum, strength, endurance, coordination, quickness, focus,
|
||
> self. Perhaps used for some kind of validation?"
|
||
|
||
ACE **ignores** this field — it's client-side anti-tamper. Retail
|
||
client summed specific fields (no shade doubles, no colors except hair/eye,
|
||
no slot, no name, no skills) — probably to catch byte-twiddling
|
||
hacks. acdream computes the same sum for bug-for-bug parity with the
|
||
live retail server (ACE doesn't care; retail-emu or GDL may).
|
||
|
||
Exact formula (from XML description order):
|
||
|
||
```
|
||
validation = heritageGroup + gender
|
||
+ eyesStrip + noseStrip + mouthStrip
|
||
+ hairColor + eyeColor + hairStyle
|
||
+ headgearStyle + shirtStyle + trousersStyle + footwearStyle
|
||
+ templateNum
|
||
+ strength + endurance + coordination + quickness + focus + self
|
||
```
|
||
|
||
All as u32 wrapping add; overflow discarded.
|
||
|
||
---
|
||
|
||
## 10. Server response (0xF643)
|
||
|
||
S2C `Character_CharGenVerificationResponse`. Schema from Chorizite:
|
||
|
||
```
|
||
UINT32 OpCode = 0xF643
|
||
UINT32 ResponseType (CharGenResponseType)
|
||
if ResponseType == OK (1):
|
||
UINT32 CharacterId
|
||
string16L Name
|
||
UINT32 SecondsUntilDeletion
|
||
align(4)
|
||
```
|
||
|
||
`CharGenResponseType` values (note gap):
|
||
|
||
```csharp
|
||
public enum CharGenResponseType : uint {
|
||
OK = 0x0001,
|
||
NameInUse = 0x0003,
|
||
NameBanned = 0x0004,
|
||
Corrupt = 0x0005, // character data rejected
|
||
Corrupt_0x0006 = 0x0006, // "DatabaseDown" aka "try again later"
|
||
AdminPrivilegeDenied = 0x0007,
|
||
}
|
||
```
|
||
|
||
ACE's `CharacterGenerationVerificationResponse` enum has extra values
|
||
(`Undef=0`, `Pending=2`, plus `Count`) that don't appear on the wire —
|
||
they're for server-internal bookkeeping. Only the 0x0001..0x0007 values
|
||
go out.
|
||
|
||
AC2D confirms the error messages (`cNetwork.cpp:1433`):
|
||
- 0x03 "The name you have chosen … is already in use"
|
||
- 0x04 "Sorry, but that name is not permitted."
|
||
- 0x05 "… found an unexplained error with this new character. The data
|
||
may be corrupt or out of date."
|
||
- 0x06 "… cannot create your new character at this time. Please try
|
||
again later."
|
||
- 0x07 "Sorry, but you do not have the privileges to make an
|
||
administrator character."
|
||
|
||
On success the server **also** pushes a refreshed character list
|
||
(S2C `Character_CharacterList`, opcode varies) so the new character
|
||
appears in the slot selection. The client should not refetch on its
|
||
own — it waits for the server's push.
|
||
|
||
---
|
||
|
||
## 11. Preview renderer
|
||
|
||
During chargen the client renders the character on a rotating pedestal
|
||
in a small 3D viewport inset. Key observations from the decompiled
|
||
CharGen panel ctor (`FUN_0047aa10` at 0x0047AA10):
|
||
|
||
- The ctor wires up a scene using `FUN_0045a910(puStack_4, 0, 0, 1)` —
|
||
this is the panel's scene graph container with flags indicating a
|
||
character preview.
|
||
- It stores an `FUN_0043c680` device pointer and asks for viewport
|
||
region registration (`0x186a1`, `0x186a2` → region IDs for the
|
||
character window and a child area).
|
||
- A 4000ms timer is registered: `(**(code**)(*DAT_00837ff4 + 0x34))
|
||
(0xe, param_1 + 2, 4000)`. This is the **preview rotation timer** —
|
||
ticks every 4 seconds to update the character's Y-axis rotation by
|
||
a small delta (retail rotates slowly, ~15°/sec observed).
|
||
|
||
Implementation plan (**ported, not invented**):
|
||
|
||
1. **Scene setup.** When the panel activates with (heritage, gender),
|
||
read `HeritageGroupCG.EnvironmentSetupId` from dat → this is the
|
||
pedestal / backdrop Setup. Instantiate it at the origin.
|
||
2. **Character model.** Read `SexCG.SetupId` (with `HairStyleCG.AlternateSetup`
|
||
override if non-zero). Build the ObjDesc from `SexCG.BaseObjDesc`
|
||
overlaid with `HairStyleCG.ObjDesc`, `EyeStripCG.ObjDesc` (bald or
|
||
standard), `FaceStripCG.ObjDesc` (nose), `FaceStripCG.ObjDesc`
|
||
(mouth). Apply palette/hue overrides same as a world player.
|
||
Place at origin offset slightly above pedestal.
|
||
3. **Camera.** Fixed orbit at ~2m radius, ~1.5m eye height, looking
|
||
at character head. Retail camera does NOT pan or zoom during
|
||
chargen — only the character rotates.
|
||
4. **Rotation driver.** Per-frame Y rotation using `Time.Delta *
|
||
rotationSpeed`. Retail speed is ~0.26 rad/sec (15°/sec). The 4000ms
|
||
timer is not for rotation continuity (that's per-frame) — it's
|
||
probably a "full revolution" tick for state inspection.
|
||
5. **Lighting.** One directional key-light from camera-right-up, a
|
||
fill from camera-left. Ambient ~0.3. This matches retail's
|
||
flat-but-readable lighting in the chargen inset.
|
||
|
||
---
|
||
|
||
## 12. Chat commands vs UI
|
||
|
||
**Retail is UI-only for chargen.** There are no `/heritage` or
|
||
`/template` chat commands. The only text the user types is the
|
||
character's name. All other selection is click-driven.
|
||
|
||
**What acdream should add (not retail):**
|
||
|
||
- The plugin API lives above the CharGen state machine. A plugin could
|
||
set the state programmatically (useful for "clone this character's
|
||
appearance" tools), but the stock UI is click-driven for parity.
|
||
- For QA we should support a `--chargen-template=aluvian:swordsman`
|
||
command-line flag that pre-fills the form for rapid iteration.
|
||
|
||
---
|
||
|
||
## 13. Full flow timeline
|
||
|
||
```
|
||
1. User clicks "Create new character" in charlist panel.
|
||
2. CharGenPanel opens (FUN_0047aa10 ctor):
|
||
- read CharGen dat (0xE000002) → StartingAreas + HeritageGroups.
|
||
- default heritage = Aluvian (id 1).
|
||
- default gender = Male (id 1).
|
||
- default template = first template (index 0).
|
||
- default start town = heritage.PrimaryStartAreas[0].
|
||
- pre-fill attributes from template.
|
||
- pre-fill skills from template (primary = Spec, normal = Trained).
|
||
3. User iterates on UI:
|
||
- heritage cycle → reload templates, reset start town, reset
|
||
attributes + skills.
|
||
- gender cycle → reload appearance option lists.
|
||
- template cycle → reset attributes + skills.
|
||
- attribute +/- → update remaining_attribute_points.
|
||
- skill raise/lower → update remaining_skill_credits.
|
||
- appearance tweaks → rebuild preview ObjDesc.
|
||
- random appearance button → confirm dialog (ID_CharGen_RandomizeWarning),
|
||
then reroll all indices + hues.
|
||
- name field → each keystroke, check length ≤ 20 (client-side).
|
||
4. User clicks "Create":
|
||
- client-side validation: all budgets spent, name non-empty,
|
||
name length ≤ 20.
|
||
- build CharGenResult struct, compute Validation checksum.
|
||
- send 0xF656 Character_SendCharGenResult with account + result.
|
||
5. Server responds 0xF643:
|
||
- OK → push refreshed CharacterList, close CharGenPanel, return to
|
||
slot selection.
|
||
- NameInUse / NameBanned → show modal with ID_CharGen_Name{InUse,Banned}
|
||
string, stay on CharGenPanel with name field re-focused.
|
||
- Corrupt / Corrupt_0x0006 → show modal "character data is corrupt,
|
||
please regenerate", reset form.
|
||
- AdminPrivilegeDenied → modal "you do not have privileges",
|
||
un-check admin box.
|
||
6. On OK, user selects new char in list → 0xF657 Login_SendEnterWorld
|
||
→ enter world.
|
||
```
|
||
|
||
---
|
||
|
||
## 14. acdream port plan (C# .NET 10)
|
||
|
||
### 14.1 Dat layer (already in DatReaderWriter, consume via NuGet-style link)
|
||
|
||
No port needed — the generated `CharGen`, `HeritageGroupCG`,
|
||
`TemplateCG`, `SexCG`, `StartingArea`, `SkillCG`, `HairStyleCG`,
|
||
`FaceStripCG`, `EyeStripCG`, `GearCG` classes are already part of the
|
||
`references/DatReaderWriter` project, which acdream can reference
|
||
directly.
|
||
|
||
### 14.2 Content layer (`Acdream.Game.CharGen`)
|
||
|
||
Mirror holtburger-content's flatten step. Reads the raw dat types and
|
||
produces snapshot objects friendlier for UI binding:
|
||
|
||
```csharp
|
||
namespace Acdream.Game.CharGen;
|
||
|
||
public sealed class CharGenCatalog
|
||
{
|
||
public IReadOnlyList<StartingArea> StartingAreas { get; }
|
||
public IReadOnlyDictionary<uint, Heritage> Heritages { get; }
|
||
public IReadOnlyDictionary<uint, SkillDefinition> Skills { get; }
|
||
public int ExpectedSkillSlots { get; } // 55 for EoR
|
||
|
||
public static CharGenCatalog FromDat(CharGen charGen, SkillTable skillTable);
|
||
public Heritage? GetHeritage(uint id);
|
||
public StartingArea? GetStartingArea(uint id);
|
||
public SkillCosts? GetSkillCosts(uint heritageId, uint skillId);
|
||
public IReadOnlyList<uint> AllowedStartAreaIds(uint heritageId);
|
||
}
|
||
|
||
public sealed class Heritage
|
||
{
|
||
public uint Id { get; }
|
||
public string Name { get; } // "Aluvian"
|
||
public uint IconId { get; }
|
||
public uint SetupId { get; }
|
||
public uint EnvironmentSetupId { get; } // backdrop scene
|
||
public uint AttributeCredits { get; } // 330, Olthoi=more
|
||
public uint SkillCredits { get; } // 52, Olthoi=68
|
||
public IReadOnlyList<int> PrimaryStartAreas { get; }
|
||
public IReadOnlyList<int> SecondaryStartAreas { get; }
|
||
public IReadOnlyDictionary<uint, SkillCostsOverride> SkillOverrides { get; }
|
||
public IReadOnlyList<Template> Templates { get; }
|
||
public IReadOnlyDictionary<uint, GenderAppearance> Genders { get; }
|
||
}
|
||
|
||
public sealed class Template
|
||
{
|
||
public int Option { get; } // index
|
||
public string Name { get; } // "Swordsman", "Custom"
|
||
public uint IconId { get; }
|
||
public uint TitleId { get; }
|
||
public int Strength, Endurance, Coordination, Quickness, Focus, Self;
|
||
public IReadOnlyList<uint> NormalSkills { get; } // → Trained at start
|
||
public IReadOnlyList<uint> PrimarySkills { get; } // → Specialized
|
||
}
|
||
|
||
public sealed class GenderAppearance
|
||
{
|
||
public uint Id { get; } // 1=Male, 2=Female
|
||
public string Name { get; }
|
||
public uint Scale { get; }
|
||
public uint SetupId { get; }
|
||
public uint SoundTableId { get; }
|
||
public uint MotionTableId { get; }
|
||
public uint CombatTableId { get; }
|
||
public uint PhysicsTableId { get; }
|
||
public uint BasePaletteId { get; }
|
||
public uint SkinPalSetId { get; }
|
||
public ObjDesc BaseObjDesc { get; }
|
||
public IReadOnlyList<uint> HairColorPalSets { get; }
|
||
public IReadOnlyList<HairStyle> HairStyles { get; }
|
||
public IReadOnlyList<uint> EyeColorPalettes { get; }
|
||
public IReadOnlyList<EyeStrip> EyeStrips { get; }
|
||
public IReadOnlyList<FaceStrip> NoseStrips { get; }
|
||
public IReadOnlyList<FaceStrip> MouthStrips { get; }
|
||
public IReadOnlyList<Gear> Headgears { get; }
|
||
public IReadOnlyList<Gear> Shirts { get; }
|
||
public IReadOnlyList<Gear> Pants { get; }
|
||
public IReadOnlyList<Gear> Footwear { get; }
|
||
public IReadOnlyList<uint> ClothingColorPalSets { get; }
|
||
}
|
||
```
|
||
|
||
### 14.3 Builder / validator (`Acdream.Game.CharGen.Builder`)
|
||
|
||
Port of holtburger-core's `CharacterGenBuilder`. Collects errors during
|
||
validation (does not throw on first), matches the 4 retail client-side
|
||
checks AND ACE's 3 server-side extras:
|
||
|
||
```csharp
|
||
public enum CharGenValidationError
|
||
{
|
||
EmptyName,
|
||
NameTooLong, // > 20 chars
|
||
DisabledHeritage,
|
||
UnknownHeritage,
|
||
UnknownGender,
|
||
InvalidTemplateOption,
|
||
InvalidStartArea,
|
||
StartAreaNotAllowedForHeritage,
|
||
AttributeOutOfRange,
|
||
AttributeBudgetExceeded,
|
||
AttributeBudgetIncomplete,
|
||
SkillSlotCountMismatch,
|
||
UnknownSkill,
|
||
SkillUnavailableAtCharacterCreation,
|
||
SkillBudgetExceeded,
|
||
AppearanceChoiceOutOfRange,
|
||
AdminFlagNotAllowed,
|
||
SentinelFlagNotAllowed,
|
||
}
|
||
|
||
public sealed class CharGenBuilder
|
||
{
|
||
public CharGenBuilder(CharGenCatalog catalog, CharGenPolicy policy = null);
|
||
|
||
public IReadOnlyList<CharGenValidationError> Validate(CharGenBuild build);
|
||
public CharCreateRequest BuildRequest(CharGenBuild build);
|
||
public AppearanceSelection RandomizeAppearance(uint heritageId, uint genderId);
|
||
}
|
||
|
||
public sealed record CharGenPolicy(
|
||
uint UnknownConstant = 1,
|
||
uint ClassId = 1,
|
||
bool AllowAdminFlag = false,
|
||
bool AllowSentinelFlag = false,
|
||
ISet<uint> DisabledHeritages = null,
|
||
bool EnforceHeritageStartAreaMembership = false,
|
||
bool RequireNonEmptyName = true,
|
||
int MaxNameLength = 20);
|
||
```
|
||
|
||
Constants:
|
||
|
||
```csharp
|
||
public static class CharGenConstants
|
||
{
|
||
public const uint MinAttribute = 10;
|
||
public const uint MaxAttribute = 100;
|
||
public const int UnavailableSkillCostSentinel = 999;
|
||
public const int ExpectedSkillSlotsEor = 55;
|
||
}
|
||
```
|
||
|
||
### 14.4 Wire model (`Acdream.Network.Messages`)
|
||
|
||
Port the Chorizite generator output directly — it is already idiomatic
|
||
C#. `CharCreateRequest` = `CharGenResult`. Write as:
|
||
|
||
```csharp
|
||
public sealed class CharCreateRequest
|
||
{
|
||
public string Account { get; set; }
|
||
public uint UnknownConstant { get; set; } = 1;
|
||
public HeritageGroup Heritage { get; set; }
|
||
public uint Gender { get; set; } // 1 or 2
|
||
public AppearanceSelection Appearance { get; set; }
|
||
public int TemplateOption { get; set; }
|
||
public uint Strength, Endurance, Coordination, Quickness, Focus, Self;
|
||
public uint Slot { get; set; }
|
||
public uint ClassId { get; set; } = 1;
|
||
public IReadOnlyList<SkillAdvancementClass> SkillAdvancementClasses { get; set; }
|
||
public string Name { get; set; }
|
||
public uint StartArea { get; set; }
|
||
public bool IsAdmin { get; set; }
|
||
public bool IsSentinel { get; set; }
|
||
|
||
public uint ComputeValidation() => unchecked(
|
||
(uint)Heritage + Gender
|
||
+ Appearance.Eyes + Appearance.Nose + Appearance.Mouth
|
||
+ Appearance.HairColor + Appearance.EyeColor + Appearance.HairStyle
|
||
+ Appearance.HeadgearStyle + Appearance.ShirtStyle
|
||
+ Appearance.PantsStyle + Appearance.FootwearStyle
|
||
+ (uint)TemplateOption
|
||
+ Strength + Endurance + Coordination + Quickness + Focus + Self);
|
||
|
||
public void Serialize(BinaryWriter w)
|
||
{
|
||
// matches CharGenResult.Write in Chorizite
|
||
w.WriteString16L(Account);
|
||
w.Write(UnknownConstant);
|
||
w.Write((byte)Heritage); // 1 byte per Chorizite
|
||
w.Write((byte)Gender);
|
||
Appearance.Serialize(w);
|
||
w.Write(TemplateOption);
|
||
w.Write(Strength); w.Write(Endurance); w.Write(Coordination);
|
||
w.Write(Quickness); w.Write(Focus); w.Write(Self);
|
||
w.Write(Slot); w.Write(ClassId);
|
||
w.WritePackableList(SkillAdvancementClasses, (bw, sac) => bw.Write((uint)sac));
|
||
w.WriteString16L(Name);
|
||
w.Write(StartArea);
|
||
w.Write(IsAdmin ? 1u : 0u);
|
||
w.Write(IsSentinel ? 1u : 0u);
|
||
w.Write(ComputeValidation());
|
||
}
|
||
}
|
||
|
||
public sealed class AppearanceSelection
|
||
{
|
||
public uint Eyes, Nose, Mouth;
|
||
public uint HairColor, EyeColor, HairStyle;
|
||
public uint HeadgearStyle = 0xFFFFFFFF; // no-hat sentinel
|
||
public uint HeadgearColor;
|
||
public uint ShirtStyle, ShirtColor;
|
||
public uint PantsStyle, PantsColor;
|
||
public uint FootwearStyle, FootwearColor;
|
||
public double SkinHue, HairHue, HeadgearHue;
|
||
public double ShirtHue, PantsHue, FootwearHue;
|
||
|
||
public void Serialize(BinaryWriter w)
|
||
{
|
||
w.Write(Eyes); w.Write(Nose); w.Write(Mouth);
|
||
w.Write(HairColor); w.Write(EyeColor); w.Write(HairStyle);
|
||
w.Write(HeadgearStyle); w.Write(HeadgearColor);
|
||
w.Write(ShirtStyle); w.Write(ShirtColor);
|
||
w.Write(PantsStyle); w.Write(PantsColor);
|
||
w.Write(FootwearStyle); w.Write(FootwearColor);
|
||
w.Write(SkinHue); w.Write(HairHue);
|
||
w.Write(HeadgearHue); w.Write(ShirtHue);
|
||
w.Write(PantsHue); w.Write(FootwearHue);
|
||
}
|
||
}
|
||
```
|
||
|
||
**ACE compatibility note:** if the target server is ACE, switch
|
||
heritage/gender to UINT32 each. Feature-flag via `CharGenPolicy`. Keep
|
||
both serializers and pick one at build time.
|
||
|
||
### 14.5 Panel (`Acdream.UI.Panels.CharGen`)
|
||
|
||
Uses our UI framework (see `docs/plans/` retail UI deep-dive). The
|
||
panel owns a `CharGenFormState` (mirror of holtburger's
|
||
`CharacterCreationFormState`) with fields:
|
||
|
||
```csharp
|
||
public sealed class CharGenFormState
|
||
{
|
||
public uint HeritageId;
|
||
public uint GenderId;
|
||
public uint StartAreaId;
|
||
public int TemplateOption;
|
||
public uint[] AttributeValues = new uint[6];
|
||
public SkillAdvancementClass[] SkillAdvancementClasses;
|
||
public AppearanceSelection Appearance;
|
||
public string Name = "";
|
||
|
||
public uint AttributeBudget { get; }
|
||
public long RemainingAttributePoints { get; }
|
||
public long SkillBudget { get; }
|
||
public long SpentSkillPoints { get; }
|
||
public long RemainingSkillPoints { get; }
|
||
|
||
public void ResetForHeritage();
|
||
public void ResetForTemplate(int templateOption);
|
||
public bool CycleHeritage(int delta);
|
||
public bool AdjustAttribute(int index, int delta);
|
||
public bool RaiseSkill(uint skillId);
|
||
public bool LowerSkill(uint skillId);
|
||
public bool SetAppearance(AppearanceField field, uint value);
|
||
public bool Randomize();
|
||
}
|
||
|
||
public enum AppearanceField {
|
||
Eyes, Nose, Mouth,
|
||
HairColor, HairStyle, EyeColor,
|
||
HeadgearStyle, HeadgearColor, HeadgearHue,
|
||
ShirtStyle, ShirtColor, ShirtHue,
|
||
PantsStyle, PantsColor, PantsHue,
|
||
FootwearStyle, FootwearColor, FootwearHue,
|
||
SkinHue, HairHue,
|
||
}
|
||
```
|
||
|
||
Sub-panels (all children of `CharGenPanel`):
|
||
|
||
1. `HeritageSelector` — grid of heritage icons with tooltips.
|
||
2. `GenderSelector` — two buttons.
|
||
3. `TemplateSelector` — scrolling list of template icons.
|
||
4. `AttributeEditor` — 6 rows, `<` `value` `>` with running total.
|
||
5. `SkillEditor` — 4 groups (Specialized, Trained, Usable Untrained,
|
||
Unusable Untrained) with per-skill up/down, grouped by category.
|
||
6. `AppearancePicker` — tabbed face/body/hair/clothing with
|
||
per-selector arrows.
|
||
7. `NameField` — 20-char input with client-side length check.
|
||
8. `StartTownSelector` — 1..4 buttons with flavor-text panel.
|
||
9. `SummaryPanel` — mirrors `FUN_0047b590` output (profession, gender,
|
||
heritage, town, 10-row attributes/vitals, 4-group skill list).
|
||
10. `PreviewRenderer` — inset 3D viewport (see §11).
|
||
11. `ActionButtons` — Random, Back, Create.
|
||
|
||
Each sub-panel emits domain events (`HeritageChanged`,
|
||
`AttributeAdjusted`) that the root panel routes into
|
||
`CharGenFormState`. The `CreateButton.Enabled` is computed from
|
||
`FormState.Validate(catalog).IsEmpty`.
|
||
|
||
### 14.6 Integration with plugin API
|
||
|
||
```csharp
|
||
public interface ICharGen
|
||
{
|
||
CharGenCatalog Catalog { get; }
|
||
CharGenFormState Form { get; } // live form state
|
||
event Action<CharGenFormState> FormChanged;
|
||
Task<CharGenResponseType> SubmitAsync(CancellationToken ct);
|
||
}
|
||
```
|
||
|
||
A plugin can inspect the current form, autofill it
|
||
(`Form.HeritageId = 1; Form.Name = "Auto";` etc.), and call
|
||
`SubmitAsync()` programmatically. This is a non-retail capability by
|
||
design — the plugin surface is the net-new thing acdream provides.
|
||
|
||
---
|
||
|
||
## 15. Port order within R7
|
||
|
||
Suggested order (each step ships behind a feature flag, visually
|
||
verified before moving on):
|
||
|
||
1. **Dat reads.** Unit tests for `CharGenCatalog.FromDat` on the EoR
|
||
`client_portal.dat`. Assert Aluvian skill credits == 52, 7
|
||
templates, ArcaneLore override. Pin all 13 heritage names. Pin
|
||
all 5 starting area names.
|
||
2. **Validator.** Port holtburger's validator + tests. Include the
|
||
"skill delta cost" math and the "template minimum" check.
|
||
3. **Wire serializer.** Round-trip `CharCreateRequest` bytes against
|
||
Chorizite's generator on synthetic inputs. Verify the Validation
|
||
checksum against an ACE packet capture if available; otherwise
|
||
pin a golden value per the formula in §9.3.
|
||
4. **Form state.** TUI-style CLI (like holtburger-cli) first — no
|
||
graphics, just stdin/stdout. Prove the form-state + validator
|
||
flow end-to-end.
|
||
5. **UI panels.** Build each sub-panel. The preview renderer is the
|
||
last piece; stub it with a placeholder sprite until the character
|
||
ObjDesc pipeline from R4/R5 is ready.
|
||
6. **Server round-trip.** Connect to ACE, go through login, press
|
||
Create, handle OK response, refresh charlist. Visual verification
|
||
= "new character appears in slot selector".
|
||
7. **Error handling.** Force each error path (name too long, name in
|
||
use by creating the same name twice, disabled heritage via
|
||
policy). Verify modal text matches retail strings.
|
||
|
||
---
|
||
|
||
## 16. Open questions
|
||
|
||
- **Heritage/gender byte vs dword on the wire.** Chorizite says byte
|
||
each, ACE says dword each. A live pcap against retail-era server
|
||
would resolve this immediately. For now: go dword to match ACE.
|
||
- **Name max length.** 20 chars is retail convention but not
|
||
documented in the dat. Verify from pcap; treat as tunable in
|
||
`CharGenPolicy.MaxNameLength`.
|
||
- **Free Ride spell on first login.** Out of scope for R7 (R8
|
||
world-entry territory), but noted: server sends a teleport spell
|
||
after CharacterCreate, so the first spawn-in may show a different
|
||
location than the chosen starting town. Do not chase this as a
|
||
bug during R7 testing.
|
||
- **Olthoi play gating.** ACE has `olthoi_play_disabled` property;
|
||
retail had Olthoi play as a special event. acdream default should
|
||
be "Olthoi heritage hidden unless server announces support" —
|
||
plumb via a server capability flag later.
|
||
- **Preview background environment.** `EnvironmentSetupId` per
|
||
heritage — need to decode what these actually are (a setup is a
|
||
model tree; the chargen backdrop is "an Empyrean plinth for
|
||
Empyreans, a stone circle for Aluvians," etc.). Confirm by
|
||
instantiating each one and visually checking in R4 renderer.
|
||
|
||
---
|
||
|
||
## 17. Summary for the parent agent
|
||
|
||
acdream's R7 character creation is a thin C# state machine over the
|
||
dat file `0xE000002 CharGen`. The retail client does **nothing
|
||
interesting** except read the dat, present it on-screen, validate the
|
||
user's picks client-side, and send a single 0xF656
|
||
`Character_SendCharGenResult` packet with the distilled choices.
|
||
All constants — attribute budget 330, skill budget 52/68,
|
||
attribute range 10–100, skill costs, heritage-specific overrides,
|
||
appearance option counts, starting town positions — live in the dat.
|
||
|
||
Port plan: dat → `CharGenCatalog` → `CharGenBuilder` (validator +
|
||
builder) → `CharGenFormState` (UI model) → panels + preview renderer.
|
||
The validator is the one place correctness matters (skill credit
|
||
accumulation including heritage overrides and template minimums);
|
||
everything else is data-driven UI.
|
||
|
||
holtburger-core's `character_gen.rs` is the closest working
|
||
reference; port it idiom-for-idiom to C# and adapt it to our UI
|
||
framework. ACE's `PlayerFactory.cs` and `CharacterCreateInfo.cs` are
|
||
the server-side counterpart — trust them for expected field widths
|
||
and validation semantics.
|
||
|
||
---
|
||
|
||
*Last updated: 2026-04-18 (R7 deep-dive for parallel research pass)*
|